@diagrammo/dgmo 0.8.10 → 0.8.12

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 (43) hide show
  1. package/dist/cli.cjs +245 -672
  2. package/dist/editor.cjs.map +1 -1
  3. package/dist/editor.d.cts +2 -3
  4. package/dist/editor.d.ts +2 -3
  5. package/dist/editor.js.map +1 -1
  6. package/dist/index.cjs +491 -125
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.cts +9 -1
  9. package/dist/index.d.ts +9 -1
  10. package/dist/index.js +489 -125
  11. package/dist/index.js.map +1 -1
  12. package/gallery/fixtures/er.dgmo +36 -0
  13. package/gallery/fixtures/kanban.dgmo +27 -0
  14. package/package.json +14 -17
  15. package/src/boxes-and-lines/parser.ts +2 -0
  16. package/src/boxes-and-lines/renderer.ts +13 -5
  17. package/src/c4/layout.ts +31 -10
  18. package/src/c4/parser.ts +5 -1
  19. package/src/completion.ts +17 -2
  20. package/src/d3.ts +220 -102
  21. package/src/echarts.ts +57 -58
  22. package/src/editor/index.ts +1 -2
  23. package/src/er/parser.ts +5 -1
  24. package/src/gantt/parser.ts +8 -0
  25. package/src/gantt/renderer.ts +6 -7
  26. package/src/gantt/resolver.ts +19 -14
  27. package/src/gantt/types.ts +1 -0
  28. package/src/index.ts +2 -0
  29. package/src/infra/parser.ts +4 -0
  30. package/src/kanban/parser.ts +4 -1
  31. package/src/kanban/renderer.ts +11 -8
  32. package/src/label-layout.ts +286 -0
  33. package/src/org/parser.ts +3 -0
  34. package/src/sequence/parser.ts +6 -0
  35. package/src/sequence/renderer.ts +24 -9
  36. package/src/sharing.ts +15 -5
  37. package/src/sitemap/parser.ts +2 -0
  38. package/src/utils/arrows.ts +1 -1
  39. package/src/utils/legend-layout.ts +8 -8
  40. package/src/utils/legend-svg.ts +2 -2
  41. package/src/utils/legend-types.ts +0 -3
  42. package/src/utils/parsing.ts +1 -1
  43. package/src/utils/tag-groups.ts +65 -1
package/src/echarts.ts CHANGED
@@ -4,6 +4,12 @@ import { FONT_FAMILY } from './fonts';
4
4
  import { injectBranding } from './branding';
5
5
  import { renderLegendSvg } from './utils/legend-svg';
6
6
  import type { LegendGroupData } from './utils/legend-svg';
7
+ import {
8
+ type LabelRect,
9
+ type PointCircle,
10
+ rectsOverlap,
11
+ rectCircleOverlap,
12
+ } from './label-layout';
7
13
 
8
14
  // ============================================================
9
15
  // Types
@@ -17,14 +23,14 @@ export type ExtendedChartType =
17
23
  | 'heatmap'
18
24
  | 'funnel';
19
25
 
20
- export interface ExtendedChartDataPoint {
26
+ interface ExtendedChartDataPoint {
21
27
  label: string;
22
28
  value: number;
23
29
  color?: string;
24
30
  lineNumber: number;
25
31
  }
26
32
 
27
- export interface ParsedSankeyLink {
33
+ interface ParsedSankeyLink {
28
34
  source: string;
29
35
  target: string;
30
36
  value: number;
@@ -33,14 +39,14 @@ export interface ParsedSankeyLink {
33
39
  lineNumber: number;
34
40
  }
35
41
 
36
- export interface ParsedFunction {
42
+ interface ParsedFunction {
37
43
  name: string;
38
44
  expression: string;
39
45
  color?: string;
40
46
  lineNumber: number;
41
47
  }
42
48
 
43
- export interface ParsedScatterPoint {
49
+ interface ParsedScatterPoint {
44
50
  name: string;
45
51
  x: number;
46
52
  y: number;
@@ -50,7 +56,7 @@ export interface ParsedScatterPoint {
50
56
  lineNumber: number;
51
57
  }
52
58
 
53
- export interface ParsedHeatmapRow {
59
+ interface ParsedHeatmapRow {
54
60
  label: string;
55
61
  values: number[];
56
62
  lineNumber: number;
@@ -733,7 +739,8 @@ export function buildExtendedChartOption(
733
739
 
734
740
  // Sankey chart has different structure
735
741
  if (parsed.type === 'sankey') {
736
- return buildSankeyOption(parsed, textColor, colors, titleConfig);
742
+ const bg = isDark ? palette.surface : palette.bg;
743
+ return buildSankeyOption(parsed, textColor, colors, bg, titleConfig);
737
744
  }
738
745
 
739
746
  // Chord diagram
@@ -794,6 +801,7 @@ function buildSankeyOption(
794
801
  parsed: ParsedExtendedChart,
795
802
  textColor: string,
796
803
  colors: string[],
804
+ bg: string,
797
805
  titleConfig: EChartsOption['title']
798
806
  ): EChartsOption {
799
807
  // Extract unique nodes from links
@@ -805,12 +813,18 @@ function buildSankeyOption(
805
813
  }
806
814
  }
807
815
 
808
- const nodes = Array.from(nodeSet).map((name, index) => ({
809
- name,
810
- itemStyle: {
811
- color: parsed.nodeColors?.[name] ?? colors[index % colors.length],
812
- },
813
- }));
816
+ // Tint colors with background so the diagram feels less saturated.
817
+ // Nodes get a lighter tint so they stand out; links get more tinting.
818
+ const tintNode = (c: string) => mix(c, bg, 75);
819
+ const tintLink = (c: string) => mix(c, bg, 45);
820
+
821
+ const nodeColorMap = new Map<string, string>();
822
+ const nodes = Array.from(nodeSet).map((name, index) => {
823
+ const raw = parsed.nodeColors?.[name] ?? colors[index % colors.length];
824
+ const tinted = tintNode(raw);
825
+ nodeColorMap.set(name, tintLink(raw));
826
+ return { name, itemStyle: { color: tinted } };
827
+ });
814
828
 
815
829
  return {
816
830
  ...CHART_BASE,
@@ -834,11 +848,15 @@ function buildSankeyOption(
834
848
  source: link.source,
835
849
  target: link.target,
836
850
  value: link.value,
837
- ...(link.color && { lineStyle: { color: link.color } }),
851
+ lineStyle: {
852
+ color: link.color
853
+ ? tintLink(link.color)
854
+ : nodeColorMap.get(link.source),
855
+ },
838
856
  })),
839
857
  lineStyle: {
840
- color: 'gradient',
841
858
  curveness: 0.5,
859
+ opacity: 0.6,
842
860
  },
843
861
  label: {
844
862
  color: textColor,
@@ -959,7 +977,7 @@ function buildChordOption(
959
977
  };
960
978
  });
961
979
  })(),
962
- roam: true,
980
+ roam: false,
963
981
  label: {
964
982
  position: 'right',
965
983
  formatter: '{b}',
@@ -1182,37 +1200,6 @@ export function getExtendedChartLegendGroups(
1182
1200
  // Scatter label collision avoidance — greedy placement algorithm
1183
1201
  // ---------------------------------------------------------------------------
1184
1202
 
1185
- interface LabelRect {
1186
- x: number;
1187
- y: number;
1188
- w: number;
1189
- h: number;
1190
- }
1191
- interface PointCircle {
1192
- cx: number;
1193
- cy: number;
1194
- r: number;
1195
- }
1196
-
1197
- /** Axis-aligned bounding box overlap test. @internal exported for testing */
1198
- export function rectsOverlap(a: LabelRect, b: LabelRect): boolean {
1199
- return (
1200
- a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
1201
- );
1202
- }
1203
-
1204
- /** Rect vs circle overlap using nearest-point-on-rect distance check. @internal exported for testing */
1205
- export function rectCircleOverlap(
1206
- rect: LabelRect,
1207
- circle: PointCircle
1208
- ): boolean {
1209
- const nearestX = Math.max(rect.x, Math.min(circle.cx, rect.x + rect.w));
1210
- const nearestY = Math.max(rect.y, Math.min(circle.cy, rect.y + rect.h));
1211
- const dx = nearestX - circle.cx;
1212
- const dy = nearestY - circle.cy;
1213
- return dx * dx + dy * dy < circle.r * circle.r;
1214
- }
1215
-
1216
1203
  export interface ScatterLabelPoint {
1217
1204
  name: string;
1218
1205
  px: number;
@@ -1734,12 +1721,22 @@ function buildHeatmapOption(
1734
1721
  });
1735
1722
  });
1736
1723
 
1724
+ // Rotate column labels only when they'd overlap at the default font size.
1725
+ // Estimate: each char ~7px at 12px font; rotate if longest label exceeds
1726
+ // an even share of a ~900px-wide chart.
1727
+ const CHAR_WIDTH = 7;
1728
+ const ESTIMATED_CHART_WIDTH = 900;
1729
+ const longestCol = Math.max(...columns.map((c) => c.length), 0);
1730
+ const slotWidth =
1731
+ columns.length > 0 ? ESTIMATED_CHART_WIDTH / columns.length : Infinity;
1732
+ const needsRotation = longestCol * CHAR_WIDTH > slotWidth * 0.85;
1733
+
1737
1734
  return {
1738
1735
  ...CHART_BASE,
1739
1736
  title: titleConfig,
1740
1737
  grid: {
1741
1738
  left: '3%',
1742
- right: '10%',
1739
+ right: '3%',
1743
1740
  bottom: '3%',
1744
1741
  top: parsed.title ? '15%' : '5%',
1745
1742
  containLabel: true,
@@ -1747,6 +1744,7 @@ function buildHeatmapOption(
1747
1744
  xAxis: {
1748
1745
  type: 'category',
1749
1746
  data: columns,
1747
+ position: 'top',
1750
1748
  splitArea: {
1751
1749
  show: true,
1752
1750
  },
@@ -1755,12 +1753,19 @@ function buildHeatmapOption(
1755
1753
  },
1756
1754
  axisLabel: {
1757
1755
  color: textColor,
1758
- fontSize: 16,
1756
+ fontSize: 12,
1757
+ interval: 0,
1758
+ ...(needsRotation && {
1759
+ rotate: -45,
1760
+ width: 200,
1761
+ overflow: 'none' as const,
1762
+ }),
1759
1763
  },
1760
1764
  },
1761
1765
  yAxis: {
1762
1766
  type: 'category',
1763
1767
  data: rowLabels,
1768
+ inverse: true,
1764
1769
  splitArea: {
1765
1770
  show: true,
1766
1771
  },
@@ -1769,16 +1774,14 @@ function buildHeatmapOption(
1769
1774
  },
1770
1775
  axisLabel: {
1771
1776
  color: textColor,
1772
- fontSize: 16,
1777
+ fontSize: 12,
1778
+ interval: 0,
1773
1779
  },
1774
1780
  },
1775
1781
  visualMap: {
1782
+ show: false,
1776
1783
  min: minValue,
1777
1784
  max: maxValue,
1778
- calculable: true,
1779
- orient: 'vertical',
1780
- right: '2%',
1781
- top: 'center',
1782
1785
  inRange: {
1783
1786
  color: [
1784
1787
  mix(palette.primary, bg, 30),
@@ -1787,9 +1790,6 @@ function buildHeatmapOption(
1787
1790
  mix(palette.colors.orange, bg, 30),
1788
1791
  ],
1789
1792
  },
1790
- textStyle: {
1791
- color: textColor,
1792
- },
1793
1793
  },
1794
1794
  series: [
1795
1795
  {
@@ -1806,9 +1806,8 @@ function buildHeatmapOption(
1806
1806
  fontWeight: 'bold' as const,
1807
1807
  },
1808
1808
  emphasis: {
1809
- ...EMPHASIS_SELF,
1809
+ disabled: true,
1810
1810
  },
1811
- blur: BLUR_DIM,
1812
1811
  },
1813
1812
  ],
1814
1813
  };
@@ -1,5 +1,4 @@
1
1
  import { LRLanguage, LanguageSupport } from '@codemirror/language';
2
- import type { Extension } from '@codemirror/state';
3
2
  import { parser } from './dgmo.grammar.js';
4
3
  import { dgmoHighlighting } from './highlight';
5
4
 
@@ -25,4 +24,4 @@ export const dgmoLanguageSupport = new LanguageSupport(dgmoLanguage);
25
24
  * Consumers should add indentationMarkers() separately if desired
26
25
  * (from @replit/codemirror-indentation-markers).
27
26
  */
28
- export const dgmoExtension: Extension = dgmoLanguageSupport;
27
+ export const dgmoExtension = dgmoLanguageSupport;
package/src/er/parser.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import {
12
12
  matchTagBlockHeading,
13
13
  validateTagValues,
14
+ validateTagGroupNames,
14
15
  stripDefaultModifier,
15
16
  } from '../utils/tag-groups';
16
17
  import type { TagGroup } from '../utils/tag-groups';
@@ -54,7 +55,7 @@ const CONSTRAINT_MAP: Record<string, ERConstraint> = {
54
55
  };
55
56
 
56
57
  // Known options (space-separated, no colon)
57
- const KNOWN_OPTIONS = new Set(['notation']);
58
+ const KNOWN_OPTIONS = new Set(['notation', 'active-tag']);
58
59
 
59
60
  // ============================================================
60
61
  // Cardinality parsing
@@ -418,6 +419,9 @@ export function parseERDiagram(
418
419
  result.diagnostics.push(makeDgmoError(line, msg, 'warning')),
419
420
  suggest
420
421
  );
422
+ validateTagGroupNames(result.tagGroups, (line, msg) =>
423
+ result.diagnostics.push(makeDgmoError(line, msg, 'warning'))
424
+ );
421
425
 
422
426
  // Inject defaults for tables without explicit tags
423
427
  for (const group of result.tagGroups) {
@@ -8,6 +8,7 @@ import type { TagGroup } from '../utils/tag-groups';
8
8
  import {
9
9
  matchTagBlockHeading,
10
10
  stripDefaultModifier,
11
+ validateTagGroupNames,
11
12
  } from '../utils/tag-groups';
12
13
  import {
13
14
  measureIndent,
@@ -134,6 +135,7 @@ export function parseGantt(
134
135
  dependencies: true,
135
136
  sort: 'default',
136
137
  defaultSwimlaneGroup: null,
138
+ activeTag: null,
137
139
  optionLineNumbers: {},
138
140
  holidaysLineNumber: null,
139
141
  },
@@ -645,6 +647,9 @@ export function parseGantt(
645
647
  );
646
648
  }
647
649
  break;
650
+ case 'active-tag':
651
+ result.options.activeTag = value;
652
+ break;
648
653
  }
649
654
  continue;
650
655
  }
@@ -872,6 +877,8 @@ export function parseGantt(
872
877
  result.options.sort = 'default';
873
878
  }
874
879
 
880
+ validateTagGroupNames(result.tagGroups, warn);
881
+
875
882
  return result;
876
883
 
877
884
  // ── Helper: create a task ───────────────────────────────
@@ -1033,6 +1040,7 @@ const KNOWN_OPTIONS = new Set([
1033
1040
  'dependencies',
1034
1041
  'chart',
1035
1042
  'sort',
1043
+ 'active-tag',
1036
1044
  ]);
1037
1045
 
1038
1046
  /** Boolean options that can appear as bare keywords or with `no-` prefix. */
@@ -7,7 +7,7 @@ import * as d3Selection from 'd3-selection';
7
7
  import { FONT_FAMILY } from '../fonts';
8
8
  import { getSeriesColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
- import { resolveTagColor } from '../utils/tag-groups';
10
+ import { resolveTagColor, resolveActiveTagGroup } from '../utils/tag-groups';
11
11
  import { computeTimeTicks } from '../d3';
12
12
  import {
13
13
  LEGEND_HEIGHT,
@@ -227,12 +227,11 @@ export function renderGantt(
227
227
  // ── Compute layout dimensions ───────────────────────────
228
228
 
229
229
  const seriesColors = getSeriesColors(palette);
230
- let currentActiveGroup: string | null =
231
- options?.currentActiveGroup !== undefined
232
- ? options.currentActiveGroup
233
- : resolved.tagGroups.length > 0
234
- ? resolved.tagGroups[0].name
235
- : null;
230
+ let currentActiveGroup: string | null = resolveActiveTagGroup(
231
+ resolved.tagGroups,
232
+ resolved.options.activeTag ?? undefined,
233
+ options?.currentActiveGroup
234
+ );
236
235
  let criticalPathActive = false;
237
236
 
238
237
  // ── Build row list (structural vs tag mode) ─────────────
@@ -7,16 +7,16 @@
7
7
 
8
8
  import type { GanttTask, GanttNode } from './types';
9
9
 
10
- export interface ResolverMatch {
10
+ interface ResolverMatch {
11
11
  task: GanttTask;
12
12
  }
13
13
 
14
- export interface ResolverError {
14
+ interface ResolverError {
15
15
  kind: 'not_found' | 'ambiguous';
16
16
  message: string;
17
17
  }
18
18
 
19
- export type ResolverResult = ResolverMatch | ResolverError;
19
+ type ResolverResult = ResolverMatch | ResolverError;
20
20
 
21
21
  export function isResolverError(r: ResolverResult): r is ResolverError {
22
22
  return 'kind' in r;
@@ -53,23 +53,23 @@ export function collectTasks(nodes: GanttNode[]): GanttTask[] {
53
53
  */
54
54
  export function resolveTaskName(
55
55
  name: string,
56
- allTasks: GanttTask[],
56
+ allTasks: GanttTask[]
57
57
  ): ResolverResult {
58
58
  const trimmed = name.trim();
59
59
 
60
60
  // 1. Try exact label match (no dots involved)
61
- const exactMatches = allTasks.filter(t => t.label === trimmed);
61
+ const exactMatches = allTasks.filter((t) => t.label === trimmed);
62
62
  if (exactMatches.length === 1) {
63
63
  return { task: exactMatches[0] };
64
64
  }
65
65
  if (exactMatches.length > 1) {
66
66
  // Multiple tasks with same name — need disambiguation
67
- const suggestions = exactMatches.map(t =>
67
+ const suggestions = exactMatches.map((t) =>
68
68
  t.groupPath.length > 0 ? `${t.groupPath.join('.')}.${t.label}` : t.label
69
69
  );
70
70
  return {
71
71
  kind: 'ambiguous',
72
- message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map(s => `\`${s}\``).join(' or ')}?`,
72
+ message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map((s) => `\`${s}\``).join(' or ')}?`,
73
73
  };
74
74
  }
75
75
 
@@ -80,7 +80,7 @@ export function resolveTaskName(
80
80
  const taskLabel = trimmed.substring(lastDotIdx + 1);
81
81
 
82
82
  // Find tasks whose label matches and whose group path ends with the prefix
83
- const matches = allTasks.filter(t => {
83
+ const matches = allTasks.filter((t) => {
84
84
  if (t.label !== taskLabel) return false;
85
85
  return matchesGroupPath(t.groupPath, groupPrefix);
86
86
  });
@@ -89,12 +89,12 @@ export function resolveTaskName(
89
89
  return { task: matches[0] };
90
90
  }
91
91
  if (matches.length > 1) {
92
- const suggestions = matches.map(t =>
92
+ const suggestions = matches.map((t) =>
93
93
  t.groupPath.length > 0 ? `${t.groupPath.join('.')}.${t.label}` : t.label
94
94
  );
95
95
  return {
96
96
  kind: 'ambiguous',
97
- message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map(s => `\`${s}\``).join(' or ')}?`,
97
+ message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map((s) => `\`${s}\``).join(' or ')}?`,
98
98
  };
99
99
  }
100
100
 
@@ -106,8 +106,8 @@ export function resolveTaskName(
106
106
  }
107
107
 
108
108
  // 3. No match found — try case-insensitive as a fallback for suggestions
109
- const caseInsensitive = allTasks.filter(t =>
110
- t.label.toLowerCase() === trimmed.toLowerCase()
109
+ const caseInsensitive = allTasks.filter(
110
+ (t) => t.label.toLowerCase() === trimmed.toLowerCase()
111
111
  );
112
112
  if (caseInsensitive.length > 0) {
113
113
  return {
@@ -134,11 +134,16 @@ export function resolveTaskName(
134
134
  function matchesGroupPath(groupPath: string[], prefix: string): boolean {
135
135
  // Simple case: prefix is a single segment
136
136
  if (!prefix.includes('.')) {
137
- return groupPath.some(g => g === prefix);
137
+ return groupPath.some((g) => g === prefix);
138
138
  }
139
139
 
140
140
  // Multi-segment prefix: try matching from the start of the group path
141
141
  const pathStr = groupPath.join('.');
142
142
  // Check if the full prefix matches any contiguous section of the path
143
- return pathStr === prefix || pathStr.endsWith('.' + prefix) || pathStr.startsWith(prefix + '.') || pathStr.includes('.' + prefix + '.');
143
+ return (
144
+ pathStr === prefix ||
145
+ pathStr.endsWith('.' + prefix) ||
146
+ pathStr.startsWith(prefix + '.') ||
147
+ pathStr.includes('.' + prefix + '.')
148
+ );
144
149
  }
@@ -115,6 +115,7 @@ export interface GanttOptions {
115
115
  dependencies: boolean;
116
116
  sort: 'default' | 'tag';
117
117
  defaultSwimlaneGroup: string | null; // tag group name from `sort: tag:Team`
118
+ activeTag: string | null; // from `active-tag GroupName` or `active-tag none`
118
119
  /** Line numbers for option/block keywords — maps key to source line */
119
120
  optionLineNumbers: Record<string, number>;
120
121
  holidaysLineNumber: number | null;
package/src/index.ts CHANGED
@@ -440,6 +440,8 @@ export {
440
440
  tokyoNightPalette,
441
441
  oneDarkPalette,
442
442
  boldPalette,
443
+ draculaPalette,
444
+ monokaiPalette,
443
445
  // Mermaid bridge
444
446
  buildMermaidThemeVars,
445
447
  buildThemeCSS,
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  matchTagBlockHeading,
17
17
  stripDefaultModifier,
18
+ validateTagGroupNames,
18
19
  } from '../utils/tag-groups';
19
20
  import type {
20
21
  ParsedInfra,
@@ -77,6 +78,7 @@ const TOP_LEVEL_OPTIONS = new Set([
77
78
  'default-latency-ms',
78
79
  'default-uptime',
79
80
  'default-rps',
81
+ 'active-tag',
80
82
  ]);
81
83
 
82
84
  // ============================================================
@@ -726,6 +728,8 @@ export function parseInfra(content: string): ParsedInfra {
726
728
  }
727
729
  }
728
730
 
731
+ validateTagGroupNames(result.tagGroups, warn);
732
+
729
733
  return result;
730
734
  }
731
735
 
@@ -4,6 +4,7 @@ import { resolveColor } from '../colors';
4
4
  import {
5
5
  matchTagBlockHeading,
6
6
  stripDefaultModifier,
7
+ validateTagGroupNames,
7
8
  } from '../utils/tag-groups';
8
9
  import {
9
10
  measureIndent,
@@ -29,7 +30,7 @@ const COLUMN_RE = /^\[(.+?)\](?:\s*\(([^)]+)\))?\s*(?:\|\s*(.+))?$/;
29
30
  const LEGACY_COLUMN_RE = /^==\s+(.+?)\s*(?:\[wip:\s*(\d+)\])?\s*==$/;
30
31
 
31
32
  /** Known kanban options (key-value). */
32
- const KNOWN_OPTIONS = new Set(['hide']);
33
+ const KNOWN_OPTIONS = new Set(['hide', 'active-tag']);
33
34
  /** Known kanban boolean options (bare keyword = on). */
34
35
  const KNOWN_BOOLEANS = new Set<string>(['no-auto-color']);
35
36
 
@@ -366,6 +367,8 @@ export function parseKanban(
366
367
  return fail(1, 'No columns found. Use [Column Name] to define columns');
367
368
  }
368
369
 
370
+ validateTagGroupNames(result.tagGroups, warn);
371
+
369
372
  return result;
370
373
  }
371
374
 
@@ -15,7 +15,7 @@ import type {
15
15
  } from './types';
16
16
  import { parseKanban } from './parser';
17
17
  import { isArchiveColumn } from './mutations';
18
- import { LEGEND_HEIGHT } from '../utils/legend-constants';
18
+ import { LEGEND_HEIGHT, measureLegendText } from '../utils/legend-constants';
19
19
  import { renderLegendD3 } from '../utils/legend-d3';
20
20
  import type { LegendConfig, LegendState } from '../utils/legend-types';
21
21
 
@@ -200,8 +200,7 @@ function computeLayout(
200
200
  }
201
201
 
202
202
  const totalWidth = currentX - COLUMN_GAP + DIAGRAM_PADDING;
203
- const legendSpace = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT : 0;
204
- const totalHeight = startY + maxColumnHeight + DIAGRAM_PADDING + legendSpace;
203
+ const totalHeight = startY + maxColumnHeight + DIAGRAM_PADDING;
205
204
 
206
205
  return { columns: columnLayouts, totalWidth, totalHeight };
207
206
  }
@@ -249,19 +248,23 @@ export function renderKanban(
249
248
  .text(parsed.title);
250
249
  }
251
250
 
252
- // Legend (bottom of diagram)
251
+ // Legend (top-right, inline with title)
253
252
  if (parsed.tagGroups.length > 0) {
254
- const legendY = height - LEGEND_HEIGHT;
253
+ const titleTextWidth = parsed.title
254
+ ? measureLegendText(parsed.title, TITLE_FONT_SIZE) + 16
255
+ : 0;
256
+ const legendX = DIAGRAM_PADDING + titleTextWidth;
257
+ const legendY = DIAGRAM_PADDING + (TITLE_FONT_SIZE - LEGEND_HEIGHT) / 2;
255
258
  const legendConfig: LegendConfig = {
256
259
  groups: parsed.tagGroups,
257
- position: { placement: 'top-center', titleRelation: 'below-title' },
260
+ position: { placement: 'top-center', titleRelation: 'inline-with-title' },
258
261
  mode: exportDims ? 'inline' : 'fixed',
259
262
  };
260
263
  const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
261
264
  const legendG = svg
262
265
  .append('g')
263
266
  .attr('class', 'kanban-legend')
264
- .attr('transform', `translate(${DIAGRAM_PADDING},${legendY})`);
267
+ .attr('transform', `translate(${legendX},${legendY})`);
265
268
  renderLegendD3(
266
269
  legendG,
267
270
  legendConfig,
@@ -269,7 +272,7 @@ export function renderKanban(
269
272
  palette,
270
273
  isDark,
271
274
  undefined,
272
- width - DIAGRAM_PADDING * 2
275
+ width - legendX - DIAGRAM_PADDING
273
276
  );
274
277
  }
275
278