@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.
- package/dist/cli.cjs +180 -178
- package/dist/index.cjs +5447 -2229
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +236 -16
- package/dist/index.d.ts +236 -16
- package/dist/index.js +5439 -2228
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/c4/parser.ts +3 -2
- package/src/c4/renderer.ts +6 -6
- package/src/class/renderer.ts +183 -7
- package/src/cli.ts +3 -11
- package/src/colors.ts +3 -3
- package/src/d3.ts +132 -29
- package/src/dgmo-router.ts +3 -1
- package/src/er/parser.ts +5 -3
- package/src/er/renderer.ts +11 -5
- package/src/gantt/calculator.ts +717 -0
- package/src/gantt/parser.ts +767 -0
- package/src/gantt/renderer.ts +2251 -0
- package/src/gantt/resolver.ts +144 -0
- package/src/gantt/types.ts +168 -0
- package/src/index.ts +27 -0
- package/src/infra/renderer.ts +48 -12
- package/src/initiative-status/filter.ts +63 -0
- package/src/initiative-status/layout.ts +319 -67
- package/src/initiative-status/parser.ts +200 -25
- package/src/initiative-status/renderer.ts +293 -10
- package/src/initiative-status/types.ts +6 -0
- package/src/org/layout.ts +22 -55
- package/src/org/parser.ts +7 -5
- package/src/org/renderer.ts +4 -8
- package/src/palettes/dracula.ts +60 -0
- package/src/palettes/index.ts +8 -6
- package/src/palettes/monokai.ts +60 -0
- package/src/palettes/registry.ts +4 -2
- package/src/sequence/parser.ts +10 -9
- package/src/sequence/renderer.ts +5 -4
- package/src/sharing.ts +8 -0
- package/src/sitemap/parser.ts +5 -3
- package/src/sitemap/renderer.ts +4 -4
- package/src/utils/duration.ts +212 -0
- package/src/utils/legend-constants.ts +1 -0
- package/src/utils/parsing.ts +23 -12
package/src/palettes/index.ts
CHANGED
|
@@ -22,15 +22,17 @@ export {
|
|
|
22
22
|
contrastText,
|
|
23
23
|
} from './color-utils';
|
|
24
24
|
|
|
25
|
-
// Re-export palette definitions
|
|
26
|
-
export {
|
|
27
|
-
export { solarizedPalette } from './solarized';
|
|
25
|
+
// Re-export palette definitions (alphabetical)
|
|
26
|
+
export { boldPalette } from './bold';
|
|
28
27
|
export { catppuccinPalette } from './catppuccin';
|
|
29
|
-
export {
|
|
28
|
+
export { draculaPalette } from './dracula';
|
|
30
29
|
export { gruvboxPalette } from './gruvbox';
|
|
31
|
-
export {
|
|
30
|
+
export { monokaiPalette } from './monokai';
|
|
31
|
+
export { nordPalette } from './nord';
|
|
32
32
|
export { oneDarkPalette } from './one-dark';
|
|
33
|
-
export {
|
|
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);
|
package/src/palettes/registry.ts
CHANGED
|
@@ -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
|
}
|
package/src/sequence/parser.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/sequence/renderer.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
}
|
package/src/sitemap/parser.ts
CHANGED
|
@@ -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}`,
|
package/src/sitemap/renderer.ts
CHANGED
|
@@ -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
|
|
131
|
-
const fixedReserveTop = fixedTitleH;
|
|
132
|
-
const fixedReserveBottom =
|
|
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, ${
|
|
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)
|
package/src/utils/parsing.ts
CHANGED
|
@@ -118,23 +118,34 @@ export function parseSeriesNames(
|
|
|
118
118
|
return { series, names, nameColors, newIndex };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/**
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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;
|