@diagrammo/dgmo 0.25.0 → 0.25.2

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.
@@ -46,13 +46,7 @@ import {
46
46
  CAPTION_BOX_PADDING_X,
47
47
  CAPTION_BOX_PADDING_Y,
48
48
  } from '../utils/title-constants';
49
- import {
50
- LEGEND_HEIGHT as LEGEND_HEIGHT_CONST,
51
- LEGEND_PILL_FONT_SIZE as LEGEND_PILL_FONT_SIZE_CONST,
52
- LEGEND_ENTRY_DOT_GAP as LEGEND_ENTRY_DOT_GAP_CONST,
53
- LEGEND_DOT_R as LEGEND_DOT_R_CONST,
54
- measureLegendText,
55
- } from '../utils/legend-constants';
49
+ import { LEGEND_HEIGHT as LEGEND_HEIGHT_CONST } from '../utils/legend-constants';
56
50
  import { resolveActiveTagGroup, resolveTagColor } from '../utils/tag-groups';
57
51
  import { renderLegendD3 } from '../utils/legend-d3';
58
52
  import type {
@@ -126,10 +120,6 @@ const CONTAINER_HEADER_HEIGHT = 28;
126
120
  // Collapse-bar height — see conventions doc §3 Pattern A/B (matches
127
121
  // org's `COLLAPSE_BAR_HEIGHT`). Universal "this is collapsed" signal.
128
122
  const COLLAPSE_BAR_HEIGHT = 6;
129
- // Fade applied to non-critical elements when the Critical Path toggle
130
- // is on. Matches gantt's `FADE_OPACITY` (renderer.ts:1815) so the same
131
- // "spotlight" effect reads consistently across diagrams.
132
- const FADE_OPACITY = 0.15;
133
123
  // Always-on fade applied to bottom-20% (by duration) activity nodes
134
124
  // so the eye is drawn to the longer, schedule-dominating work first.
135
125
  // Less aggressive than FADE_OPACITY because these cards still need to
@@ -141,16 +131,11 @@ const DURATION_FADE_OPACITY = 0.55;
141
131
  const PIN_ICON_W = 13;
142
132
  const PIN_ICON_H = 13;
143
133
 
144
- // Top legenda horizontal row of pills (Critical Path, Anchor,
145
- // Milestone) that sits between the title and the diagram body. Pills
146
- // match the visual conventions of the shared `renderLegendD3` legend
147
- // used by Cycle/Mindmap/BoxesAndLines (see `utils/legend-constants.ts`):
148
- // 28px tall, 11pt label, fully-rounded rx, mix-fill against surface.
134
+ // Legend pill height matches the shared `renderLegendD3` legend used
135
+ // by Cycle/Mindmap/BoxesAndLines (see `utils/legend-constants.ts`):
136
+ // 28px tall, fully-rounded rx, mix-fill against surface. Drives the
137
+ // tag-group legend block's reserved height.
149
138
  const LEGEND_PILL_HEIGHT = LEGEND_HEIGHT_CONST;
150
- const LEGEND_PILL_PADDING_X = 8;
151
- const LEGEND_PILL_GAP = 8;
152
- const LEGEND_SWATCH_GAP = LEGEND_ENTRY_DOT_GAP_CONST;
153
- const LEGEND_FONT_SIZE = LEGEND_PILL_FONT_SIZE_CONST;
154
139
  // Top gap is the breathing room between the title baseline (or canvas
155
140
  // top when there's no title) and the pill row. Bottom gap separates
156
141
  // the pills from the diagram body. Together with LEGEND_PILL_HEIGHT
@@ -371,14 +356,14 @@ export interface PertRenderOptions {
371
356
  */
372
357
  showFieldLegend?: boolean;
373
358
  /**
374
- * Render the top legend (Critical Path / Anchor / Milestone pills)
375
- * inside the SVG, between the title and the diagram. Defaults to
376
- * true so CLI exports and share-link images include the legend; the
377
- * desktop preview flips it off and renders the legend in a sibling
378
- * native-pixel SVG instead, so the pill text stays at intended size
379
- * even when the diagram SVG gets scale-to-fit'd into the panel.
359
+ * Render the tag-group legend inside the SVG, between the title and
360
+ * the diagram. Defaults to true so CLI exports and share-link images
361
+ * include it; the desktop preview flips it off and renders the legend
362
+ * in a sibling native-pixel SVG instead, so the pill text stays at
363
+ * intended size even when the diagram SVG gets scale-to-fit'd into the
364
+ * panel.
380
365
  */
381
- showTopLegend?: boolean;
366
+ showLegend?: boolean;
382
367
  /**
383
368
  * Render the project-stats Summary box below the diagram. Defaults
384
369
  * to true so CLI exports / share-link images keep showing it; the
@@ -460,21 +445,16 @@ export function renderPert(
460
445
  ? CAPTION_TOP_GAP +
461
446
  fieldLegendHeightFor(standaloneFieldLegendWidthForExport)
462
447
  : 0;
463
- const showTopLegend = options.showTopLegend ?? true;
464
- const legendEntries = showTopLegend ? pertLegendEntries(resolved) : [];
448
+ const showLegend = options.showLegend ?? true;
465
449
  const tagLegendActive = resolveActiveTagGroup(
466
450
  resolved.tagGroups,
467
451
  resolved.options.activeTag,
468
452
  options.activeTagOverride
469
453
  );
470
- const showTagLegend = showTopLegend && resolved.tagGroups.length > 0;
471
- const tagLegendBlockHeight = showTagLegend
472
- ? LEGEND_PILL_HEIGHT + LEGEND_BOTTOM_GAP
454
+ const showTagLegend = showLegend && resolved.tagGroups.length > 0;
455
+ const legendBlockHeight = showTagLegend
456
+ ? LEGEND_TOP_GAP + LEGEND_PILL_HEIGHT + LEGEND_BOTTOM_GAP
473
457
  : 0;
474
- const legendBlockHeight =
475
- (legendEntries.length > 0
476
- ? LEGEND_TOP_GAP + LEGEND_PILL_HEIGHT + LEGEND_BOTTOM_GAP
477
- : 0) + tagLegendBlockHeight;
478
458
 
479
459
  const naturalChartWidth = layout.width + DIAGRAM_PADDING * 2;
480
460
  const minAnalysisRowW = analysisLayer.analysisHasContent
@@ -512,13 +492,9 @@ export function renderPert(
512
492
  const sLegendTopGap = ctx.aesthetic(LEGEND_TOP_GAP);
513
493
  const sLegendBottomGap = ctx.aesthetic(LEGEND_BOTTOM_GAP);
514
494
  const sLegendPillHeight = ctx.structural(LEGEND_PILL_HEIGHT);
515
- const sTagLegendBlockHeight = showTagLegend
516
- ? sLegendPillHeight + sLegendBottomGap
495
+ const sLegendBlockHeight = showTagLegend
496
+ ? sLegendTopGap + sLegendPillHeight + sLegendBottomGap
517
497
  : 0;
518
- const sLegendBlockHeight =
519
- (legendEntries.length > 0
520
- ? sLegendTopGap + sLegendPillHeight + sLegendBottomGap
521
- : 0) + sTagLegendBlockHeight;
522
498
  const sNodeRadius = ctx.structural(NODE_RADIUS);
523
499
  const sNodeStrokeWidth = ctx.structural(NODE_STROKE_WIDTH);
524
500
  const sNodeFontSize = ctx.text(NODE_FONT_SIZE);
@@ -587,22 +563,8 @@ export function renderPert(
587
563
  const offsetX = Math.max(sDiagramPad, (svgW - layout.width) / 2);
588
564
  const offsetY = sDiagramPad + sTitleHeight + sLegendBlockHeight;
589
565
 
590
- if (legendEntries.length > 0) {
591
- renderLegendBlock(svg, legendEntries, {
592
- x: 0,
593
- y: sDiagramPad + sTitleHeight + sLegendTopGap,
594
- width: svgW,
595
- palette,
596
- isDark,
597
- });
598
- }
599
566
  if (showTagLegend) {
600
- const tagLegendY =
601
- sDiagramPad +
602
- sTitleHeight +
603
- (legendEntries.length > 0
604
- ? sLegendTopGap + sLegendPillHeight
605
- : sLegendTopGap);
567
+ const tagLegendY = sDiagramPad + sTitleHeight + sLegendTopGap;
606
568
  renderTagLegendRow(svg, resolved, palette, isDark, {
607
569
  x: 0,
608
570
  y: tagLegendY,
@@ -720,11 +682,10 @@ export function renderPertForExport(
720
682
  : 0;
721
683
  const captionBlockHeight =
722
684
  captionBullets.length > 0 ? CAPTION_TOP_GAP + captionBoxHeight : 0;
723
- // Mirror renderPert's top-legend reservation so the offscreen
685
+ // Mirror renderPert's tag-legend reservation so the offscreen
724
686
  // container matches the natural canvas height.
725
- const legendEntries = pertLegendEntries(resolved);
726
687
  const legendBlockHeight =
727
- legendEntries.length > 0
688
+ resolved.tagGroups.length > 0
728
689
  ? LEGEND_TOP_GAP + LEGEND_PILL_HEIGHT + LEGEND_BOTTOM_GAP
729
690
  : 0;
730
691
  const exportWidth = layout.width + DIAGRAM_PADDING * 2;
@@ -2502,131 +2463,6 @@ function computeAnchorPinSet(resolved: ResolvedPert): Set<string> {
2502
2463
  return pinned;
2503
2464
  }
2504
2465
 
2505
- // ============================================================
2506
- // Section: critical-path highlight (React-callable)
2507
- // ============================================================
2508
- //
2509
- // Helpers that React (PertPreview) calls when the user toggles the
2510
- // "Highlight Critical Path" entry inside the cog dropdown. Operates
2511
- // on the diagram's container element — finds the SVG inside, fades
2512
- // non-critical nodes/edges/groups to 15%.
2513
- //
2514
- // In Monte-Carlo mode the binary critical chain is a misleading lens
2515
- // (every activity has some criticality), so the highlight rule keeps
2516
- // the high-band activities (red / orange / yellow) visible and fades
2517
- // the rest. In analytical mode it falls back to the binary path.
2518
-
2519
- const HIGHLIGHT_BANDS = new Set<string>(['red', 'orange', 'yellow']);
2520
-
2521
- function isCritical(el: Element, mcOn: boolean): boolean {
2522
- if (mcOn) {
2523
- return HIGHLIGHT_BANDS.has(el.getAttribute('data-criticality-band') ?? '');
2524
- }
2525
- return el.getAttribute('data-critical-path') === 'true';
2526
- }
2527
-
2528
- /**
2529
- * Predicate for whether a node/edge belongs to the highlighted set
2530
- * for a given legend entry. Edges only apply to the critical kind —
2531
- * anchor and milestone are node-only properties.
2532
- */
2533
- function isInHighlightSet(
2534
- el: Element,
2535
- kind: LegendKind,
2536
- mcOn: boolean
2537
- ): boolean {
2538
- if (kind === 'critical') return isCritical(el, mcOn);
2539
- if (kind === 'milestone') {
2540
- return el.getAttribute('data-milestone') === 'true';
2541
- }
2542
- // anchor
2543
- return el.hasAttribute('data-anchor');
2544
- }
2545
-
2546
- /**
2547
- * Fade everything in the diagram that doesn't belong to the given
2548
- * legend set (`'critical'`, `'anchor'`, or `'milestone'`). Auto-detects
2549
- * MC vs analytical mode for the critical-path rule.
2550
- *
2551
- * No-op when nothing qualifies (e.g. hovering Anchor on a diagram with
2552
- * no anchor — shouldn't happen because the pill wouldn't render, but
2553
- * defensive). The React layer is responsible for resetting via
2554
- * `resetPertHighlight` when hover/click goes away.
2555
- */
2556
- export function highlightPertSet(container: Element, kind: LegendKind): void {
2557
- const svg = container.querySelector('svg');
2558
- if (!svg) return;
2559
- // Detect MC mode: edges in MC mode have non-empty data-criticality-band
2560
- // bands like 'red'/'orange'/etc. Analytical mode uses only 'red' or ''.
2561
- const mcOn = Array.from(svg.querySelectorAll('.pert-edge')).some((e) => {
2562
- const b = e.getAttribute('data-criticality-band');
2563
- return b !== null && b !== '' && b !== 'red';
2564
- });
2565
-
2566
- const candidates = svg.querySelectorAll(
2567
- '.pert-node, .pert-edge, .pert-group-collapsed'
2568
- );
2569
- let anyMatch = false;
2570
- for (const el of candidates) {
2571
- if (isInHighlightSet(el, kind, mcOn)) {
2572
- anyMatch = true;
2573
- break;
2574
- }
2575
- }
2576
- if (!anyMatch) return;
2577
-
2578
- svg.setAttribute('data-pert-highlight-active', kind);
2579
- for (const el of svg.querySelectorAll('.pert-node, .pert-edge')) {
2580
- (el as SVGElement).setAttribute(
2581
- 'opacity',
2582
- isInHighlightSet(el, kind, mcOn) ? '1' : String(FADE_OPACITY)
2583
- );
2584
- }
2585
- // Group containers always dim to scenery; collapsed group cards
2586
- // behave like nodes and follow the membership rule.
2587
- for (const el of svg.querySelectorAll('.pert-group')) {
2588
- const inSet =
2589
- el.classList.contains('pert-group-collapsed') &&
2590
- isInHighlightSet(el, kind, mcOn);
2591
- (el as SVGElement).setAttribute(
2592
- 'opacity',
2593
- inSet ? '1' : String(FADE_OPACITY)
2594
- );
2595
- }
2596
- }
2597
-
2598
- /**
2599
- * Critical-path-specific shorthand for `highlightPertSet(container,
2600
- * 'critical')`. Kept for backwards compatibility with existing callers.
2601
- */
2602
- export function highlightPertCriticalPath(container: Element): void {
2603
- highlightPertSet(container, 'critical');
2604
- }
2605
-
2606
- /**
2607
- * Reset opacities applied by `highlightPertSet`. Safe to call when no
2608
- * highlight is active.
2609
- */
2610
- export function resetPertHighlight(container: Element): void {
2611
- const svg = container.querySelector('svg');
2612
- if (!svg) return;
2613
- svg.removeAttribute('data-pert-highlight-active');
2614
- // Drop the legacy attribute too in case an older bundle wrote it.
2615
- svg.removeAttribute('data-critical-path-active');
2616
- for (const el of svg.querySelectorAll(
2617
- '.pert-node, .pert-edge, .pert-group'
2618
- )) {
2619
- (el as SVGElement).removeAttribute('opacity');
2620
- }
2621
- }
2622
-
2623
- /**
2624
- * Backwards-compatible alias for `resetPertHighlight`.
2625
- */
2626
- export function resetPertCriticalPath(container: Element): void {
2627
- resetPertHighlight(container);
2628
- }
2629
-
2630
2466
  /**
2631
2467
  * Build the anchor framing bullet, or `null` when no anchor is set.
2632
2468
  * Surfaces the user-pinned date in plain language; the start/finish
@@ -2854,179 +2690,6 @@ interface FieldLegendArgs {
2854
2690
  * top: [ Early Start | Duration | Early Finish ]
2855
2691
  * bottom: [ Late Start | Slack | Late Finish ]
2856
2692
  */
2857
- // ============================================================
2858
- // Section: top legend (Critical Path / Anchor / Milestone pills)
2859
- // ============================================================
2860
-
2861
- type LegendKind = 'critical' | 'anchor' | 'milestone';
2862
-
2863
- interface LegendEntry {
2864
- kind: LegendKind;
2865
- label: string;
2866
- }
2867
-
2868
- /**
2869
- * Returns the PERT-specific legend entries (Critical Path / Anchor /
2870
- * Milestone). Tag groups are rendered separately via the shared
2871
- * `renderLegendD3` helper so they get the standard collapsible-capsule
2872
- * treatment used by org / kanban / gantt.
2873
- */
2874
- export function pertLegendEntries(resolved: ResolvedPert): LegendEntry[] {
2875
- const entries: LegendEntry[] = [];
2876
- if (resolved.activities.length > 0) {
2877
- entries.push({ kind: 'critical', label: 'Critical Path' });
2878
- }
2879
- if (resolved.options.anchor !== null) {
2880
- entries.push({ kind: 'anchor', label: 'Anchor' });
2881
- }
2882
- if (resolved.activities.some((a) => a.activity.isMilestone)) {
2883
- entries.push({ kind: 'milestone', label: 'Milestone' });
2884
- }
2885
- return entries;
2886
- }
2887
-
2888
- // Visual swatch widths per kind. Critical = a small filled circle the
2889
- // same size as the entry-dot used by the shared legend (renders as a
2890
- // crisp 8px dot). Anchor = anchor icon at PIN_ICON_W. Milestone = ◆
2891
- // glyph rendered at the pill font size.
2892
- function legendSwatchWidth(kind: LegendKind): number {
2893
- if (kind === 'critical') return LEGEND_DOT_R_CONST * 2;
2894
- if (kind === 'anchor') return PIN_ICON_W;
2895
- return LEGEND_FONT_SIZE; // ◆ glyph width approx = font size
2896
- }
2897
-
2898
- function legendPillWidth(entry: LegendEntry): number {
2899
- const labelW = measureLegendText(entry.label, LEGEND_FONT_SIZE);
2900
- return Math.ceil(
2901
- LEGEND_PILL_PADDING_X +
2902
- legendSwatchWidth(entry.kind) +
2903
- LEGEND_SWATCH_GAP +
2904
- labelW +
2905
- LEGEND_PILL_PADDING_X
2906
- );
2907
- }
2908
-
2909
- function legendNaturalWidth(entries: LegendEntry[]): number {
2910
- if (entries.length === 0) return 0;
2911
- let total = 0;
2912
- for (const e of entries) total += legendPillWidth(e);
2913
- total += (entries.length - 1) * LEGEND_PILL_GAP;
2914
- return total;
2915
- }
2916
-
2917
- interface LegendBlockArgs {
2918
- x: number;
2919
- y: number;
2920
- width: number;
2921
- palette: PaletteColors;
2922
- isDark: boolean;
2923
- }
2924
-
2925
- /**
2926
- * Render the top-legend pill row. Each pill carries
2927
- * `data-legend-entry="critical|anchor|milestone"` so the React layer
2928
- * can attach hover/click wiring to fade the matching set.
2929
- *
2930
- * Visual style mirrors the shared `renderLegendD3` pill convention so
2931
- * PERT looks consistent with Cycle / Mindmap / BoxesAndLines: 28px tall,
2932
- * fully-rounded rx, mix-fill against surface, no stroke, 11pt label.
2933
- */
2934
- export const PERT_LEGEND_PILL_HEIGHT = LEGEND_PILL_HEIGHT;
2935
-
2936
- export function pertLegendBlockWidth(entries: LegendEntry[]): number {
2937
- return legendNaturalWidth(entries);
2938
- }
2939
-
2940
- export function renderLegendBlock(
2941
- svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2942
- entries: LegendEntry[],
2943
- args: LegendBlockArgs
2944
- ): void {
2945
- if (entries.length === 0) return;
2946
- const { x, y, width, palette, isDark } = args;
2947
- // Same fill recipe as the shared legend pills.
2948
- const groupBg = isDark
2949
- ? mix(palette.surface, palette.bg, 50)
2950
- : mix(palette.surface, palette.bg, 30);
2951
-
2952
- const block = svg
2953
- .append('g')
2954
- .attr('class', 'pert-legend')
2955
- .attr('data-pert-legend', '');
2956
-
2957
- // Pill row centered horizontally inside [x, x+width].
2958
- const totalW = legendNaturalWidth(entries);
2959
- let pillX = x + (width - totalW) / 2;
2960
-
2961
- for (const entry of entries) {
2962
- const pillW = legendPillWidth(entry);
2963
- const pill = block
2964
- .append('g')
2965
- .attr('class', 'pert-legend-entry')
2966
- .attr('data-legend-entry', entry.kind)
2967
- .attr('transform', `translate(${pillX}, ${y})`)
2968
- .style('cursor', 'pointer');
2969
-
2970
- pill
2971
- .append('rect')
2972
- .attr('class', 'pert-legend-pill')
2973
- .attr('width', pillW)
2974
- .attr('height', LEGEND_PILL_HEIGHT)
2975
- .attr('rx', LEGEND_PILL_HEIGHT / 2)
2976
- .attr('ry', LEGEND_PILL_HEIGHT / 2)
2977
- .attr('fill', groupBg);
2978
-
2979
- // Swatch — small inline mark for the kind. Critical = filled dot
2980
- // matching the shared-legend entry-dot style; Anchor = anchor icon;
2981
- // Milestone = ◆ glyph.
2982
- const swatchW = legendSwatchWidth(entry.kind);
2983
- const swatchCx = LEGEND_PILL_PADDING_X + swatchW / 2;
2984
- const swatchCy = LEGEND_PILL_HEIGHT / 2;
2985
- if (entry.kind === 'critical') {
2986
- pill
2987
- .append('circle')
2988
- .attr('class', 'pert-legend-swatch')
2989
- .attr('cx', swatchCx)
2990
- .attr('cy', swatchCy)
2991
- .attr('r', LEGEND_DOT_R_CONST)
2992
- .attr('fill', palette.colors.red);
2993
- } else if (entry.kind === 'anchor') {
2994
- drawAnchorPin(
2995
- pill,
2996
- swatchCx - PIN_ICON_W / 2,
2997
- swatchCy,
2998
- palette.textMuted
2999
- );
3000
- } else {
3001
- pill
3002
- .append('text')
3003
- .attr('class', 'pert-legend-swatch')
3004
- .attr('x', swatchCx)
3005
- .attr('y', swatchCy)
3006
- .attr('text-anchor', 'middle')
3007
- .attr('dominant-baseline', 'central')
3008
- .attr('font-family', FONT_FAMILY)
3009
- .attr('font-size', LEGEND_FONT_SIZE + 1)
3010
- .attr('fill', palette.textMuted)
3011
- .text('◆');
3012
- }
3013
-
3014
- pill
3015
- .append('text')
3016
- .attr('class', 'pert-legend-label')
3017
- .attr('x', LEGEND_PILL_PADDING_X + swatchW + LEGEND_SWATCH_GAP)
3018
- .attr('y', swatchCy)
3019
- .attr('dominant-baseline', 'central')
3020
- .attr('font-family', FONT_FAMILY)
3021
- .attr('font-size', LEGEND_FONT_SIZE)
3022
- .attr('font-weight', 500)
3023
- .attr('fill', palette.textMuted)
3024
- .text(entry.label);
3025
-
3026
- pillX += pillW + LEGEND_PILL_GAP;
3027
- }
3028
- }
3029
-
3030
2693
  interface TagLegendArgs {
3031
2694
  x: number;
3032
2695
  y: number;
@@ -2006,9 +2006,15 @@ export function renderSequenceDiagram(
2006
2006
  .attr('class', 'group-box-wrapper')
2007
2007
  .attr('data-group-toggle', '')
2008
2008
  .attr('data-group-line', String(group.lineNumber))
2009
+ .attr('tabindex', '0')
2010
+ .attr('role', 'button')
2011
+ .attr('aria-expanded', 'true')
2009
2012
  .attr('cursor', 'pointer');
2010
2013
  groupG.append('title').text('Click to collapse');
2011
2014
 
2015
+ // Visual group frame — pointer-events:none so it never intercepts clicks.
2016
+ // The box is rendered behind the participant shapes, so the only reliable
2017
+ // toggle target is the header strip above the participants (hit area below).
2012
2018
  groupG
2013
2019
  .append('rect')
2014
2020
  .attr('x', minX)
@@ -2020,17 +2026,43 @@ export function renderSequenceDiagram(
2020
2026
  .attr('stroke', strokeColor)
2021
2027
  .attr('stroke-width', 1)
2022
2028
  .attr('stroke-opacity', 0.5)
2029
+ .attr('pointer-events', 'none')
2023
2030
  .attr('class', 'group-box');
2024
2031
 
2025
- // Group label
2032
+ // Transparent hit area over the header strip (above the participant boxes,
2033
+ // so it is never occluded). Gives the toggle a generous, discoverable
2034
+ // click target — mirrors the section-label-hit pattern.
2035
+ groupG
2036
+ .append('rect')
2037
+ .attr('x', minX)
2038
+ .attr('y', boxY)
2039
+ .attr('width', maxX - minX)
2040
+ .attr('height', participantStartY - boxY)
2041
+ .attr('fill', 'transparent')
2042
+ .attr('class', 'group-label-hit');
2043
+
2044
+ // Collapse chevron — visual affordance signalling the header is clickable.
2026
2045
  groupG
2027
2046
  .append('text')
2028
2047
  .attr('x', minX + 8)
2029
2048
  .attr('y', boxY + GROUP_LABEL_SIZE + 4)
2030
2049
  .attr('fill', strokeColor)
2031
2050
  .attr('font-size', GROUP_LABEL_SIZE)
2051
+ .attr('opacity', 0.7)
2052
+ .attr('pointer-events', 'none')
2053
+ .attr('class', 'group-chevron')
2054
+ .text('▾'); // ▾ expanded
2055
+
2056
+ // Group label
2057
+ groupG
2058
+ .append('text')
2059
+ .attr('x', minX + 8 + GROUP_LABEL_SIZE + 2)
2060
+ .attr('y', boxY + GROUP_LABEL_SIZE + 4)
2061
+ .attr('fill', strokeColor)
2062
+ .attr('font-size', GROUP_LABEL_SIZE)
2032
2063
  .attr('font-weight', 'bold')
2033
2064
  .attr('opacity', 0.7)
2065
+ .attr('pointer-events', 'none')
2034
2066
  .attr('class', 'group-label')
2035
2067
  .text(group.name);
2036
2068
  }
@@ -2091,6 +2123,9 @@ export function renderSequenceDiagram(
2091
2123
  participantG
2092
2124
  .attr('data-group-toggle', '')
2093
2125
  .attr('data-group-line', String(meta.lineNumber))
2126
+ .attr('tabindex', '0')
2127
+ .attr('role', 'button')
2128
+ .attr('aria-expanded', 'false')
2094
2129
  .attr('cursor', 'pointer');
2095
2130
  participantG.append('title').text('Click to expand');
2096
2131
 
@@ -34,8 +34,37 @@ export function normalizeSvgForEmbed(input: string): string {
34
34
  const tight = computeBBox(svg);
35
35
  if (tight && tight.width > 0 && tight.height > 0) {
36
36
  const pad = 16;
37
- const vb = `${tight.x - pad} ${tight.y - pad} ${tight.width + pad * 2} ${tight.height + pad * 2}`;
38
- svg = svg.replace(/(<svg[^>]*?)viewBox="[^"]*"/, `$1viewBox="${vb}"`);
37
+ const x = tight.x - pad;
38
+ const y = tight.y - pad;
39
+ const w = tight.width + pad * 2;
40
+ const h = tight.height + pad * 2;
41
+ // The renderer's viewBox is authoritative; computeBBox may only shave dead
42
+ // margins off it. computeBBox is a best-effort string parser with two
43
+ // failure modes, and the renderer's canvas bounds catch both:
44
+ // 1. OVER-shoot — it misreads path arc commands (`A rx ry rot … x y`) and
45
+ // yields a box that strays OUTSIDE the canvas (negative origin /
46
+ // over-wide). Applying it shifts the viewBox and CLIPS the diagram.
47
+ // 2. UNDER-shoot — it can't see elements positioned via `transform`
48
+ // (no x/y attrs), e.g. word-cloud words. It then measures only the
49
+ // stragglers (the title) and collapses the box to a tiny region,
50
+ // zooming that fragment to fill the frame.
51
+ // So only tighten when the computed box sits WITHIN the canvas AND still
52
+ // covers most of it; otherwise keep the renderer's correct bounds.
53
+ const canvas = readViewBox(svg);
54
+ const TOL = 2;
55
+ const MIN_COVERAGE = 0.5; // a real trim shaves margins, not the diagram
56
+ const trustworthy =
57
+ !canvas ||
58
+ (x >= canvas.x - TOL &&
59
+ y >= canvas.y - TOL &&
60
+ x + w <= canvas.x + canvas.width + TOL &&
61
+ y + h <= canvas.y + canvas.height + TOL &&
62
+ w >= canvas.width * MIN_COVERAGE &&
63
+ h >= canvas.height * MIN_COVERAGE);
64
+ if (trustworthy) {
65
+ const vb = `${x} ${y} ${w} ${h}`;
66
+ svg = svg.replace(/(<svg[^>]*?)viewBox="[^"]*"/, `$1viewBox="${vb}"`);
67
+ }
39
68
  }
40
69
 
41
70
  svg = svg.replace(/(<svg[^>]*?) width="[^"]*"/g, '$1');
@@ -65,6 +94,26 @@ export function getEmbedSvgViewBox(
65
94
  };
66
95
  }
67
96
 
97
+ /** Read the root `<svg>` viewBox as numbers, if present and well-formed. */
98
+ function readViewBox(
99
+ svg: string
100
+ ): { x: number; y: number; width: number; height: number } | null {
101
+ const m = svg.match(/<svg[^>]*?\bviewBox="([^"]+)"/);
102
+ if (!m) return null;
103
+ const n = m[1]!
104
+ .trim()
105
+ .split(/[\s,]+/)
106
+ .map(Number);
107
+ if (
108
+ n.length !== 4 ||
109
+ n.some((v) => !Number.isFinite(v)) ||
110
+ n[2]! <= 0 ||
111
+ n[3]! <= 0
112
+ )
113
+ return null;
114
+ return { x: n[0]!, y: n[1]!, width: n[2]!, height: n[3]! };
115
+ }
116
+
68
117
  /**
69
118
  * Compute an approximate content bounding box from raw element coordinates.
70
119
  *