@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
@@ -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);
@@ -22,15 +22,17 @@ export {
22
22
  contrastText,
23
23
  } from './color-utils';
24
24
 
25
- // Re-export palette definitions
26
- export { nordPalette } from './nord';
27
- export { solarizedPalette } from './solarized';
25
+ // Re-export palette definitions (alphabetical)
26
+ export { boldPalette } from './bold';
28
27
  export { catppuccinPalette } from './catppuccin';
29
- export { rosePinePalette } from './rose-pine';
28
+ export { draculaPalette } from './dracula';
30
29
  export { gruvboxPalette } from './gruvbox';
31
- export { tokyoNightPalette } from './tokyo-night';
30
+ export { monokaiPalette } from './monokai';
31
+ export { nordPalette } from './nord';
32
32
  export { oneDarkPalette } from './one-dark';
33
- export { boldPalette } from './bold';
33
+ export { rosePinePalette } from './rose-pine';
34
+ export { solarizedPalette } from './solarized';
35
+ export { tokyoNightPalette } from './tokyo-night';
34
36
 
35
37
  // Re-export Mermaid bridge
36
38
  export { buildMermaidThemeVars, buildThemeCSS } from './mermaid-bridge';
@@ -0,0 +1,60 @@
1
+ import type { PaletteConfig } from './types';
2
+ import { registerPalette } from './registry';
3
+
4
+ // ============================================================
5
+ // Monokai Palette Definition
6
+ // Based on Monokai / Monokai Pro color scheme
7
+ // ============================================================
8
+
9
+ export const monokaiPalette: PaletteConfig = {
10
+ id: 'monokai',
11
+ name: 'Monokai',
12
+ light: {
13
+ bg: '#fafaf8',
14
+ surface: '#f0efe8',
15
+ overlay: '#e6e5de',
16
+ border: '#d4d3cc',
17
+ text: '#272822', // classic Monokai bg as text
18
+ textMuted: '#75715e', // comment
19
+ primary: '#49483e', // line highlight
20
+ secondary: '#f92672', // pink
21
+ accent: '#a6e22e', // green
22
+ destructive: '#f92672', // pink/red
23
+ colors: {
24
+ red: '#f92672', // Monokai pink-red
25
+ orange: '#fd971f',
26
+ yellow: '#e6db74',
27
+ green: '#a6e22e',
28
+ blue: '#5c7eab', // derived true blue
29
+ purple: '#ae81ff',
30
+ teal: '#4ea8a6', // muted from cyan
31
+ cyan: '#66d9ef',
32
+ gray: '#75715e', // comment
33
+ },
34
+ },
35
+ dark: {
36
+ bg: '#272822', // classic background
37
+ surface: '#2d2e27',
38
+ overlay: '#3e3d32', // line highlight
39
+ border: '#49483e',
40
+ text: '#f8f8f2', // foreground
41
+ textMuted: '#a6a28c', // brightened comment
42
+ primary: '#a6e22e', // green
43
+ secondary: '#66d9ef', // cyan
44
+ accent: '#f92672', // pink
45
+ destructive: '#f92672', // pink/red
46
+ colors: {
47
+ red: '#f92672',
48
+ orange: '#fd971f',
49
+ yellow: '#e6db74',
50
+ green: '#a6e22e',
51
+ blue: '#5c7eab', // derived true blue
52
+ purple: '#ae81ff',
53
+ teal: '#4ea8a6', // muted from cyan
54
+ cyan: '#66d9ef',
55
+ gray: '#75715e', // comment
56
+ },
57
+ },
58
+ };
59
+
60
+ registerPalette(monokaiPalette);
@@ -86,7 +86,9 @@ export function getPalette(id: string): PaletteConfig {
86
86
  return PALETTE_REGISTRY.get(id) ?? PALETTE_REGISTRY.get(DEFAULT_PALETTE_ID)!;
87
87
  }
88
88
 
89
- /** List all registered palettes (for the selector UI). */
89
+ /** List all registered palettes alphabetically (for the selector UI). */
90
90
  export function getAvailablePalettes(): PaletteConfig[] {
91
- return Array.from(PALETTE_REGISTRY.values());
91
+ return Array.from(PALETTE_REGISTRY.values()).sort((a, b) =>
92
+ a.name.localeCompare(b.name)
93
+ );
92
94
  }
@@ -153,12 +153,14 @@ export interface ParsedSequenceDgmo {
153
153
  error: string | null;
154
154
  }
155
155
 
156
- // "Name is a type" pattern — e.g. "AuthService is a service"
156
+ // "Name is a type" pattern — e.g. "Auth Server is a service"
157
+ // Participant names may contain spaces; [^:]+? stops at colons so that
158
+ // note lines like "note right of A: this is a service" are not falsely matched.
157
159
  // Remainder after type is parsed separately for aka/position modifiers
158
- const IS_A_PATTERN = /^(\S+)\s+is\s+an?\s+(\w+)(?:\s+(.+))?$/i;
160
+ const IS_A_PATTERN = /^([^:]+?)\s+is\s+an?\s+(\w+)(?:\s+(.+))?$/i;
159
161
 
160
162
  // Standalone "Name position N" pattern — e.g. "DB position -1"
161
- const POSITION_ONLY_PATTERN = /^(\S+)\s+position\s+(-?\d+)$/i;
163
+ const POSITION_ONLY_PATTERN = /^([^:]+?)\s+position\s+(-?\d+)$/i;
162
164
 
163
165
  // Colored participant declaration — e.g. "Tapin2(green)", "API(blue)"
164
166
  const COLORED_PARTICIPANT_PATTERN = /^(\S+?)\(([^)]+)\)\s*$/;
@@ -174,9 +176,10 @@ const SECTION_PATTERN = /^==\s+(.+?)(?:\s*==)?\s*$/;
174
176
  // Arrow pattern for sequence inference — detects any arrow form
175
177
  const ARROW_PATTERN = /\S+\s*(?:<-\S+-|<~\S+~|-\S+->|~\S+~>|->|~>|<-|<~)\s*\S+/;
176
178
 
177
- // Note patterns — "note: text", "note right of API: text", "note left of User"
178
- const NOTE_SINGLE = /^note(?:\s+(right|left)\s+of\s+(\S+))?\s*:\s*(.+)$/i;
179
- const NOTE_MULTI = /^note(?:\s+(right|left)\s+of\s+([^\s:]+))?\s*:?\s*$/i;
179
+ // Note patterns — "note: text", "note right of Auth Server: text"
180
+ // Participant names may contain spaces; the colon acts as the delimiter.
181
+ const NOTE_SINGLE = /^note(?:\s+(right|left)\s+of\s+(.+?))?\s*:\s*(.+)$/i;
182
+ const NOTE_MULTI = /^note(?:\s+(right|left)\s+of\s+(.+?))?\s*:?\s*$/i;
180
183
 
181
184
  /**
182
185
  * Parse a .dgmo file with `chart: sequence` into a structured representation.
@@ -673,7 +676,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
673
676
 
674
677
  // ---- Error: plain bidirectional arrows (A <-> B, A <~> B) ----
675
678
  const bidiPlainMatch = arrowCore.match(
676
- /^(\S+)\s*(?:<->|<~>)\s*(\S+)/
679
+ /^(.+?)\s*(?:<->|<~>)\s*(.+)/
677
680
  );
678
681
  if (bidiPlainMatch) {
679
682
  pushError(
@@ -684,8 +687,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
684
687
  }
685
688
 
686
689
  // ---- Deprecated bare return arrows: A <- B, A <~ B ----
687
- const bareReturnSync = arrowCore.match(/^(\S+)\s+<-\s+(\S+)$/);
688
- const bareReturnAsync = arrowCore.match(/^(\S+)\s+<~\s+(\S+)$/);
690
+ const bareReturnSync = arrowCore.match(/^(.+?)\s+<-\s+(.+)$/);
691
+ const bareReturnAsync = arrowCore.match(/^(.+?)\s+<~\s+(.+)$/);
689
692
  const bareReturn = bareReturnSync || bareReturnAsync;
690
693
  if (bareReturn) {
691
694
  const to = bareReturn[1];
@@ -698,8 +701,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
698
701
  }
699
702
 
700
703
  // ---- Bare (unlabeled) call arrows: A -> B, A ~> B ----
701
- const bareCallSync = arrowCore.match(/^(\S+)\s*->\s*(\S+)$/);
702
- const bareCallAsync = arrowCore.match(/^(\S+)\s*~>\s*(\S+)$/);
704
+ const bareCallSync = arrowCore.match(/^(.+?)\s*->\s*(.+)$/);
705
+ const bareCallAsync = arrowCore.match(/^(.+?)\s*~>\s*(.+)$/);
703
706
  const bareCall = bareCallSync || bareCallAsync;
704
707
  if (bareCall) {
705
708
  contentStarted = true;
@@ -10,7 +10,6 @@ import {
10
10
  truncateBareUrl,
11
11
  renderInlineText,
12
12
  } from '../utils/inline-markdown';
13
- export type { InlineSpan } from '../utils/inline-markdown';
14
13
  export { parseInlineMarkdown, truncateBareUrl };
15
14
  import { FONT_FAMILY } from '../fonts';
16
15
  import { resolveColor } from '../colors';
@@ -1282,10 +1281,12 @@ export function renderSequenceDiagram(
1282
1281
 
1283
1282
  // Compute cumulative Y positions for each step, with section dividers as stable anchors
1284
1283
  const titleOffset = title ? TITLE_HEIGHT : 0;
1284
+ const LEGEND_FIXED_GAP = 8;
1285
+ const legendTopSpace = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
1285
1286
  const groupOffset =
1286
1287
  groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
1287
1288
  const participantStartY =
1288
- TOP_MARGIN + titleOffset + PARTICIPANT_Y_OFFSET + groupOffset;
1289
+ TOP_MARGIN + titleOffset + legendTopSpace + PARTICIPANT_Y_OFFSET + groupOffset;
1289
1290
  const lifelineStartY0 = participantStartY + PARTICIPANT_BOX_HEIGHT;
1290
1291
  const hasActors = participants.some((p) => p.type === 'actor');
1291
1292
  const messageStartOffset = MESSAGE_START_OFFSET + (hasActors ? 20 : 0);
@@ -1391,8 +1392,7 @@ export function renderSequenceDiagram(
1391
1392
  PARTICIPANT_BOX_HEIGHT +
1392
1393
  Math.max(lifelineLength, 40) +
1393
1394
  40;
1394
- const legendSpace = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT : 0;
1395
- const totalHeight = contentHeight + legendSpace;
1395
+ const totalHeight = contentHeight;
1396
1396
 
1397
1397
  const containerWidth = options?.exportWidth ?? container.getBoundingClientRect().width;
1398
1398
  const svgWidth = Math.max(totalWidth, containerWidth);
@@ -1571,7 +1571,7 @@ export function renderSequenceDiagram(
1571
1571
 
1572
1572
  // Render legend pills for tag groups
1573
1573
  if (parsed.tagGroups.length > 0) {
1574
- const legendY = contentHeight;
1574
+ const legendY = TOP_MARGIN + titleOffset;
1575
1575
  const groupBg = isDark
1576
1576
  ? mix(palette.surface, palette.bg, 50)
1577
1577
  : mix(palette.surface, palette.bg, 30);
@@ -2129,7 +2129,6 @@ export function renderSequenceDiagram(
2129
2129
  // IMPORTANT: only the <g> carries data-line-number / data-section —
2130
2130
  // children must NOT have them, otherwise the click walk-up resolves
2131
2131
  // to a line-number navigation before reaching data-section-toggle.
2132
- const HIT_AREA_HEIGHT = 36;
2133
2132
  const sectionG = svg
2134
2133
  .append('g')
2135
2134
  .attr('data-section-toggle', '')
@@ -5,7 +5,6 @@
5
5
  // Resolves effective tag values for participants and messages
6
6
  // using the priority chain: explicit > group > receiver-inherit > default > neutral
7
7
 
8
- import type { TagGroup } from '../utils/tag-groups';
9
8
  import type {
10
9
  ParsedSequenceDgmo,
11
10
  SequenceParticipant,
package/src/sharing.ts CHANGED
@@ -10,6 +10,7 @@ export interface DiagramViewState {
10
10
  activeTagGroup?: string;
11
11
  collapsedGroups?: string[];
12
12
  swimlaneTagGroup?: string;
13
+ collapsedLanes?: string[];
13
14
  palette?: string;
14
15
  theme?: 'light' | 'dark';
15
16
  }
@@ -59,6 +60,10 @@ export function encodeDiagramUrl(
59
60
  hash += `&swim=${encodeURIComponent(options.viewState.swimlaneTagGroup)}`;
60
61
  }
61
62
 
63
+ if (options?.viewState?.collapsedLanes?.length) {
64
+ hash += `&cl=${encodeURIComponent(options.viewState.collapsedLanes.join(','))}`;
65
+ }
66
+
62
67
  if (options?.viewState?.palette && options.viewState.palette !== 'nord') {
63
68
  hash += `&pal=${encodeURIComponent(options.viewState.palette)}`;
64
69
  }
@@ -115,6 +120,9 @@ export function decodeDiagramUrl(hash: string): DecodedDiagramUrl {
115
120
  if (key === 'swim' && val) {
116
121
  viewState.swimlaneTagGroup = val;
117
122
  }
123
+ if (key === 'cl' && val) {
124
+ viewState.collapsedLanes = val.split(',').filter(Boolean);
125
+ }
118
126
  if (key === 'pal' && val) viewState.palette = val;
119
127
  if (key === 'th' && (val === 'light' || val === 'dark')) viewState.theme = val;
120
128
  }
@@ -3,7 +3,7 @@
3
3
  // ============================================================
4
4
 
5
5
  import dagre from '@dagrejs/dagre';
6
- import type { ParsedSitemap, SitemapNode, SitemapEdge } from './types';
6
+ import type { ParsedSitemap, SitemapNode } from './types';
7
7
  import type { TagGroup } from '../utils/tag-groups';
8
8
  import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
9
9
 
@@ -89,8 +89,6 @@ export interface SitemapLayoutResult {
89
89
  // ============================================================
90
90
 
91
91
  const CHAR_WIDTH = 7.5;
92
- const LABEL_FONT_SIZE = 13;
93
- const META_FONT_SIZE = 11;
94
92
  const META_LINE_HEIGHT = 16;
95
93
  const HEADER_HEIGHT = 28;
96
94
  const SEPARATOR_GAP = 6;
@@ -105,7 +103,6 @@ const CONTAINER_LABEL_HEIGHT = 28;
105
103
  const CONTAINER_META_LINE_HEIGHT = 16;
106
104
 
107
105
  // Legend (kanban-style pills)
108
- const LEGEND_GAP = 30;
109
106
  const LEGEND_HEIGHT = 28;
110
107
  const LEGEND_PILL_PAD = 16;
111
108
  const LEGEND_PILL_FONT_W = 11 * 0.6;
@@ -162,16 +159,6 @@ function resolveNodeColor(
162
159
 
163
160
  const OVERLAP_GAP = 20;
164
161
 
165
- function countDescendantNodes(node: SitemapNode, hiddenCounts?: Map<string, number>): number {
166
- let count = 0;
167
- for (const child of node.children) {
168
- count += (child.isContainer ? 0 : 1) + countDescendantNodes(child, hiddenCounts);
169
- const hc = hiddenCounts?.get(child.id);
170
- if (hc) count += hc;
171
- }
172
- return count;
173
- }
174
-
175
162
  // ============================================================
176
163
  // Legend
177
164
  // ============================================================
@@ -5,7 +5,7 @@
5
5
  import type { PaletteColors } from '../palettes';
6
6
  import { resolveColor } from '../colors';
7
7
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
8
- import type { TagGroup, TagEntry } from '../utils/tag-groups';
8
+ import type { TagGroup } from '../utils/tag-groups';
9
9
  import { isTagBlockHeading, matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
10
10
  import {
11
11
  measureIndent,
@@ -17,7 +17,6 @@ import {
17
17
  } from '../utils/parsing';
18
18
  import type {
19
19
  SitemapNode,
20
- SitemapEdge,
21
20
  SitemapDirection,
22
21
  ParsedSitemap,
23
22
  } from './types';
@@ -10,9 +10,6 @@ import { mix } from '../palettes/color-utils';
10
10
  import type { ParsedSitemap } from './types';
11
11
  import type {
12
12
  SitemapLayoutResult,
13
- SitemapLayoutNode,
14
- SitemapLayoutEdge,
15
- SitemapContainerBounds,
16
13
  SitemapLegendGroup,
17
14
  } from './layout';
18
15
  import {
@@ -130,9 +127,9 @@ export function renderSitemap(
130
127
  const fixedTitle = fixedLegend && !!parsed.title;
131
128
  const fixedTitleH = fixedTitle ? TITLE_HEIGHT : 0;
132
129
  const legendReserveH = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
133
- // Space reserved above content (title only), and below content (legend)
134
- const fixedReserveTop = fixedTitleH;
135
- const fixedReserveBottom = legendReserveH;
130
+ // Space reserved above content (title + legend)
131
+ const fixedReserveTop = fixedTitleH + legendReserveH;
132
+ const fixedReserveBottom = 0;
136
133
  // Title inside scaled group only when legend is NOT fixed
137
134
  const titleOffset = !fixedTitle && parsed.title ? TITLE_HEIGHT : 0;
138
135
 
@@ -546,7 +543,7 @@ export function renderSitemap(
546
543
  const legendParent = svg
547
544
  .append('g')
548
545
  .attr('class', 'sitemap-legend-fixed')
549
- .attr('transform', `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`);
546
+ .attr('transform', `translate(0, ${DIAGRAM_PADDING + fixedTitleH})`);
550
547
  if (activeTagGroup) {
551
548
  legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
552
549
  }
@@ -13,15 +13,15 @@ export interface ParsedArrow {
13
13
  async: boolean;
14
14
  }
15
15
 
16
- // Forward (call) patterns
17
- const SYNC_LABELED_RE = /^(\S+)\s+-(.+)->\s+(\S+)$/;
18
- const ASYNC_LABELED_RE = /^(\S+)\s+~(.+)~>\s+(\S+)$/;
16
+ // Forward (call) patterns — participant names may contain spaces, so use non-greedy (.+?)
17
+ const SYNC_LABELED_RE = /^(.+?)\s+-(.+)->\s+(.+)$/;
18
+ const ASYNC_LABELED_RE = /^(.+?)\s+~(.+)~>\s+(.+)$/;
19
19
 
20
20
  // Deprecated patterns — produce errors
21
- const RETURN_SYNC_LABELED_RE = /^(\S+)\s+<-(.+)-\s+(\S+)$/;
22
- const RETURN_ASYNC_LABELED_RE = /^(\S+)\s+<~(.+)~\s+(\S+)$/;
23
- const BIDI_SYNC_RE = /^(\S+)\s+<-(.+)->\s+(\S+)$/;
24
- const BIDI_ASYNC_RE = /^(\S+)\s+<~(.+)~>\s+(\S+)$/;
21
+ const RETURN_SYNC_LABELED_RE = /^(.+?)\s+<-(.+)-\s+(.+)$/;
22
+ const RETURN_ASYNC_LABELED_RE = /^(.+?)\s+<~(.+)~\s+(.+)$/;
23
+ const BIDI_SYNC_RE = /^(.+?)\s+<-(.+)->\s+(.+)$/;
24
+ const BIDI_ASYNC_RE = /^(.+?)\s+<~(.+)~>\s+(.+)$/;
25
25
 
26
26
  const ARROW_CHARS = ['->', '~>'];
27
27
 
@@ -0,0 +1,212 @@
1
+ // ============================================================
2
+ // Duration & Business Day Arithmetic
3
+ // ============================================================
4
+
5
+ import type { Duration, DurationUnit, GanttHolidays, Offset, Weekday } from '../gantt/types';
6
+
7
+ // ── Weekday constants ─────────────────────────────────────
8
+
9
+ /** JS Date.getDay() → Weekday mapping (0=Sun, 1=Mon, ..., 6=Sat) */
10
+ const JS_DAY_TO_WEEKDAY: Weekday[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
11
+
12
+ /**
13
+ * Check if a date is a workday (not a weekend and not a holiday).
14
+ */
15
+ export function isWorkday(
16
+ date: Date,
17
+ workweek: Weekday[],
18
+ holidaySet: Set<string>,
19
+ ): boolean {
20
+ const dayName = JS_DAY_TO_WEEKDAY[date.getDay()];
21
+ if (!workweek.includes(dayName)) return false;
22
+ if (holidaySet.has(formatDateKey(date))) return false;
23
+ return true;
24
+ }
25
+
26
+ /**
27
+ * Format a Date as YYYY-MM-DD for holiday set lookups.
28
+ */
29
+ export function formatDateKey(date: Date): string {
30
+ const y = date.getFullYear();
31
+ const m = String(date.getMonth() + 1).padStart(2, '0');
32
+ const d = String(date.getDate()).padStart(2, '0');
33
+ return `${y}-${m}-${d}`;
34
+ }
35
+
36
+ /**
37
+ * Build a Set of holiday date strings for efficient lookup.
38
+ * Expands date ranges into individual dates.
39
+ */
40
+ export function buildHolidaySet(holidays: GanttHolidays): Set<string> {
41
+ const set = new Set<string>();
42
+
43
+ for (const h of holidays.dates) {
44
+ set.add(h.date);
45
+ }
46
+
47
+ for (const range of holidays.ranges) {
48
+ const start = new Date(range.startDate + 'T00:00:00');
49
+ const end = new Date(range.endDate + 'T00:00:00');
50
+ const current = new Date(start);
51
+ while (current <= end) {
52
+ set.add(formatDateKey(current));
53
+ current.setDate(current.getDate() + 1);
54
+ }
55
+ }
56
+
57
+ return set;
58
+ }
59
+
60
+ /**
61
+ * Add business days to a start date, skipping weekends and holidays.
62
+ *
63
+ * For fractional business days, rounds to the nearest whole day first.
64
+ * Handles both positive amounts (forward) and zero (returns start date).
65
+ */
66
+ export function addBusinessDays(
67
+ startDate: Date,
68
+ count: number,
69
+ workweek: Weekday[],
70
+ holidaySet: Set<string>,
71
+ direction: 1 | -1 = 1,
72
+ ): Date {
73
+ const days = Math.round(Math.abs(count));
74
+ if (days === 0) return new Date(startDate);
75
+
76
+ const current = new Date(startDate);
77
+ let remaining = days;
78
+
79
+ while (remaining > 0) {
80
+ current.setDate(current.getDate() + direction);
81
+ if (isWorkday(current, workweek, holidaySet)) {
82
+ remaining--;
83
+ }
84
+ }
85
+
86
+ return current;
87
+ }
88
+
89
+ /**
90
+ * Add a gantt duration to a start date, producing an end date.
91
+ *
92
+ * Calendar units (d, w, m, q, y) ignore holidays.
93
+ * Business day units (bd) skip weekends and holidays.
94
+ */
95
+ export function addGanttDuration(
96
+ startDate: Date,
97
+ duration: Duration,
98
+ holidays: GanttHolidays,
99
+ holidaySet: Set<string>,
100
+ direction: 1 | -1 = 1,
101
+ ): Date {
102
+ const { amount, unit } = duration;
103
+
104
+ switch (unit) {
105
+ case 'bd':
106
+ return addBusinessDays(startDate, amount, holidays.workweek, holidaySet, direction);
107
+
108
+ case 'd': {
109
+ const result = new Date(startDate);
110
+ result.setDate(result.getDate() + Math.round(amount) * direction);
111
+ return result;
112
+ }
113
+
114
+ case 'w': {
115
+ const result = new Date(startDate);
116
+ result.setDate(result.getDate() + Math.round(amount * 7) * direction);
117
+ return result;
118
+ }
119
+
120
+ case 'm': {
121
+ const result = new Date(startDate);
122
+ const wholeMonths = direction === -1 ? Math.round(amount) : Math.floor(amount);
123
+ const fractionalDays = direction === -1 ? 0 : Math.round((amount - wholeMonths) * 30);
124
+ result.setMonth(result.getMonth() + wholeMonths * direction);
125
+ if (fractionalDays > 0) {
126
+ result.setDate(result.getDate() + fractionalDays * direction);
127
+ }
128
+ return result;
129
+ }
130
+
131
+ case 'q': {
132
+ const result = new Date(startDate);
133
+ const totalMonths = amount * 3;
134
+ const wholeMonths = direction === -1 ? Math.round(totalMonths) : Math.floor(totalMonths);
135
+ const fractionalDays = direction === -1 ? 0 : Math.round((totalMonths - wholeMonths) * 30);
136
+ result.setMonth(result.getMonth() + wholeMonths * direction);
137
+ if (fractionalDays > 0) {
138
+ result.setDate(result.getDate() + fractionalDays * direction);
139
+ }
140
+ return result;
141
+ }
142
+
143
+ case 'y': {
144
+ const result = new Date(startDate);
145
+ const wholeYears = direction === -1 ? Math.round(amount) : Math.floor(amount);
146
+ const fractionalMonths = direction === -1 ? 0 : Math.round((amount - wholeYears) * 12);
147
+ result.setFullYear(result.getFullYear() + wholeYears * direction);
148
+ if (fractionalMonths > 0) {
149
+ result.setMonth(result.getMonth() + fractionalMonths * direction);
150
+ }
151
+ return result;
152
+ }
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Parse a duration string like "3bd" or "5d".
158
+ */
159
+ export function parseDuration(s: string): Duration | null {
160
+ const match = s.trim().match(/^(\d+(?:\.\d+)?)(d|bd|w|m|q|y)$/);
161
+ if (!match) return null;
162
+ return { amount: parseFloat(match[1]), unit: match[2] as DurationUnit };
163
+ }
164
+
165
+ /**
166
+ * Parse an offset string like "5bd", "-3bd", or "0d".
167
+ * Returns null if the format is invalid.
168
+ * Explicit '+' prefix (e.g. "+5bd") returns null — caller should warn.
169
+ */
170
+ export function parseOffset(value: string): Offset | null {
171
+ const trimmed = value.trim();
172
+ let direction: 1 | -1 = 1;
173
+ let remainder = trimmed;
174
+
175
+ if (trimmed.startsWith('-')) {
176
+ direction = -1;
177
+ remainder = trimmed.slice(1);
178
+ } else if (trimmed.startsWith('+')) {
179
+ return null; // explicit + is not supported
180
+ }
181
+
182
+ const duration = parseDuration(remainder);
183
+ if (!duration) return null;
184
+ return { duration, direction };
185
+ }
186
+
187
+ /**
188
+ * Parse a date string (YYYY-MM-DD, YYYY-MM, or YYYY) into a Date object.
189
+ * Always returns midnight local time on the first available day.
190
+ */
191
+ export function parseGanttDate(s: string): Date {
192
+ const parts = s.split('-').map(p => parseInt(p, 10));
193
+ const year = parts[0];
194
+ const month = parts.length >= 2 ? parts[1] - 1 : 0; // JS months are 0-based
195
+ const day = parts.length >= 3 ? parts[2] : 1;
196
+ return new Date(year, month, day);
197
+ }
198
+
199
+ /**
200
+ * Format a Date as YYYY-MM-DD string.
201
+ */
202
+ export function formatGanttDate(date: Date): string {
203
+ return formatDateKey(date);
204
+ }
205
+
206
+ /**
207
+ * Calculate the difference in calendar days between two dates.
208
+ */
209
+ export function daysBetween(a: Date, b: Date): number {
210
+ const msPerDay = 86400000;
211
+ return Math.round((b.getTime() - a.getTime()) / msPerDay);
212
+ }
@@ -0,0 +1,40 @@
1
+ import { FONT_FAMILY } from '../fonts';
2
+
3
+ /**
4
+ * Creates an offscreen DOM container at the given dimensions, runs `fn` inside it,
5
+ * then removes it (try/finally). Returns whatever `fn` returns.
6
+ */
7
+ export function runInExportContainer<T>(
8
+ width: number,
9
+ height: number,
10
+ fn: (container: HTMLDivElement) => T,
11
+ ): T {
12
+ const container = document.createElement('div');
13
+ container.style.width = `${width}px`;
14
+ container.style.height = `${height}px`;
15
+ container.style.position = 'absolute';
16
+ container.style.left = '-9999px';
17
+ document.body.appendChild(container);
18
+ try {
19
+ return fn(container);
20
+ } finally {
21
+ document.body.removeChild(container);
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Extracts the SVG element from an export container, applies required export attributes
27
+ * (xmlns, fontFamily, transparent background if requested), and returns its outerHTML.
28
+ * Returns '' if no SVG element is found.
29
+ */
30
+ export function extractExportSvg(
31
+ container: HTMLElement,
32
+ theme: 'light' | 'dark' | 'transparent',
33
+ ): string {
34
+ const svgEl = container.querySelector('svg');
35
+ if (!svgEl) return '';
36
+ if (theme === 'transparent') svgEl.style.background = 'none';
37
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
38
+ svgEl.style.fontFamily = FONT_FAMILY;
39
+ return svgEl.outerHTML;
40
+ }