@diagrammo/dgmo 0.6.2 → 0.7.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.
Files changed (61) hide show
  1. package/.claude/commands/dgmo.md +231 -13
  2. package/AGENTS.md +148 -0
  3. package/dist/cli.cjs +341 -165
  4. package/dist/index.cjs +4900 -1685
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +259 -18
  7. package/dist/index.d.ts +259 -18
  8. package/dist/index.js +4642 -1436
  9. package/dist/index.js.map +1 -1
  10. package/package.json +5 -3
  11. package/src/c4/layout.ts +0 -5
  12. package/src/c4/parser.ts +0 -16
  13. package/src/c4/renderer.ts +7 -11
  14. package/src/class/layout.ts +0 -1
  15. package/src/class/parser.ts +28 -0
  16. package/src/class/renderer.ts +189 -34
  17. package/src/cli.ts +566 -25
  18. package/src/colors.ts +3 -3
  19. package/src/completion.ts +58 -0
  20. package/src/d3.ts +179 -122
  21. package/src/dgmo-router.ts +3 -58
  22. package/src/echarts.ts +96 -55
  23. package/src/er/parser.ts +30 -1
  24. package/src/er/renderer.ts +12 -7
  25. package/src/gantt/calculator.ts +677 -0
  26. package/src/gantt/parser.ts +761 -0
  27. package/src/gantt/renderer.ts +2125 -0
  28. package/src/gantt/resolver.ts +144 -0
  29. package/src/gantt/types.ts +168 -0
  30. package/src/graph/flowchart-parser.ts +27 -4
  31. package/src/graph/flowchart-renderer.ts +1 -2
  32. package/src/graph/state-parser.ts +0 -1
  33. package/src/graph/state-renderer.ts +1 -3
  34. package/src/index.ts +37 -0
  35. package/src/infra/compute.ts +0 -7
  36. package/src/infra/layout.ts +0 -2
  37. package/src/infra/parser.ts +46 -4
  38. package/src/infra/renderer.ts +49 -27
  39. package/src/initiative-status/filter.ts +63 -0
  40. package/src/initiative-status/layout.ts +319 -67
  41. package/src/initiative-status/parser.ts +200 -25
  42. package/src/initiative-status/renderer.ts +298 -35
  43. package/src/initiative-status/types.ts +6 -0
  44. package/src/kanban/parser.ts +0 -2
  45. package/src/org/layout.ts +22 -59
  46. package/src/org/renderer.ts +11 -36
  47. package/src/palettes/dracula.ts +60 -0
  48. package/src/palettes/index.ts +8 -6
  49. package/src/palettes/monokai.ts +60 -0
  50. package/src/palettes/registry.ts +4 -2
  51. package/src/sequence/parser.ts +14 -11
  52. package/src/sequence/renderer.ts +5 -6
  53. package/src/sequence/tag-resolution.ts +0 -1
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/layout.ts +1 -14
  56. package/src/sitemap/parser.ts +1 -2
  57. package/src/sitemap/renderer.ts +4 -7
  58. package/src/utils/arrows.ts +7 -7
  59. package/src/utils/duration.ts +212 -0
  60. package/src/utils/export-container.ts +40 -0
  61. package/src/utils/legend-constants.ts +1 -0
@@ -5,11 +5,26 @@
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
+ import { runInExportContainer, extractExportSvg } from '../utils/export-container';
9
+ import {
10
+ LEGEND_HEIGHT,
11
+ LEGEND_PILL_PAD,
12
+ LEGEND_PILL_FONT_SIZE,
13
+ LEGEND_PILL_FONT_W,
14
+ LEGEND_CAPSULE_PAD,
15
+ LEGEND_DOT_R,
16
+ LEGEND_ENTRY_FONT_SIZE,
17
+ LEGEND_ENTRY_FONT_W,
18
+ LEGEND_ENTRY_DOT_GAP,
19
+ LEGEND_ENTRY_TRAIL,
20
+ LEGEND_GROUP_GAP,
21
+ } from '../utils/legend-constants';
8
22
  import { contrastText, mix } from '../palettes/color-utils';
23
+ import type { TagGroup } from '../utils/tag-groups';
9
24
  import type { PaletteColors } from '../palettes';
10
25
  import type { ParsedInitiativeStatus, InitiativeStatus } from './types';
11
26
  import type { ParticipantType } from '../sequence/parser';
12
- import type { ISLayoutResult, ISLayoutNode, ISLayoutEdge, ISLayoutGroup } from './layout';
27
+ import type { ISLayoutResult } from './layout';
13
28
  import { parseInitiativeStatus } from './parser';
14
29
  import { layoutInitiativeStatus } from './layout';
15
30
 
@@ -66,6 +81,44 @@ function edgeStrokeColor(status: InitiativeStatus, palette: PaletteColors, isDar
66
81
  return statusColor(status, palette, isDark);
67
82
  }
68
83
 
84
+ // ============================================================
85
+ // Legend helpers
86
+ // ============================================================
87
+
88
+ interface ISLegendEntry {
89
+ label: string;
90
+ statusKey: InitiativeStatus;
91
+ }
92
+
93
+ const IS_STATUS_LABELS: Record<string, string> = {
94
+ done: 'Done',
95
+ wip: 'In Progress',
96
+ todo: 'To Do',
97
+ na: 'N/A',
98
+ };
99
+
100
+ const IS_STATUS_ORDER: InitiativeStatus[] = ['todo', 'wip', 'done', 'na'];
101
+
102
+ function collectStatuses(parsed: ParsedInitiativeStatus): ISLegendEntry[] {
103
+ const present = new Set<string>();
104
+ for (const n of parsed.nodes) {
105
+ if (n.status) present.add(n.status);
106
+ }
107
+ return IS_STATUS_ORDER
108
+ .filter((s) => s !== null && present.has(s))
109
+ .map((s) => ({ label: IS_STATUS_LABELS[s!], statusKey: s }));
110
+ }
111
+
112
+ const LEGEND_GROUP_NAME = 'Status';
113
+
114
+ function legendEntriesWidth(entries: ISLegendEntry[]): number {
115
+ let w = 0;
116
+ for (const e of entries) {
117
+ w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
118
+ }
119
+ return w;
120
+ }
121
+
69
122
  // ============================================================
70
123
  // Edge path generator
71
124
  // ============================================================
@@ -409,15 +462,31 @@ function renderNodeShape(
409
462
  // Main renderer
410
463
  // ============================================================
411
464
 
465
+ export interface ISRenderOptions {
466
+ onClickItem?: (lineNumber: number) => void;
467
+ exportDims?: { width?: number; height?: number };
468
+ legendActive?: boolean | null;
469
+ activeTagGroup?: string | null;
470
+ hiddenTagValues?: Map<string, Set<string>>;
471
+ tagGroups?: TagGroup[];
472
+ }
473
+
412
474
  export function renderInitiativeStatus(
413
475
  container: HTMLDivElement,
414
476
  parsed: ParsedInitiativeStatus,
415
477
  layout: ISLayoutResult,
416
478
  palette: PaletteColors,
417
479
  isDark: boolean,
418
- onClickItem?: (lineNumber: number) => void,
419
- exportDims?: { width?: number; height?: number }
480
+ options?: ISRenderOptions
420
481
  ): void {
482
+ const {
483
+ onClickItem,
484
+ exportDims,
485
+ legendActive,
486
+ activeTagGroup,
487
+ hiddenTagValues,
488
+ tagGroups,
489
+ } = options ?? {};
421
490
  // Clear existing content
422
491
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
423
492
 
@@ -425,20 +494,28 @@ export function renderInitiativeStatus(
425
494
  const height = exportDims?.height ?? container.clientHeight;
426
495
  if (width <= 0 || height <= 0) return;
427
496
 
497
+ const legendEntries = collectStatuses(parsed);
498
+ const hasLegend = legendEntries.length > 1;
499
+ const isLegendExpanded = legendActive !== false;
500
+
501
+ const effectiveTagGroups = tagGroups ?? parsed.tagGroups ?? [];
502
+ const hasTagGroups = effectiveTagGroups.length > 0;
503
+
428
504
  const titleHeight = parsed.title ? 40 : 0;
505
+ const LEGEND_FIXED_GAP = 8;
506
+ const legendReserve = (hasLegend || hasTagGroups) ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
429
507
 
430
508
  // Scale to fit
431
509
  const diagramW = layout.width;
432
510
  const diagramH = layout.height;
433
- const availH = height - titleHeight;
511
+ const availH = height - titleHeight - legendReserve;
434
512
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
435
513
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
436
514
  const scale = Math.min(MAX_SCALE, scaleX, scaleY);
437
515
 
438
516
  const scaledW = diagramW * scale;
439
- const scaledH = diagramH * scale;
440
517
  const offsetX = (width - scaledW) / 2;
441
- const offsetY = titleHeight + DIAGRAM_PADDING;
518
+ const offsetY = titleHeight + legendReserve + DIAGRAM_PADDING;
442
519
 
443
520
  // Create SVG
444
521
  const svg = d3Selection
@@ -498,6 +575,201 @@ export function renderInitiativeStatus(
498
575
  }
499
576
  }
500
577
 
578
+ // ── Legend ──
579
+ if (hasLegend || hasTagGroups) {
580
+ const groupBg = isDark
581
+ ? mix(palette.surface, palette.bg, 50)
582
+ : mix(palette.surface, palette.bg, 30);
583
+
584
+ // Build legend groups: Status + tag groups
585
+ interface LegendGroup {
586
+ name: string;
587
+ key: string; // lowercase key for data attribute
588
+ isStatus: boolean;
589
+ entries: { label: string; color: string; value: string }[];
590
+ width: number; // total width when expanded
591
+ }
592
+
593
+ const legendGroups: LegendGroup[] = [];
594
+
595
+ // Status group (always first if entries exist)
596
+ if (hasLegend) {
597
+ const statusEntries = legendEntries.map((e) => ({
598
+ label: e.label,
599
+ color: statusColor(e.statusKey, palette, isDark),
600
+ value: e.statusKey ?? 'na',
601
+ }));
602
+ const pillW = LEGEND_GROUP_NAME.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
603
+ const entrW = legendEntriesWidth(legendEntries);
604
+ legendGroups.push({
605
+ name: LEGEND_GROUP_NAME,
606
+ key: 'status',
607
+ isStatus: true,
608
+ entries: statusEntries,
609
+ width: LEGEND_CAPSULE_PAD * 2 + pillW + LEGEND_ENTRY_TRAIL + entrW,
610
+ });
611
+ }
612
+
613
+ // Tag groups
614
+ for (const tg of effectiveTagGroups) {
615
+ const entries = tg.entries.map((e) => ({
616
+ label: e.value,
617
+ color: e.color || palette.textMuted,
618
+ value: e.value.toLowerCase(),
619
+ }));
620
+ const pillW = tg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
621
+ let entrW = 0;
622
+ for (const e of entries) {
623
+ entrW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
624
+ }
625
+ legendGroups.push({
626
+ name: tg.name,
627
+ key: tg.name.toLowerCase(),
628
+ isStatus: false,
629
+ entries,
630
+ width: LEGEND_CAPSULE_PAD * 2 + pillW + 4 + entrW,
631
+ });
632
+ }
633
+
634
+ // Determine which group is active/expanded
635
+ const activeKey = activeTagGroup?.toLowerCase() ?? null;
636
+ const isStatusExpanded = isLegendExpanded && activeKey === null;
637
+
638
+ // When a tag group is active, only show that group (mutual exclusion).
639
+ // When no tag group is active, show all pills (Status expanded + tag pills minified).
640
+ const visibleLegendGroups = activeKey !== null
641
+ ? legendGroups.filter((lg) => !lg.isStatus && lg.key === activeKey)
642
+ : legendGroups;
643
+
644
+ // Compute total legend width
645
+ let totalLegendW = 0;
646
+ for (const lg of visibleLegendGroups) {
647
+ const isActive = lg.isStatus ? isStatusExpanded : (activeKey === lg.key);
648
+ const pillW = lg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
649
+ totalLegendW += isActive ? lg.width : pillW;
650
+ totalLegendW += LEGEND_GROUP_GAP;
651
+ }
652
+ totalLegendW -= LEGEND_GROUP_GAP; // remove trailing gap
653
+
654
+ const legendX = (width - totalLegendW) / 2;
655
+ const legendY = titleHeight;
656
+
657
+ const legendRow = svg
658
+ .append('g')
659
+ .attr('class', 'is-legend-row')
660
+ .attr('transform', `translate(${legendX}, ${legendY})`);
661
+
662
+ let cursorX = 0;
663
+
664
+ for (const lg of visibleLegendGroups) {
665
+ const isActive = lg.isStatus ? isStatusExpanded : (activeKey === lg.key);
666
+ const pillW = lg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
667
+ const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
668
+ const groupW = isActive ? lg.width : pillW;
669
+
670
+ const gEl = legendRow
671
+ .append('g')
672
+ .attr('transform', `translate(${cursorX}, 0)`)
673
+ .attr('class', 'is-legend-group')
674
+ .attr('data-legend-group', lg.key)
675
+ .style('cursor', 'pointer');
676
+
677
+ if (isActive) {
678
+ // Outer capsule background
679
+ gEl.append('rect')
680
+ .attr('width', groupW)
681
+ .attr('height', LEGEND_HEIGHT)
682
+ .attr('rx', LEGEND_HEIGHT / 2)
683
+ .attr('fill', groupBg);
684
+ }
685
+
686
+ const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
687
+ const pillYOff = isActive ? LEGEND_CAPSULE_PAD : 0;
688
+
689
+ // Pill background
690
+ gEl.append('rect')
691
+ .attr('x', pillXOff)
692
+ .attr('y', pillYOff)
693
+ .attr('width', pillW)
694
+ .attr('height', pillH)
695
+ .attr('rx', pillH / 2)
696
+ .attr('fill', isActive ? palette.bg : groupBg);
697
+
698
+ // Active pill border
699
+ if (isActive) {
700
+ gEl.append('rect')
701
+ .attr('x', pillXOff)
702
+ .attr('y', pillYOff)
703
+ .attr('width', pillW)
704
+ .attr('height', pillH)
705
+ .attr('rx', pillH / 2)
706
+ .attr('fill', 'none')
707
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
708
+ .attr('stroke-width', 0.75);
709
+ }
710
+
711
+ // Pill text
712
+ gEl.append('text')
713
+ .attr('x', pillXOff + pillW / 2)
714
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
715
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
716
+ .attr('font-weight', '500')
717
+ .attr('fill', isActive ? palette.text : palette.textMuted)
718
+ .attr('text-anchor', 'middle')
719
+ .attr('font-family', FONT_FAMILY)
720
+ .text(lg.name);
721
+
722
+ // Entries inside capsule (active only)
723
+ if (isActive) {
724
+ // Determine which values are hidden for this group
725
+ const hiddenSet = !lg.isStatus ? hiddenTagValues?.get(lg.key) : undefined;
726
+
727
+ let entryX = pillXOff + pillW + 4;
728
+ for (const entry of lg.entries) {
729
+ const isHidden = hiddenSet?.has(entry.value) ?? false;
730
+
731
+ const entryG = gEl.append('g')
732
+ .attr('data-legend-entry', entry.value)
733
+ .style('cursor', !lg.isStatus ? 'pointer' : 'default');
734
+
735
+ if (isHidden) {
736
+ // Hidden: hollow ring + dimmed text (strikethrough-like)
737
+ entryG.append('circle')
738
+ .attr('cx', entryX + LEGEND_DOT_R)
739
+ .attr('cy', LEGEND_HEIGHT / 2)
740
+ .attr('r', LEGEND_DOT_R)
741
+ .attr('fill', 'none')
742
+ .attr('stroke', entry.color)
743
+ .attr('stroke-width', 1.2)
744
+ .attr('opacity', 0.5);
745
+ } else {
746
+ // Visible: solid dot
747
+ entryG.append('circle')
748
+ .attr('cx', entryX + LEGEND_DOT_R)
749
+ .attr('cy', LEGEND_HEIGHT / 2)
750
+ .attr('r', LEGEND_DOT_R)
751
+ .attr('fill', entry.color);
752
+ }
753
+
754
+ entryG.append('text')
755
+ .attr('x', entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
756
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
757
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
758
+ .attr('fill', palette.textMuted)
759
+ .attr('font-family', FONT_FAMILY)
760
+ .attr('opacity', isHidden ? 0.4 : 1)
761
+ .attr('text-decoration', isHidden ? 'line-through' : 'none')
762
+ .text(entry.label);
763
+
764
+ entryX += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
765
+ }
766
+ }
767
+
768
+ cursorX += groupW + LEGEND_GROUP_GAP;
769
+ }
770
+
771
+ }
772
+
501
773
  // Content group
502
774
  const contentG = svg
503
775
  .append('g')
@@ -768,7 +1040,15 @@ export function renderInitiativeStatus(
768
1040
  .append('g')
769
1041
  .attr('transform', `translate(${node.x}, ${node.y})`)
770
1042
  .attr('class', 'is-node')
771
- .attr('data-line-number', String(node.lineNumber));
1043
+ .attr('data-line-number', String(node.lineNumber))
1044
+ .attr('data-is-status', node.status ?? 'na');
1045
+
1046
+ // Tag data attributes for hover dimming
1047
+ if (node.metadata) {
1048
+ for (const [key, val] of Object.entries(node.metadata)) {
1049
+ nodeG.attr(`data-tag-${key}`, val.toLowerCase());
1050
+ }
1051
+ }
772
1052
 
773
1053
  if (onClickItem) {
774
1054
  nodeG.style('cursor', 'pointer').on('click', () => {
@@ -787,14 +1067,16 @@ export function renderInitiativeStatus(
787
1067
  .attr('fill', 'transparent')
788
1068
  .attr('class', 'is-node-hit-area');
789
1069
 
1070
+ // Always use status coloring regardless of legend state
790
1071
  const fill = nodeFill(node.status, palette, isDark);
791
1072
  const stroke = nodeStroke(node.status, palette, isDark);
792
1073
  renderNodeShape(nodeG, node.shape, node.width, node.height, fill, stroke);
793
1074
 
1075
+ const textColor = contrastText(fill, '#eceff4', '#2e3440');
1076
+
794
1077
  // Label placement: actors put label below the figure, others center inside
795
1078
  const isActor = node.shape === 'actor';
796
1079
  if (isActor) {
797
- const textColor = nodeTextColor(node.status, palette, isDark);
798
1080
  const fitted = fitTextToNode(node.label, node.width, node.height * 0.35);
799
1081
  const labelY = node.height / 2 - fitted.fontSize * 0.3;
800
1082
  for (let li = 0; li < fitted.lines.length; li++) {
@@ -811,7 +1093,6 @@ export function renderInitiativeStatus(
811
1093
  }
812
1094
  } else {
813
1095
  const fitted = fitTextToNode(node.label, node.width, node.height);
814
- const textColor = nodeTextColor(node.status, palette, isDark);
815
1096
  const totalTextHeight = fitted.lines.length * fitted.fontSize * 1.3;
816
1097
  const startY = -totalTextHeight / 2 + fitted.fontSize * 0.65;
817
1098
 
@@ -846,40 +1127,22 @@ export function renderInitiativeStatusForExport(
846
1127
  const layout = layoutInitiativeStatus(parsed);
847
1128
  const isDark = theme === 'dark';
848
1129
 
1130
+ const legendEntries = collectStatuses(parsed);
1131
+ const EXPORT_LEGEND_GAP = 8;
1132
+ const legendReserve = legendEntries.length > 1 ? LEGEND_HEIGHT + EXPORT_LEGEND_GAP : 0;
849
1133
  const titleOffset = parsed.title ? 40 : 0;
850
1134
  const exportWidth = layout.width + DIAGRAM_PADDING * 2;
851
- const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
1135
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset + legendReserve;
852
1136
 
853
- const container = document.createElement('div');
854
- container.style.width = `${exportWidth}px`;
855
- container.style.height = `${exportHeight}px`;
856
- container.style.position = 'absolute';
857
- container.style.left = '-9999px';
858
- document.body.appendChild(container);
859
-
860
- try {
1137
+ return runInExportContainer(exportWidth, exportHeight, (container) => {
861
1138
  renderInitiativeStatus(
862
1139
  container,
863
1140
  parsed,
864
1141
  layout,
865
1142
  palette,
866
1143
  isDark,
867
- undefined,
868
- { width: exportWidth, height: exportHeight }
1144
+ { exportDims: { width: exportWidth, height: exportHeight } }
869
1145
  );
870
-
871
- const svgEl = container.querySelector('svg');
872
- if (!svgEl) return '';
873
-
874
- if (theme === 'transparent') {
875
- svgEl.style.background = 'none';
876
- }
877
-
878
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
879
- svgEl.style.fontFamily = FONT_FAMILY;
880
-
881
- return svgEl.outerHTML;
882
- } finally {
883
- document.body.removeChild(container);
884
- }
1146
+ return extractExportSvg(container, theme);
1147
+ });
885
1148
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { DgmoError } from '../diagnostics';
6
6
  import type { ParticipantType } from '../sequence/parser';
7
+ import type { TagGroup } from '../utils/tag-groups';
7
8
 
8
9
  export type InitiativeStatus = 'done' | 'wip' | 'todo' | 'na' | null;
9
10
 
@@ -14,6 +15,7 @@ export interface ISNode {
14
15
  status: InitiativeStatus;
15
16
  shape: ParticipantType;
16
17
  lineNumber: number;
18
+ metadata: Record<string, string>;
17
19
  }
18
20
 
19
21
  export interface ISEdge {
@@ -22,6 +24,7 @@ export interface ISEdge {
22
24
  label?: string; // e.g. "getUser"
23
25
  status: InitiativeStatus;
24
26
  lineNumber: number;
27
+ metadata: Record<string, string>;
25
28
  }
26
29
 
27
30
  export interface ISGroup {
@@ -37,7 +40,10 @@ export interface ParsedInitiativeStatus {
37
40
  nodes: ISNode[];
38
41
  edges: ISEdge[];
39
42
  groups: ISGroup[];
43
+ tagGroups: TagGroup[];
40
44
  options: Record<string, string>;
45
+ /** Initial hidden tag values from `hide:` DSL directive. Map<groupKey, Set<values>> */
46
+ initialHiddenTagValues: Map<string, Set<string>>;
41
47
  diagnostics: DgmoError[];
42
48
  error: string | null;
43
49
  }
@@ -1,5 +1,4 @@
1
1
  import type { PaletteColors } from '../palettes';
2
- import type { DgmoError } from '../diagnostics';
3
2
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
3
  import { resolveColor } from '../colors';
5
4
  import { matchTagBlockHeading } from '../utils/tag-groups';
@@ -15,7 +14,6 @@ import type {
15
14
  KanbanColumn,
16
15
  KanbanCard,
17
16
  KanbanTagGroup,
18
- KanbanTagEntry,
19
17
  } from './types';
20
18
 
21
19
  // ============================================================
package/src/org/layout.ts CHANGED
@@ -81,8 +81,6 @@ export interface OrgLayoutResult {
81
81
  // ============================================================
82
82
 
83
83
  const CHAR_WIDTH = 7.5;
84
- const LABEL_FONT_SIZE = 13;
85
- const META_FONT_SIZE = 11;
86
84
  const META_LINE_HEIGHT = 16;
87
85
  const HEADER_HEIGHT = 28;
88
86
  const SEPARATOR_GAP = 6;
@@ -1117,8 +1115,6 @@ export function layoutOrg(
1117
1115
  let finalWidth = totalWidth;
1118
1116
  let finalHeight = totalHeight;
1119
1117
 
1120
- const legendPosition = parsed.options?.['legend-position'] ?? 'top';
1121
-
1122
1118
  // When a tag group is active, only that group is laid out (full size).
1123
1119
  // When none is active, all groups are laid out minified — unless
1124
1120
  // expandAllLegend is set (export mode), which shows all groups expanded.
@@ -1128,66 +1124,33 @@ export function layoutOrg(
1128
1124
  const allExpanded = expandAllLegend && activeTagGroup == null;
1129
1125
  const effectiveW = (g: OrgLegendGroup) =>
1130
1126
  activeTagGroup != null || allExpanded ? g.width : g.minifiedWidth;
1131
- const effectiveH = (g: OrgLegendGroup) =>
1132
- activeTagGroup != null || allExpanded ? g.height : g.minifiedHeight;
1133
1127
 
1134
1128
  if (visibleGroups.length > 0) {
1135
- if (legendPosition === 'bottom') {
1136
- // Bottom: center legend groups horizontally below diagram content
1137
- const totalGroupsWidth =
1138
- visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1139
- (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1140
- const neededWidth = totalGroupsWidth + MARGIN * 2;
1141
-
1142
- if (neededWidth > totalWidth) {
1143
- finalWidth = neededWidth;
1144
- const shift = (finalWidth - totalWidth) / 2;
1145
- for (const n of layoutNodes) n.x += shift;
1146
- for (const c of containers) c.x += shift;
1147
- for (const e of layoutEdges) {
1148
- for (const p of e.points) p.x += shift;
1149
- }
1150
- }
1151
-
1152
- const contentBottom = totalHeight - MARGIN;
1153
- const legendY = contentBottom + LEGEND_GAP;
1154
- const startX = (finalWidth - totalGroupsWidth) / 2;
1155
-
1156
- let cx = startX;
1157
- for (const g of visibleGroups) {
1158
- g.x = cx;
1159
- g.y = legendY;
1160
- cx += effectiveW(g) + LEGEND_GROUP_GAP;
1161
- }
1162
-
1163
- finalHeight = totalHeight + LEGEND_GAP + LEGEND_HEIGHT;
1164
- } else {
1165
- // Top: horizontal row above chart content, left-aligned
1166
- const legendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP;
1167
-
1168
- // Push all chart content down
1169
- for (const n of layoutNodes) n.y += legendShift;
1170
- for (const c of containers) c.y += legendShift;
1171
- for (const e of layoutEdges) {
1172
- for (const p of e.points) p.y += legendShift;
1173
- }
1129
+ // Top: horizontal row above chart content, left-aligned
1130
+ const legendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP;
1131
+
1132
+ // Push all chart content down
1133
+ for (const n of layoutNodes) n.y += legendShift;
1134
+ for (const c of containers) c.y += legendShift;
1135
+ for (const e of layoutEdges) {
1136
+ for (const p of e.points) p.y += legendShift;
1137
+ }
1174
1138
 
1175
- const totalGroupsWidth =
1176
- visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1177
- (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1139
+ const totalGroupsWidth =
1140
+ visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1141
+ (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1178
1142
 
1179
- let cx = MARGIN;
1180
- for (const g of visibleGroups) {
1181
- g.x = cx;
1182
- g.y = MARGIN;
1183
- cx += effectiveW(g) + LEGEND_GROUP_GAP;
1184
- }
1143
+ let cx = MARGIN;
1144
+ for (const g of visibleGroups) {
1145
+ g.x = cx;
1146
+ g.y = MARGIN;
1147
+ cx += effectiveW(g) + LEGEND_GROUP_GAP;
1148
+ }
1185
1149
 
1186
- finalHeight += legendShift;
1187
- const neededWidth = totalGroupsWidth + MARGIN * 2;
1188
- if (neededWidth > finalWidth) {
1189
- finalWidth = neededWidth;
1190
- }
1150
+ finalHeight += legendShift;
1151
+ const neededWidth = totalGroupsWidth + MARGIN * 2;
1152
+ if (neededWidth > finalWidth) {
1153
+ finalWidth = neededWidth;
1191
1154
  }
1192
1155
  }
1193
1156
 
@@ -4,10 +4,11 @@
4
4
 
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import { FONT_FAMILY } from '../fonts';
7
+ import { runInExportContainer, extractExportSvg } from '../utils/export-container';
7
8
  import type { PaletteColors } from '../palettes';
8
9
  import { mix } from '../palettes/color-utils';
9
10
  import type { ParsedOrg } from './parser';
10
- import type { OrgLayoutResult, OrgLayoutNode } from './layout';
11
+ import type { OrgLayoutResult } from './layout';
11
12
  import { parseOrg } from './parser';
12
13
  import { layoutOrg } from './layout';
13
14
  import {
@@ -114,7 +115,6 @@ export function renderOrg(
114
115
 
115
116
  const titleOffset = parsed.title ? TITLE_HEIGHT : 0;
116
117
  const legendOnly = layout.nodes.length === 0;
117
- const legendPosition = parsed.options?.['legend-position'] ?? 'bottom';
118
118
  const hasLegend = layout.legend.length > 0;
119
119
 
120
120
  // In app mode (not export), render the legend at a fixed size outside the
@@ -147,10 +147,9 @@ export function renderOrg(
147
147
  // Center the diagram
148
148
  const scaledW = diagramW * scale;
149
149
  const offsetX = (width - scaledW) / 2;
150
- const offsetY =
151
- legendPosition === 'top' && fixedLegend
152
- ? DIAGRAM_PADDING + legendReserve + titleReserve
153
- : DIAGRAM_PADDING + titleReserve;
150
+ const offsetY = fixedLegend
151
+ ? DIAGRAM_PADDING + legendReserve + titleReserve
152
+ : DIAGRAM_PADDING + titleReserve;
154
153
 
155
154
  // Create SVG
156
155
  const svg = d3Selection
@@ -515,9 +514,7 @@ export function renderOrg(
515
514
  .attr('class', 'org-legend-fixed')
516
515
  .attr(
517
516
  'transform',
518
- legendPosition === 'bottom'
519
- ? `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`
520
- : `translate(0, ${DIAGRAM_PADDING + titleReserve})`
517
+ `translate(0, ${DIAGRAM_PADDING + titleReserve})`
521
518
  )
522
519
  : contentG;
523
520
  const legendParent = legendParentBase;
@@ -687,38 +684,16 @@ export function renderOrgForExport(
687
684
  const layout = layoutOrg(parsed, undefined, undefined, exportHidden);
688
685
  const isDark = theme === 'dark';
689
686
 
690
- // Create offscreen container
691
- const container = document.createElement('div');
692
687
  const titleOffset = parsed.title ? TITLE_HEIGHT : 0;
693
688
  const exportWidth = layout.width + DIAGRAM_PADDING * 2;
694
- const exportHeight =
695
- layout.height + DIAGRAM_PADDING * 2 + titleOffset;
689
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
696
690
 
697
- container.style.width = `${exportWidth}px`;
698
- container.style.height = `${exportHeight}px`;
699
- container.style.position = 'absolute';
700
- container.style.left = '-9999px';
701
- document.body.appendChild(container);
702
-
703
- try {
704
- // No hiddenAttributes passed to renderOrg — export never shows eye icons
691
+ // No hiddenAttributes passed to renderOrg — export never shows eye icons
692
+ return runInExportContainer(exportWidth, exportHeight, (container) => {
705
693
  renderOrg(container, parsed, layout, palette, isDark, undefined, {
706
694
  width: exportWidth,
707
695
  height: exportHeight,
708
696
  });
709
-
710
- const svgEl = container.querySelector('svg');
711
- if (!svgEl) return '';
712
-
713
- if (theme === 'transparent') {
714
- svgEl.style.background = 'none';
715
- }
716
-
717
- svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
718
- svgEl.style.fontFamily = FONT_FAMILY;
719
-
720
- return svgEl.outerHTML;
721
- } finally {
722
- document.body.removeChild(container);
723
- }
697
+ return extractExportSvg(container, theme);
698
+ });
724
699
  }