@diagrammo/dgmo 0.6.3 → 0.7.1

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 (44) hide show
  1. package/dist/cli.cjs +180 -178
  2. package/dist/index.cjs +5447 -2229
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +236 -16
  5. package/dist/index.d.ts +236 -16
  6. package/dist/index.js +5439 -2228
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/c4/parser.ts +3 -2
  10. package/src/c4/renderer.ts +6 -6
  11. package/src/class/renderer.ts +183 -7
  12. package/src/cli.ts +3 -11
  13. package/src/colors.ts +3 -3
  14. package/src/d3.ts +132 -29
  15. package/src/dgmo-router.ts +3 -1
  16. package/src/er/parser.ts +5 -3
  17. package/src/er/renderer.ts +11 -5
  18. package/src/gantt/calculator.ts +717 -0
  19. package/src/gantt/parser.ts +767 -0
  20. package/src/gantt/renderer.ts +2251 -0
  21. package/src/gantt/resolver.ts +144 -0
  22. package/src/gantt/types.ts +168 -0
  23. package/src/index.ts +27 -0
  24. package/src/infra/renderer.ts +48 -12
  25. package/src/initiative-status/filter.ts +63 -0
  26. package/src/initiative-status/layout.ts +319 -67
  27. package/src/initiative-status/parser.ts +200 -25
  28. package/src/initiative-status/renderer.ts +293 -10
  29. package/src/initiative-status/types.ts +6 -0
  30. package/src/org/layout.ts +22 -55
  31. package/src/org/parser.ts +7 -5
  32. package/src/org/renderer.ts +4 -8
  33. package/src/palettes/dracula.ts +60 -0
  34. package/src/palettes/index.ts +8 -6
  35. package/src/palettes/monokai.ts +60 -0
  36. package/src/palettes/registry.ts +4 -2
  37. package/src/sequence/parser.ts +10 -9
  38. package/src/sequence/renderer.ts +5 -4
  39. package/src/sharing.ts +8 -0
  40. package/src/sitemap/parser.ts +5 -3
  41. package/src/sitemap/renderer.ts +4 -4
  42. package/src/utils/duration.ts +212 -0
  43. package/src/utils/legend-constants.ts +1 -0
  44. package/src/utils/parsing.ts +23 -12
@@ -6,7 +6,21 @@ import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
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';
9
22
  import { contrastText, mix } from '../palettes/color-utils';
23
+ import type { TagGroup } from '../utils/tag-groups';
10
24
  import type { PaletteColors } from '../palettes';
11
25
  import type { ParsedInitiativeStatus, InitiativeStatus } from './types';
12
26
  import type { ParticipantType } from '../sequence/parser';
@@ -67,6 +81,44 @@ function edgeStrokeColor(status: InitiativeStatus, palette: PaletteColors, isDar
67
81
  return statusColor(status, palette, isDark);
68
82
  }
69
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
+
70
122
  // ============================================================
71
123
  // Edge path generator
72
124
  // ============================================================
@@ -410,15 +462,31 @@ function renderNodeShape(
410
462
  // Main renderer
411
463
  // ============================================================
412
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
+
413
474
  export function renderInitiativeStatus(
414
475
  container: HTMLDivElement,
415
476
  parsed: ParsedInitiativeStatus,
416
477
  layout: ISLayoutResult,
417
478
  palette: PaletteColors,
418
479
  isDark: boolean,
419
- onClickItem?: (lineNumber: number) => void,
420
- exportDims?: { width?: number; height?: number }
480
+ options?: ISRenderOptions
421
481
  ): void {
482
+ const {
483
+ onClickItem,
484
+ exportDims,
485
+ legendActive,
486
+ activeTagGroup,
487
+ hiddenTagValues,
488
+ tagGroups,
489
+ } = options ?? {};
422
490
  // Clear existing content
423
491
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
424
492
 
@@ -426,19 +494,28 @@ export function renderInitiativeStatus(
426
494
  const height = exportDims?.height ?? container.clientHeight;
427
495
  if (width <= 0 || height <= 0) return;
428
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
+
429
504
  const titleHeight = parsed.title ? 40 : 0;
505
+ const LEGEND_FIXED_GAP = 8;
506
+ const legendReserve = (hasLegend || hasTagGroups) ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
430
507
 
431
508
  // Scale to fit
432
509
  const diagramW = layout.width;
433
510
  const diagramH = layout.height;
434
- const availH = height - titleHeight;
511
+ const availH = height - titleHeight - legendReserve;
435
512
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
436
513
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
437
514
  const scale = Math.min(MAX_SCALE, scaleX, scaleY);
438
515
 
439
516
  const scaledW = diagramW * 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,9 +1127,12 @@ 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
1137
  return runInExportContainer(exportWidth, exportHeight, (container) => {
854
1138
  renderInitiativeStatus(
@@ -857,8 +1141,7 @@ export function renderInitiativeStatusForExport(
857
1141
  layout,
858
1142
  palette,
859
1143
  isDark,
860
- undefined,
861
- { width: exportWidth, height: exportHeight }
1144
+ { exportDims: { width: exportWidth, height: exportHeight } }
862
1145
  );
863
1146
  return extractExportSvg(container, theme);
864
1147
  });
@@ -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
  }
package/src/org/layout.ts CHANGED
@@ -1115,8 +1115,6 @@ export function layoutOrg(
1115
1115
  let finalWidth = totalWidth;
1116
1116
  let finalHeight = totalHeight;
1117
1117
 
1118
- const legendPosition = parsed.options?.['legend-position'] ?? 'top';
1119
-
1120
1118
  // When a tag group is active, only that group is laid out (full size).
1121
1119
  // When none is active, all groups are laid out minified — unless
1122
1120
  // expandAllLegend is set (export mode), which shows all groups expanded.
@@ -1128,62 +1126,31 @@ export function layoutOrg(
1128
1126
  activeTagGroup != null || allExpanded ? g.width : g.minifiedWidth;
1129
1127
 
1130
1128
  if (visibleGroups.length > 0) {
1131
- if (legendPosition === 'bottom') {
1132
- // Bottom: center legend groups horizontally below diagram content
1133
- const totalGroupsWidth =
1134
- visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1135
- (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1136
- const neededWidth = totalGroupsWidth + MARGIN * 2;
1137
-
1138
- if (neededWidth > totalWidth) {
1139
- finalWidth = neededWidth;
1140
- const shift = (finalWidth - totalWidth) / 2;
1141
- for (const n of layoutNodes) n.x += shift;
1142
- for (const c of containers) c.x += shift;
1143
- for (const e of layoutEdges) {
1144
- for (const p of e.points) p.x += shift;
1145
- }
1146
- }
1147
-
1148
- const contentBottom = totalHeight - MARGIN;
1149
- const legendY = contentBottom + LEGEND_GAP;
1150
- const startX = (finalWidth - totalGroupsWidth) / 2;
1151
-
1152
- let cx = startX;
1153
- for (const g of visibleGroups) {
1154
- g.x = cx;
1155
- g.y = legendY;
1156
- cx += effectiveW(g) + LEGEND_GROUP_GAP;
1157
- }
1158
-
1159
- finalHeight = totalHeight + LEGEND_GAP + LEGEND_HEIGHT;
1160
- } else {
1161
- // Top: horizontal row above chart content, left-aligned
1162
- const legendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP;
1163
-
1164
- // Push all chart content down
1165
- for (const n of layoutNodes) n.y += legendShift;
1166
- for (const c of containers) c.y += legendShift;
1167
- for (const e of layoutEdges) {
1168
- for (const p of e.points) p.y += legendShift;
1169
- }
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
+ }
1170
1138
 
1171
- const totalGroupsWidth =
1172
- visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1173
- (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;
1174
1142
 
1175
- let cx = MARGIN;
1176
- for (const g of visibleGroups) {
1177
- g.x = cx;
1178
- g.y = MARGIN;
1179
- cx += effectiveW(g) + LEGEND_GROUP_GAP;
1180
- }
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
+ }
1181
1149
 
1182
- finalHeight += legendShift;
1183
- const neededWidth = totalGroupsWidth + MARGIN * 2;
1184
- if (neededWidth > finalWidth) {
1185
- finalWidth = neededWidth;
1186
- }
1150
+ finalHeight += legendShift;
1151
+ const neededWidth = totalGroupsWidth + MARGIN * 2;
1152
+ if (neededWidth > finalWidth) {
1153
+ finalWidth = neededWidth;
1187
1154
  }
1188
1155
  }
1189
1156
 
package/src/org/parser.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  measureIndent,
8
8
  extractColor,
9
9
  parsePipeMetadata,
10
+ MULTIPLE_PIPE_WARNING,
10
11
  CHART_TYPE_RE,
11
12
  TITLE_RE,
12
13
  OPTION_RE,
@@ -280,14 +281,14 @@ export function parseOrg(
280
281
  // Otherwise it's an orphan metadata error
281
282
  if (indent === 0) {
282
283
  // Treat as a node label (e.g., "Dr. Smith: Surgeon" is a valid name)
283
- const node = parseNodeLabel(trimmed, indent, lineNumber, palette, ++nodeCounter, aliasMap);
284
+ const node = parseNodeLabel(trimmed, indent, lineNumber, palette, ++nodeCounter, aliasMap, pushWarning);
284
285
  attachNode(node, indent, indentStack, result);
285
286
  } else {
286
287
  pushError(lineNumber, 'Metadata has no parent node');
287
288
  }
288
289
  } else {
289
290
  // It's a node label — possibly with single-line pipe-delimited metadata
290
- const node = parseNodeLabel(trimmed, indent, lineNumber, palette, ++nodeCounter, aliasMap);
291
+ const node = parseNodeLabel(trimmed, indent, lineNumber, palette, ++nodeCounter, aliasMap, pushWarning);
291
292
  attachNode(node, indent, indentStack, result);
292
293
  }
293
294
  }
@@ -326,15 +327,16 @@ function parseNodeLabel(
326
327
  lineNumber: number,
327
328
  palette: PaletteColors | undefined,
328
329
  counter: number,
329
- aliasMap: Map<string, string> = new Map()
330
+ aliasMap: Map<string, string> = new Map(),
331
+ warnFn?: (line: number, msg: string) => void,
330
332
  ): OrgNode {
331
- // Check for single-line compact metadata: "Alice Park | role: Senior | location: NY"
333
+ // Check for single-line compact metadata: "Alice Park | role: Senior, location: NY"
332
334
  const segments = trimmed.split('|').map((s) => s.trim());
333
335
 
334
336
  let rawLabel = segments[0];
335
337
  const { label, color } = extractColor(rawLabel, palette);
336
338
 
337
- const metadata = parsePipeMetadata(segments, aliasMap);
339
+ const metadata = parsePipeMetadata(segments, aliasMap, warnFn ? () => warnFn(lineNumber, MULTIPLE_PIPE_WARNING) : undefined);
338
340
 
339
341
  return {
340
342
  id: `node-${counter}`,
@@ -115,7 +115,6 @@ export function renderOrg(
115
115
 
116
116
  const titleOffset = parsed.title ? TITLE_HEIGHT : 0;
117
117
  const legendOnly = layout.nodes.length === 0;
118
- const legendPosition = parsed.options?.['legend-position'] ?? 'bottom';
119
118
  const hasLegend = layout.legend.length > 0;
120
119
 
121
120
  // In app mode (not export), render the legend at a fixed size outside the
@@ -148,10 +147,9 @@ export function renderOrg(
148
147
  // Center the diagram
149
148
  const scaledW = diagramW * scale;
150
149
  const offsetX = (width - scaledW) / 2;
151
- const offsetY =
152
- legendPosition === 'top' && fixedLegend
153
- ? DIAGRAM_PADDING + legendReserve + titleReserve
154
- : DIAGRAM_PADDING + titleReserve;
150
+ const offsetY = fixedLegend
151
+ ? DIAGRAM_PADDING + legendReserve + titleReserve
152
+ : DIAGRAM_PADDING + titleReserve;
155
153
 
156
154
  // Create SVG
157
155
  const svg = d3Selection
@@ -516,9 +514,7 @@ export function renderOrg(
516
514
  .attr('class', 'org-legend-fixed')
517
515
  .attr(
518
516
  'transform',
519
- legendPosition === 'bottom'
520
- ? `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`
521
- : `translate(0, ${DIAGRAM_PADDING + titleReserve})`
517
+ `translate(0, ${DIAGRAM_PADDING + titleReserve})`
522
518
  )
523
519
  : contentG;
524
520
  const legendParent = legendParentBase;
@@ -0,0 +1,60 @@
1
+ import type { PaletteConfig } from './types';
2
+ import { registerPalette } from './registry';
3
+
4
+ // ============================================================
5
+ // Dracula Palette Definition
6
+ // https://draculatheme.com/contribute
7
+ // ============================================================
8
+
9
+ export const draculaPalette: PaletteConfig = {
10
+ id: 'dracula',
11
+ name: 'Dracula',
12
+ light: {
13
+ bg: '#f8f8f2', // foreground as light bg
14
+ surface: '#f0f0ec',
15
+ overlay: '#e8e8e2',
16
+ border: '#d8d8d2',
17
+ text: '#282a36', // background as light text
18
+ textMuted: '#44475a', // current line
19
+ primary: '#6272a4', // comment
20
+ secondary: '#bd93f9', // purple
21
+ accent: '#bd93f9', // purple
22
+ destructive: '#ff5555', // red
23
+ colors: {
24
+ red: '#ff5555',
25
+ orange: '#ffb86c',
26
+ yellow: '#f1fa8c',
27
+ green: '#50fa7b',
28
+ blue: '#6272a4', // comment blue
29
+ purple: '#bd93f9',
30
+ teal: '#5ac8b8', // muted cyan toward green
31
+ cyan: '#8be9fd',
32
+ gray: '#6272a4',
33
+ },
34
+ },
35
+ dark: {
36
+ bg: '#282a36', // background
37
+ surface: '#343746', // between bg and current line
38
+ overlay: '#44475a', // current line
39
+ border: '#6272a4', // comment
40
+ text: '#f8f8f2', // foreground
41
+ textMuted: '#bcc2d4', // muted foreground
42
+ primary: '#bd93f9', // purple (Dracula's signature)
43
+ secondary: '#8be9fd', // cyan
44
+ accent: '#ff79c6', // pink
45
+ destructive: '#ff5555', // red
46
+ colors: {
47
+ red: '#ff5555',
48
+ orange: '#ffb86c',
49
+ yellow: '#f1fa8c',
50
+ green: '#50fa7b',
51
+ blue: '#6272a4', // comment blue
52
+ purple: '#bd93f9',
53
+ teal: '#5ac8b8', // muted cyan toward green
54
+ cyan: '#8be9fd',
55
+ gray: '#6272a4',
56
+ },
57
+ },
58
+ };
59
+
60
+ registerPalette(draculaPalette);