@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.
- package/dist/advanced.cjs +189 -333
- package/dist/advanced.d.cts +17 -80
- package/dist/advanced.d.ts +17 -80
- package/dist/advanced.js +189 -325
- package/dist/auto.cjs +187 -318
- package/dist/auto.js +122 -122
- package/dist/auto.mjs +187 -318
- package/dist/cli.cjs +159 -159
- package/dist/editor.cjs +1 -0
- package/dist/editor.js +1 -0
- package/dist/highlight.cjs +1 -0
- package/dist/highlight.js +1 -0
- package/dist/index.cjs +205 -319
- package/dist/index.js +205 -319
- package/dist/internal.cjs +189 -333
- package/dist/internal.d.cts +17 -80
- package/dist/internal.d.ts +17 -80
- package/dist/internal.js +189 -325
- package/package.json +17 -17
- package/src/advanced.ts +0 -8
- package/src/completion.ts +5 -0
- package/src/d3.ts +12 -2
- package/src/editor/keywords.ts +1 -0
- package/src/map/layout.ts +18 -11
- package/src/map/parser.ts +12 -2
- package/src/map/renderer.ts +25 -2
- package/src/map/types.ts +9 -0
- package/src/pert/renderer.ts +21 -358
- package/src/sequence/renderer.ts +36 -1
- package/src/utils/svg-embed.ts +51 -2
package/src/pert/renderer.ts
CHANGED
|
@@ -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
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
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
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
471
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/sequence/renderer.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
package/src/utils/svg-embed.ts
CHANGED
|
@@ -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
|
|
38
|
-
|
|
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
|
*
|