@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
@@ -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
  }
@@ -6,7 +6,7 @@ import { inferParticipantType } from './participant-inference';
6
6
  import type { DgmoError } from '../diagnostics';
7
7
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
8
8
  import { parseArrow } from '../utils/arrows';
9
- import { measureIndent, extractColor, parsePipeMetadata } from '../utils/parsing';
9
+ import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from '../utils/parsing';
10
10
  import type { TagGroup } from '../utils/tag-groups';
11
11
  import { matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
12
12
 
@@ -237,12 +237,13 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
237
237
  const aliasMap = new Map<string, string>();
238
238
 
239
239
  /** Split pipe metadata from a line: "core | k: v" → { core, meta } */
240
- const splitPipe = (text: string): { core: string; meta?: Record<string, string> } => {
240
+ const splitPipe = (text: string, ln?: number): { core: string; meta?: Record<string, string> } => {
241
241
  const idx = text.indexOf('|');
242
242
  if (idx < 0) return { core: text };
243
243
  const core = text.substring(0, idx).trimEnd();
244
244
  const segments = text.substring(idx).split('|');
245
- const meta = parsePipeMetadata(segments, aliasMap);
245
+ const warnFn = ln != null ? () => pushWarning(ln, MULTIPLE_PIPE_WARNING) : undefined;
246
+ const meta = parsePipeMetadata(segments, aliasMap, warnFn);
246
247
  return Object.keys(meta).length > 0 ? { core, meta } : { core };
247
248
  };
248
249
 
@@ -287,7 +288,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
287
288
  if (gpipeIdx >= 0) {
288
289
  const nameAndColor = groupName.substring(0, gpipeIdx).trimEnd();
289
290
  const segments = groupName.substring(gpipeIdx).split('|');
290
- const meta = parsePipeMetadata(segments, aliasMap);
291
+ const meta = parsePipeMetadata(segments, aliasMap, () => pushWarning(lineNumber, MULTIPLE_PIPE_WARNING));
291
292
  if (Object.keys(meta).length > 0) groupMeta = meta;
292
293
  // Re-extract color from name part
293
294
  const colorSuffix = nameAndColor.match(/^(.+?)\(([^)]+)\)$/);
@@ -444,7 +445,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
444
445
  }
445
446
 
446
447
  // Parse "Name is a type [aka Alias]" declarations (always top-level)
447
- const { core: isACore, meta: isAMeta } = splitPipe(trimmed);
448
+ const { core: isACore, meta: isAMeta } = splitPipe(trimmed, lineNumber);
448
449
  const isAMatch = isACore.match(IS_A_PATTERN);
449
450
  if (isAMatch) {
450
451
  contentStarted = true;
@@ -491,7 +492,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
491
492
  }
492
493
 
493
494
  // Parse standalone "Name position N" (no "is a" type)
494
- const { core: posCore, meta: posMeta } = splitPipe(trimmed);
495
+ const { core: posCore, meta: posMeta } = splitPipe(trimmed, lineNumber);
495
496
  const posOnlyMatch = posCore.match(POSITION_ONLY_PATTERN);
496
497
  if (posOnlyMatch) {
497
498
  contentStarted = true;
@@ -523,7 +524,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
523
524
 
524
525
  // Colored participant declaration — "Name(color)" at any level
525
526
  // Color syntax is deprecated — emit warning and register without color
526
- const { core: colorCore, meta: colorMeta } = splitPipe(trimmed);
527
+ const { core: colorCore, meta: colorMeta } = splitPipe(trimmed, lineNumber);
527
528
  const coloredMatch = colorCore.match(COLORED_PARTICIPANT_PATTERN);
528
529
  if (coloredMatch && !ARROW_PATTERN.test(colorCore)) {
529
530
  const id = coloredMatch[1];
@@ -554,7 +555,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
554
555
  // Bare participant name — either inside an active group (indented) or top-level declaration
555
556
  // Supports pipe metadata: " API | c: Gateway" or "Tapin2 | l:Park"
556
557
  {
557
- const { core: bareCore, meta: bareMeta } = splitPipe(trimmed);
558
+ const { core: bareCore, meta: bareMeta } = splitPipe(trimmed, lineNumber);
558
559
  const inGroup = activeGroup && measureIndent(raw) > 0;
559
560
  if (/^\S+$/.test(bareCore) && !ARROW_PATTERN.test(bareCore) && (inGroup || !contentStarted || bareMeta)) {
560
561
  contentStarted = true;
@@ -600,7 +601,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
600
601
  }
601
602
 
602
603
  // Split pipe metadata before arrow parsing (arrows use $ anchor)
603
- const { core: arrowCore, meta: arrowMeta } = splitPipe(trimmed);
604
+ const { core: arrowCore, meta: arrowMeta } = splitPipe(trimmed, lineNumber);
604
605
 
605
606
  // Parse message lines first — arrows take priority over keywords
606
607
  // Reject "async" keyword prefix — use ~> instead
@@ -1281,10 +1281,12 @@ export function renderSequenceDiagram(
1281
1281
 
1282
1282
  // Compute cumulative Y positions for each step, with section dividers as stable anchors
1283
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;
1284
1286
  const groupOffset =
1285
1287
  groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
1286
1288
  const participantStartY =
1287
- TOP_MARGIN + titleOffset + PARTICIPANT_Y_OFFSET + groupOffset;
1289
+ TOP_MARGIN + titleOffset + legendTopSpace + PARTICIPANT_Y_OFFSET + groupOffset;
1288
1290
  const lifelineStartY0 = participantStartY + PARTICIPANT_BOX_HEIGHT;
1289
1291
  const hasActors = participants.some((p) => p.type === 'actor');
1290
1292
  const messageStartOffset = MESSAGE_START_OFFSET + (hasActors ? 20 : 0);
@@ -1390,8 +1392,7 @@ export function renderSequenceDiagram(
1390
1392
  PARTICIPANT_BOX_HEIGHT +
1391
1393
  Math.max(lifelineLength, 40) +
1392
1394
  40;
1393
- const legendSpace = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT : 0;
1394
- const totalHeight = contentHeight + legendSpace;
1395
+ const totalHeight = contentHeight;
1395
1396
 
1396
1397
  const containerWidth = options?.exportWidth ?? container.getBoundingClientRect().width;
1397
1398
  const svgWidth = Math.max(totalWidth, containerWidth);
@@ -1570,7 +1571,7 @@ export function renderSequenceDiagram(
1570
1571
 
1571
1572
  // Render legend pills for tag groups
1572
1573
  if (parsed.tagGroups.length > 0) {
1573
- const legendY = contentHeight;
1574
+ const legendY = TOP_MARGIN + titleOffset;
1574
1575
  const groupBg = isDark
1575
1576
  ? mix(palette.surface, palette.bg, 50)
1576
1577
  : mix(palette.surface, palette.bg, 30);
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
  }
@@ -11,6 +11,7 @@ import {
11
11
  measureIndent,
12
12
  extractColor,
13
13
  parsePipeMetadata,
14
+ MULTIPLE_PIPE_WARNING,
14
15
  CHART_TYPE_RE,
15
16
  TITLE_RE,
16
17
  OPTION_RE,
@@ -360,7 +361,7 @@ export function parseSitemap(
360
361
  } else if (metadataMatch && indentStack.length === 0) {
361
362
  // Could be a node label containing ':'
362
363
  if (indent === 0) {
363
- const node = parseNodeLabel(trimmed, lineNumber, palette, ++nodeCounter, aliasMap);
364
+ const node = parseNodeLabel(trimmed, lineNumber, palette, ++nodeCounter, aliasMap, pushWarning);
364
365
  attachNode(node, indent, indentStack, result);
365
366
  labelToNode.set(node.label.toLowerCase(), node);
366
367
  } else {
@@ -368,7 +369,7 @@ export function parseSitemap(
368
369
  }
369
370
  } else {
370
371
  // Node label — possibly with pipe-delimited metadata
371
- const node = parseNodeLabel(trimmed, lineNumber, palette, ++nodeCounter, aliasMap);
372
+ const node = parseNodeLabel(trimmed, lineNumber, palette, ++nodeCounter, aliasMap, pushWarning);
372
373
  attachNode(node, indent, indentStack, result);
373
374
  labelToNode.set(node.label.toLowerCase(), node);
374
375
  }
@@ -430,11 +431,12 @@ function parseNodeLabel(
430
431
  palette: PaletteColors | undefined,
431
432
  counter: number,
432
433
  aliasMap: Map<string, string> = new Map(),
434
+ warnFn?: (line: number, msg: string) => void,
433
435
  ): SitemapNode {
434
436
  const segments = trimmed.split('|').map((s) => s.trim());
435
437
  const rawLabel = segments[0];
436
438
  const { label, color } = extractColor(rawLabel, palette);
437
- const metadata = parsePipeMetadata(segments, aliasMap);
439
+ const metadata = parsePipeMetadata(segments, aliasMap, warnFn ? () => warnFn(lineNumber, MULTIPLE_PIPE_WARNING) : undefined);
438
440
 
439
441
  return {
440
442
  id: `node-${counter}`,
@@ -127,9 +127,9 @@ export function renderSitemap(
127
127
  const fixedTitle = fixedLegend && !!parsed.title;
128
128
  const fixedTitleH = fixedTitle ? TITLE_HEIGHT : 0;
129
129
  const legendReserveH = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
130
- // Space reserved above content (title only), and below content (legend)
131
- const fixedReserveTop = fixedTitleH;
132
- const fixedReserveBottom = legendReserveH;
130
+ // Space reserved above content (title + legend)
131
+ const fixedReserveTop = fixedTitleH + legendReserveH;
132
+ const fixedReserveBottom = 0;
133
133
  // Title inside scaled group only when legend is NOT fixed
134
134
  const titleOffset = !fixedTitle && parsed.title ? TITLE_HEIGHT : 0;
135
135
 
@@ -543,7 +543,7 @@ export function renderSitemap(
543
543
  const legendParent = svg
544
544
  .append('g')
545
545
  .attr('class', 'sitemap-legend-fixed')
546
- .attr('transform', `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`);
546
+ .attr('transform', `translate(0, ${DIAGRAM_PADDING + fixedTitleH})`);
547
547
  if (activeTagGroup) {
548
548
  legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
549
549
  }
@@ -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
+ }
@@ -16,6 +16,7 @@ export const LEGEND_ENTRY_TRAIL = 8;
16
16
  export const LEGEND_GROUP_GAP = 12;
17
17
  export const LEGEND_EYE_SIZE = 14;
18
18
  export const LEGEND_EYE_GAP = 6;
19
+ export const LEGEND_ICON_W = 20;
19
20
 
20
21
  // Eye icon SVG paths (14×14 viewBox)
21
22
  // Present only in org and sitemap legends (metadata visibility toggle)
@@ -118,23 +118,34 @@ export function parseSeriesNames(
118
118
  return { series, names, nameColors, newIndex };
119
119
  }
120
120
 
121
- /** Parse pipe-delimited metadata from segments after the first (name) segment. */
121
+ /** Warning message for multiple pipes on a single line. */
122
+ export const MULTIPLE_PIPE_WARNING =
123
+ 'Use a single "|" to start metadata, then separate items with commas.';
124
+
125
+ /**
126
+ * Parse metadata from segments after the first (name) segment.
127
+ * A single `|` separates the label from metadata; items after the pipe are comma-delimited.
128
+ * Multiple pipes are treated as commas for backward compatibility but trigger a warning.
129
+ */
122
130
  export function parsePipeMetadata(
123
131
  segments: string[],
124
132
  aliasMap: Map<string, string> = new Map(),
133
+ warnMultiplePipes?: () => void,
125
134
  ): Record<string, string> {
135
+ if (segments.length > 2 && warnMultiplePipes) {
136
+ warnMultiplePipes();
137
+ }
126
138
  const metadata: Record<string, string> = {};
127
- for (let j = 1; j < segments.length; j++) {
128
- for (const part of segments[j].split(',')) {
129
- const trimmedPart = part.trim();
130
- if (!trimmedPart) continue;
131
- const colonIdx = trimmedPart.indexOf(':');
132
- if (colonIdx > 0) {
133
- const rawKey = trimmedPart.substring(0, colonIdx).trim().toLowerCase();
134
- const key = aliasMap.get(rawKey) ?? rawKey;
135
- const value = trimmedPart.substring(colonIdx + 1).trim();
136
- metadata[key] = value;
137
- }
139
+ const raw = segments.slice(1).join(',');
140
+ for (const part of raw.split(',')) {
141
+ const trimmedPart = part.trim();
142
+ if (!trimmedPart) continue;
143
+ const colonIdx = trimmedPart.indexOf(':');
144
+ if (colonIdx > 0) {
145
+ const rawKey = trimmedPart.substring(0, colonIdx).trim().toLowerCase();
146
+ const key = aliasMap.get(rawKey) ?? rawKey;
147
+ const value = trimmedPart.substring(colonIdx + 1).trim();
148
+ metadata[key] = value;
138
149
  }
139
150
  }
140
151
  return metadata;