@diagrammo/dgmo 0.8.16 → 0.8.18
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 +98 -98
- package/dist/editor.cjs +1 -1
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +1 -1
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +1 -1
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +1 -1
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +787 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.js +807 -28
- package/dist/index.js.map +1 -1
- package/docs/guide/how-dgmo-thinks.md +277 -0
- package/docs/guide/registry.json +1 -0
- package/gallery/fixtures/gantt-sprints.dgmo +20 -0
- package/package.json +1 -1
- package/src/colors.ts +1 -1
- package/src/editor/dgmo.grammar +1 -1
- package/src/editor/dgmo.grammar.js +1 -1
- package/src/gantt/calculator.ts +120 -7
- package/src/gantt/parser.ts +98 -3
- package/src/gantt/renderer.ts +259 -6
- package/src/gantt/types.ts +23 -2
- package/src/sharing.ts +4 -3
- package/src/utils/duration.ts +16 -2
- package/src/utils/legend-layout.ts +8 -4
package/src/gantt/parser.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
MULTIPLE_PIPE_ERROR,
|
|
18
18
|
parseFirstLine,
|
|
19
19
|
} from '../utils/parsing';
|
|
20
|
-
import { parseOffset } from '../utils/duration';
|
|
20
|
+
import { parseOffset, parseDuration } from '../utils/duration';
|
|
21
21
|
import type { PaletteColors } from '../palettes';
|
|
22
22
|
import { getSeriesColors } from '../palettes';
|
|
23
23
|
import type {
|
|
@@ -35,14 +35,14 @@ import type {
|
|
|
35
35
|
// ── Regexes ─────────────────────────────────────────────────
|
|
36
36
|
|
|
37
37
|
/** Duration task: `30d Label`, `1.5w Label`, `10bd? Label`, `2h Label`, `90min Label` */
|
|
38
|
-
const DURATION_RE = /^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)(\?)?\s+(.+)$/;
|
|
38
|
+
const DURATION_RE = /^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h|s)(\?)?\s+(.+)$/;
|
|
39
39
|
|
|
40
40
|
/** Explicit date task: `2024-01-15 Label` or `2024-01-15 14:30 Label` */
|
|
41
41
|
const EXPLICIT_DATE_RE = /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s+(.+)$/;
|
|
42
42
|
|
|
43
43
|
/** Timeline migration syntax: `2024-01-15 -> 30d Label` or `2024-01-15 14:30 -> 2h Label` */
|
|
44
44
|
const TIMELINE_DURATION_RE =
|
|
45
|
-
/^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s*(?:->|\u2013>)\s*(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)(\?)?\s+(.+)$/;
|
|
45
|
+
/^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s*(?:->|\u2013>)\s*(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h|s)(\?)?\s+(.+)$/;
|
|
46
46
|
|
|
47
47
|
/** Group container: `[GroupName]` with optional pipe metadata */
|
|
48
48
|
const GROUP_RE = /^\[(.+?)\]\s*(.*)$/;
|
|
@@ -138,6 +138,10 @@ export function parseGantt(
|
|
|
138
138
|
activeTag: null,
|
|
139
139
|
optionLineNumbers: {},
|
|
140
140
|
holidaysLineNumber: null,
|
|
141
|
+
sprintLength: null,
|
|
142
|
+
sprintNumber: null,
|
|
143
|
+
sprintStart: null,
|
|
144
|
+
sprintMode: null,
|
|
141
145
|
},
|
|
142
146
|
diagnostics,
|
|
143
147
|
error: null,
|
|
@@ -650,6 +654,57 @@ export function parseGantt(
|
|
|
650
654
|
case 'active-tag':
|
|
651
655
|
result.options.activeTag = value;
|
|
652
656
|
break;
|
|
657
|
+
case 'sprint-length': {
|
|
658
|
+
const dur = parseDuration(value);
|
|
659
|
+
if (!dur) {
|
|
660
|
+
warn(
|
|
661
|
+
lineNumber,
|
|
662
|
+
`Invalid sprint-length value: "${value}". Expected a duration like "2w" or "10d".`
|
|
663
|
+
);
|
|
664
|
+
} else if (dur.unit !== 'd' && dur.unit !== 'w') {
|
|
665
|
+
warn(
|
|
666
|
+
lineNumber,
|
|
667
|
+
`sprint-length only accepts "d" or "w" units, got "${dur.unit}".`
|
|
668
|
+
);
|
|
669
|
+
} else if (dur.amount <= 0) {
|
|
670
|
+
warn(lineNumber, `sprint-length must be greater than 0.`);
|
|
671
|
+
} else if (
|
|
672
|
+
!Number.isInteger(dur.amount * (dur.unit === 'w' ? 7 : 1))
|
|
673
|
+
) {
|
|
674
|
+
warn(
|
|
675
|
+
lineNumber,
|
|
676
|
+
`sprint-length must resolve to a whole number of days.`
|
|
677
|
+
);
|
|
678
|
+
} else {
|
|
679
|
+
result.options.sprintLength = dur;
|
|
680
|
+
}
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
case 'sprint-number': {
|
|
684
|
+
const n = Number(value);
|
|
685
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
|
686
|
+
warn(
|
|
687
|
+
lineNumber,
|
|
688
|
+
`sprint-number must be a positive integer, got "${value}".`
|
|
689
|
+
);
|
|
690
|
+
} else {
|
|
691
|
+
result.options.sprintNumber = n;
|
|
692
|
+
}
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
case 'sprint-start': {
|
|
696
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
697
|
+
warn(
|
|
698
|
+
lineNumber,
|
|
699
|
+
`sprint-start requires a full date (YYYY-MM-DD), got "${value}".`
|
|
700
|
+
);
|
|
701
|
+
} else if (Number.isNaN(new Date(value + 'T00:00:00').getTime())) {
|
|
702
|
+
warn(lineNumber, `sprint-start is not a valid date: "${value}".`);
|
|
703
|
+
} else {
|
|
704
|
+
result.options.sprintStart = value;
|
|
705
|
+
}
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
653
708
|
}
|
|
654
709
|
continue;
|
|
655
710
|
}
|
|
@@ -879,6 +934,31 @@ export function parseGantt(
|
|
|
879
934
|
|
|
880
935
|
validateTagGroupNames(result.tagGroups, warn);
|
|
881
936
|
|
|
937
|
+
// ── Sprint mode detection ──────────────────────────────
|
|
938
|
+
const hasSprintOption =
|
|
939
|
+
result.options.sprintLength !== null ||
|
|
940
|
+
result.options.sprintNumber !== null ||
|
|
941
|
+
result.options.sprintStart !== null;
|
|
942
|
+
|
|
943
|
+
const hasSprintUnit = hasSprintDurationUnit(result.nodes);
|
|
944
|
+
|
|
945
|
+
if (hasSprintOption) {
|
|
946
|
+
result.options.sprintMode = 'explicit';
|
|
947
|
+
} else if (hasSprintUnit) {
|
|
948
|
+
result.options.sprintMode = 'auto';
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Apply defaults when sprint mode is active
|
|
952
|
+
if (result.options.sprintMode) {
|
|
953
|
+
if (!result.options.sprintLength) {
|
|
954
|
+
result.options.sprintLength = { amount: 2, unit: 'w' };
|
|
955
|
+
}
|
|
956
|
+
if (result.options.sprintNumber === null) {
|
|
957
|
+
result.options.sprintNumber = 1;
|
|
958
|
+
}
|
|
959
|
+
// sprintStart defaults to chart start or today — handled in calculator
|
|
960
|
+
}
|
|
961
|
+
|
|
882
962
|
return result;
|
|
883
963
|
|
|
884
964
|
// ── Helper: create a task ───────────────────────────────
|
|
@@ -1041,6 +1121,9 @@ const KNOWN_OPTIONS = new Set([
|
|
|
1041
1121
|
'chart',
|
|
1042
1122
|
'sort',
|
|
1043
1123
|
'active-tag',
|
|
1124
|
+
'sprint-length',
|
|
1125
|
+
'sprint-number',
|
|
1126
|
+
'sprint-start',
|
|
1044
1127
|
]);
|
|
1045
1128
|
|
|
1046
1129
|
/** Boolean options that can appear as bare keywords or with `no-` prefix. */
|
|
@@ -1053,3 +1136,15 @@ const KNOWN_BOOLEANS = new Set([
|
|
|
1053
1136
|
function isKnownOption(key: string): boolean {
|
|
1054
1137
|
return KNOWN_OPTIONS.has(key);
|
|
1055
1138
|
}
|
|
1139
|
+
|
|
1140
|
+
/** Check if any task in the tree uses the `s` (sprint) duration unit. */
|
|
1141
|
+
function hasSprintDurationUnit(nodes: GanttNode[]): boolean {
|
|
1142
|
+
for (const node of nodes) {
|
|
1143
|
+
if (node.kind === 'task') {
|
|
1144
|
+
if (node.duration?.unit === 's') return true;
|
|
1145
|
+
} else if (node.kind === 'group' || node.kind === 'parallel') {
|
|
1146
|
+
if (hasSprintDurationUnit(node.children)) return true;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
package/src/gantt/renderer.ts
CHANGED
|
@@ -272,11 +272,16 @@ export function renderGantt(
|
|
|
272
272
|
const topDateLabelReserve = 22; // tick (6) + gap (4) + label height (~12)
|
|
273
273
|
const hasOverheadLabels =
|
|
274
274
|
resolved.markers.length > 0 || resolved.eras.length > 0;
|
|
275
|
-
const markerLabelReserve = hasOverheadLabels ?
|
|
275
|
+
const markerLabelReserve = hasOverheadLabels ? 28 : 0; // markers/eras get own row above sprint labels
|
|
276
|
+
const sprintLabelReserve = resolved.sprints.length > 0 ? 16 : 0; // sprint hover label above date labels
|
|
276
277
|
const CONTENT_TOP_PAD = 16; // breathing room between scale labels and first row
|
|
277
278
|
|
|
278
279
|
const marginTop =
|
|
279
|
-
titleHeight +
|
|
280
|
+
titleHeight +
|
|
281
|
+
tagLegendReserve +
|
|
282
|
+
topDateLabelReserve +
|
|
283
|
+
markerLabelReserve +
|
|
284
|
+
sprintLabelReserve;
|
|
280
285
|
|
|
281
286
|
// Content area
|
|
282
287
|
const contentH = isTagMode
|
|
@@ -286,7 +291,10 @@ export function renderGantt(
|
|
|
286
291
|
const outerHeight = marginTop + innerHeight + BOTTOM_MARGIN;
|
|
287
292
|
|
|
288
293
|
const containerWidth = exportDims?.width ?? (container.clientWidth || 800);
|
|
289
|
-
|
|
294
|
+
// Extra right margin when sprints present so hover date labels aren't clipped
|
|
295
|
+
const sprintRightPad = resolved.sprints.length > 0 ? 50 : 0;
|
|
296
|
+
const innerWidth =
|
|
297
|
+
containerWidth - leftMargin - RIGHT_MARGIN - sprintRightPad;
|
|
290
298
|
|
|
291
299
|
// ── Create SVG ──────────────────────────────────────────
|
|
292
300
|
|
|
@@ -418,6 +426,7 @@ export function renderGantt(
|
|
|
418
426
|
onClickItem
|
|
419
427
|
);
|
|
420
428
|
renderErasAndMarkers(g, svg, resolved, xScale, innerHeight, palette);
|
|
429
|
+
renderSprintBands(g, svg, resolved, xScale, innerHeight, palette);
|
|
421
430
|
|
|
422
431
|
// ── Today marker (line rendered before rows so it paints behind task bars) ──
|
|
423
432
|
|
|
@@ -2192,7 +2201,7 @@ function renderErasAndMarkers(
|
|
|
2192
2201
|
.append('text')
|
|
2193
2202
|
.attr('class', 'gantt-era-label')
|
|
2194
2203
|
.attr('x', (sx + ex) / 2)
|
|
2195
|
-
.attr('y', -
|
|
2204
|
+
.attr('y', -34)
|
|
2196
2205
|
.attr('text-anchor', 'middle')
|
|
2197
2206
|
.attr('font-size', '10px')
|
|
2198
2207
|
.attr('fill', color)
|
|
@@ -2257,8 +2266,8 @@ function renderErasAndMarkers(
|
|
|
2257
2266
|
const mx = xScale(parseDateToFractionalYear(marker.date));
|
|
2258
2267
|
const markerDate = parseDateStringToDate(marker.date);
|
|
2259
2268
|
const diamondSize = 5;
|
|
2260
|
-
const labelY = -
|
|
2261
|
-
const diamondY =
|
|
2269
|
+
const labelY = -34;
|
|
2270
|
+
const diamondY = -2; // below date indicator labels
|
|
2262
2271
|
|
|
2263
2272
|
const markerG = g
|
|
2264
2273
|
.append('g')
|
|
@@ -2374,6 +2383,250 @@ function renderErasAndMarkers(
|
|
|
2374
2383
|
}
|
|
2375
2384
|
}
|
|
2376
2385
|
|
|
2386
|
+
// ── Sprint band rendering ──────────────────────────────────
|
|
2387
|
+
|
|
2388
|
+
const SPRINT_BAND_OPACITY = 0.05;
|
|
2389
|
+
const SPRINT_HOVER_OPACITY = 0.12;
|
|
2390
|
+
const SPRINT_BOUNDARY_OPACITY = 0.3;
|
|
2391
|
+
|
|
2392
|
+
function renderSprintBands(
|
|
2393
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
2394
|
+
svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
2395
|
+
resolved: ResolvedSchedule,
|
|
2396
|
+
xScale: d3Scale.ScaleLinear<number, number>,
|
|
2397
|
+
innerHeight: number,
|
|
2398
|
+
palette: PaletteColors
|
|
2399
|
+
): void {
|
|
2400
|
+
if (resolved.sprints.length === 0) return;
|
|
2401
|
+
|
|
2402
|
+
// When both eras and sprints defined, eras win — don't render sprint bands
|
|
2403
|
+
if (resolved.eras.length > 0) return;
|
|
2404
|
+
|
|
2405
|
+
const bandColor = palette.textMuted || palette.text || '#888';
|
|
2406
|
+
|
|
2407
|
+
// Chart content area starts at x=0 in the g coordinate space
|
|
2408
|
+
const chartMinX = 0;
|
|
2409
|
+
|
|
2410
|
+
for (let i = 0; i < resolved.sprints.length; i++) {
|
|
2411
|
+
const sprint = resolved.sprints[i];
|
|
2412
|
+
const rawSx = xScale(dateToFractionalYear(sprint.startDate));
|
|
2413
|
+
const rawEx = xScale(dateToFractionalYear(sprint.endDate));
|
|
2414
|
+
if (rawEx <= rawSx) continue;
|
|
2415
|
+
|
|
2416
|
+
// Clip to chart content area — prevent bands from extending into swimlane margin
|
|
2417
|
+
const sx = Math.max(rawSx, chartMinX);
|
|
2418
|
+
const ex = rawEx;
|
|
2419
|
+
const bandWidth = ex - sx;
|
|
2420
|
+
if (bandWidth <= 0) continue;
|
|
2421
|
+
|
|
2422
|
+
const sprintG = g
|
|
2423
|
+
.append('g')
|
|
2424
|
+
.attr('class', 'gantt-sprint-group')
|
|
2425
|
+
.style('cursor', 'pointer');
|
|
2426
|
+
|
|
2427
|
+
// Alternating shaded bands (consistent by sprint number)
|
|
2428
|
+
const sprintRect = sprintG
|
|
2429
|
+
.append('rect')
|
|
2430
|
+
.attr('class', 'gantt-sprint-band')
|
|
2431
|
+
.attr('x', sx)
|
|
2432
|
+
.attr('y', 0)
|
|
2433
|
+
.attr('width', bandWidth)
|
|
2434
|
+
.attr('height', innerHeight)
|
|
2435
|
+
.attr('fill', bandColor)
|
|
2436
|
+
.attr('opacity', sprint.number % 2 === 0 ? SPRINT_BAND_OPACITY : 0);
|
|
2437
|
+
|
|
2438
|
+
// Invisible hit rect for hover on unshaded bands
|
|
2439
|
+
if (sprint.number % 2 !== 0) {
|
|
2440
|
+
sprintG
|
|
2441
|
+
.append('rect')
|
|
2442
|
+
.attr('x', sx)
|
|
2443
|
+
.attr('y', 0)
|
|
2444
|
+
.attr('width', bandWidth)
|
|
2445
|
+
.attr('height', innerHeight)
|
|
2446
|
+
.attr('fill', 'transparent');
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// Persistent sprint number label — always visible, turns into "Sprint X" on hover
|
|
2450
|
+
const sprintLabel = sprintG
|
|
2451
|
+
.append('text')
|
|
2452
|
+
.attr('class', 'gantt-sprint-label')
|
|
2453
|
+
.attr('x', (sx + ex) / 2)
|
|
2454
|
+
.attr('y', -22)
|
|
2455
|
+
.attr('text-anchor', 'middle')
|
|
2456
|
+
.attr('font-size', '10px')
|
|
2457
|
+
.attr('font-weight', '600')
|
|
2458
|
+
.attr('fill', bandColor)
|
|
2459
|
+
.attr('opacity', 0.4)
|
|
2460
|
+
.text(String(sprint.number));
|
|
2461
|
+
|
|
2462
|
+
// Dashed boundary line at sprint start (skip for first visible band)
|
|
2463
|
+
if (i > 0 && rawSx >= chartMinX) {
|
|
2464
|
+
sprintG
|
|
2465
|
+
.append('line')
|
|
2466
|
+
.attr('class', 'gantt-sprint-boundary')
|
|
2467
|
+
.attr('x1', sx)
|
|
2468
|
+
.attr('y1', -6)
|
|
2469
|
+
.attr('x2', sx)
|
|
2470
|
+
.attr('y2', innerHeight)
|
|
2471
|
+
.attr('stroke', bandColor)
|
|
2472
|
+
.attr('stroke-width', 1)
|
|
2473
|
+
.attr('stroke-dasharray', '3 3')
|
|
2474
|
+
.attr('opacity', SPRINT_BOUNDARY_OPACITY);
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// Determine which tasks and groups overlap this sprint (start-inclusive, end-exclusive)
|
|
2478
|
+
const sprintStartMs = sprint.startDate.getTime();
|
|
2479
|
+
const sprintEndMs = sprint.endDate.getTime();
|
|
2480
|
+
const overlappingTaskIds = new Set<string>();
|
|
2481
|
+
for (const rt of resolved.tasks) {
|
|
2482
|
+
const taskStart = rt.startDate.getTime();
|
|
2483
|
+
const taskEnd = rt.endDate.getTime();
|
|
2484
|
+
// Task overlaps sprint if it starts before sprint ends AND ends after sprint starts
|
|
2485
|
+
if (taskStart < sprintEndMs && taskEnd > sprintStartMs) {
|
|
2486
|
+
overlappingTaskIds.add(rt.task.id);
|
|
2487
|
+
}
|
|
2488
|
+
// Milestones (zero duration): include if they fall within [sprintStart, sprintEnd)
|
|
2489
|
+
if (
|
|
2490
|
+
taskStart === taskEnd &&
|
|
2491
|
+
taskStart >= sprintStartMs &&
|
|
2492
|
+
taskStart < sprintEndMs
|
|
2493
|
+
) {
|
|
2494
|
+
overlappingTaskIds.add(rt.task.id);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
const overlappingGroupNames = new Set<string>();
|
|
2498
|
+
for (const rg of resolved.groups) {
|
|
2499
|
+
const gStart = rg.startDate.getTime();
|
|
2500
|
+
const gEnd = rg.endDate.getTime();
|
|
2501
|
+
if (gStart < sprintEndMs && gEnd > sprintStartMs) {
|
|
2502
|
+
overlappingGroupNames.add(rg.name);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// Hover: highlight band, keep overlapping tasks visible, show dates + sprint label
|
|
2507
|
+
const baseOpacity = sprint.number % 2 === 0 ? SPRINT_BAND_OPACITY : 0;
|
|
2508
|
+
sprintG
|
|
2509
|
+
.on('mouseenter', () => {
|
|
2510
|
+
// Dim tasks NOT in this sprint, keep overlapping ones visible
|
|
2511
|
+
g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
|
|
2512
|
+
const el = d3Selection.select(this);
|
|
2513
|
+
const id = el.attr('data-task-id');
|
|
2514
|
+
el.attr(
|
|
2515
|
+
'opacity',
|
|
2516
|
+
id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
|
|
2517
|
+
);
|
|
2518
|
+
});
|
|
2519
|
+
g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
|
|
2520
|
+
const el = d3Selection.select(this);
|
|
2521
|
+
const id = el.attr('data-task-id');
|
|
2522
|
+
el.attr(
|
|
2523
|
+
'opacity',
|
|
2524
|
+
id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
|
|
2525
|
+
);
|
|
2526
|
+
});
|
|
2527
|
+
svg
|
|
2528
|
+
.selectAll<SVGTextElement, unknown>('.gantt-task-label')
|
|
2529
|
+
.each(function () {
|
|
2530
|
+
const el = d3Selection.select(this);
|
|
2531
|
+
const id = el.attr('data-task-id');
|
|
2532
|
+
el.attr(
|
|
2533
|
+
'opacity',
|
|
2534
|
+
id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
|
|
2535
|
+
);
|
|
2536
|
+
});
|
|
2537
|
+
g.selectAll<SVGElement, unknown>(
|
|
2538
|
+
'.gantt-group-bar, .gantt-group-summary'
|
|
2539
|
+
).each(function () {
|
|
2540
|
+
const el = d3Selection.select(this);
|
|
2541
|
+
const name = el.attr('data-group');
|
|
2542
|
+
el.attr(
|
|
2543
|
+
'opacity',
|
|
2544
|
+
name && overlappingGroupNames.has(name) ? 1 : FADE_OPACITY
|
|
2545
|
+
);
|
|
2546
|
+
});
|
|
2547
|
+
svg
|
|
2548
|
+
.selectAll<SVGGElement, unknown>('.gantt-group-label')
|
|
2549
|
+
.each(function () {
|
|
2550
|
+
const el = d3Selection.select(this);
|
|
2551
|
+
const name = el.attr('data-group');
|
|
2552
|
+
el.attr(
|
|
2553
|
+
'opacity',
|
|
2554
|
+
name && overlappingGroupNames.has(name) ? 1 : FADE_OPACITY
|
|
2555
|
+
);
|
|
2556
|
+
});
|
|
2557
|
+
svg
|
|
2558
|
+
.selectAll<SVGGElement, unknown>('.gantt-lane-header')
|
|
2559
|
+
.attr('opacity', FADE_OPACITY);
|
|
2560
|
+
g.selectAll<SVGElement, unknown>(
|
|
2561
|
+
'.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
|
|
2562
|
+
).attr('opacity', FADE_OPACITY);
|
|
2563
|
+
g.selectAll<SVGElement, unknown>(
|
|
2564
|
+
'.gantt-dep-arrow, .gantt-dep-arrowhead'
|
|
2565
|
+
).attr('opacity', FADE_OPACITY);
|
|
2566
|
+
g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
|
|
2567
|
+
'opacity',
|
|
2568
|
+
FADE_OPACITY
|
|
2569
|
+
);
|
|
2570
|
+
sprintRect.attr('opacity', SPRINT_HOVER_OPACITY);
|
|
2571
|
+
// Only show start date indicator if it's within the visible chart area
|
|
2572
|
+
const startVisible = rawSx >= chartMinX;
|
|
2573
|
+
if (startVisible) {
|
|
2574
|
+
showGanttDateIndicators(
|
|
2575
|
+
g,
|
|
2576
|
+
xScale,
|
|
2577
|
+
sprint.startDate,
|
|
2578
|
+
sprint.endDate,
|
|
2579
|
+
innerHeight,
|
|
2580
|
+
bandColor
|
|
2581
|
+
);
|
|
2582
|
+
} else {
|
|
2583
|
+
// Only show end date indicator (start is clipped/off-screen)
|
|
2584
|
+
showGanttDateIndicators(
|
|
2585
|
+
g,
|
|
2586
|
+
xScale,
|
|
2587
|
+
sprint.endDate,
|
|
2588
|
+
null,
|
|
2589
|
+
innerHeight,
|
|
2590
|
+
bandColor
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
2593
|
+
// Swap persistent label to full "Sprint X" on hover
|
|
2594
|
+
const accentColor = palette.accent || palette.text || bandColor;
|
|
2595
|
+
sprintLabel
|
|
2596
|
+
.text(`Sprint ${sprint.number}`)
|
|
2597
|
+
.attr('font-size', '13px')
|
|
2598
|
+
.attr('font-weight', '700')
|
|
2599
|
+
.attr('fill', accentColor)
|
|
2600
|
+
.attr('opacity', 1);
|
|
2601
|
+
})
|
|
2602
|
+
.on('mouseleave', () => {
|
|
2603
|
+
resetHighlight(g, svg);
|
|
2604
|
+
sprintRect.attr('opacity', baseOpacity);
|
|
2605
|
+
sprintLabel
|
|
2606
|
+
.text(String(sprint.number))
|
|
2607
|
+
.attr('font-size', '10px')
|
|
2608
|
+
.attr('font-weight', '600')
|
|
2609
|
+
.attr('fill', bandColor)
|
|
2610
|
+
.attr('opacity', 0.4);
|
|
2611
|
+
hideGanttDateIndicators(g);
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
// Boundary line at end of last sprint
|
|
2616
|
+
const lastSprint = resolved.sprints[resolved.sprints.length - 1];
|
|
2617
|
+
const lastEx = xScale(dateToFractionalYear(lastSprint.endDate));
|
|
2618
|
+
g.append('line')
|
|
2619
|
+
.attr('class', 'gantt-sprint-boundary')
|
|
2620
|
+
.attr('x1', lastEx)
|
|
2621
|
+
.attr('y1', -6)
|
|
2622
|
+
.attr('x2', lastEx)
|
|
2623
|
+
.attr('y2', innerHeight)
|
|
2624
|
+
.attr('stroke', bandColor)
|
|
2625
|
+
.attr('stroke-width', 1)
|
|
2626
|
+
.attr('stroke-dasharray', '3 3')
|
|
2627
|
+
.attr('opacity', SPRINT_BOUNDARY_OPACITY);
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2377
2630
|
/**
|
|
2378
2631
|
* Parse a date string (YYYY, YYYY-MM, YYYY-MM-DD) to a Date object.
|
|
2379
2632
|
* Used for eras and markers which store dates as strings.
|
package/src/gantt/types.ts
CHANGED
|
@@ -7,8 +7,17 @@ import type { TagGroup } from '../utils/tag-groups';
|
|
|
7
7
|
|
|
8
8
|
// ── Duration ────────────────────────────────────────────────
|
|
9
9
|
|
|
10
|
-
/** Calendar units: d (days), w (weeks), m (months), q (quarters), y (years), h (hours), min (minutes). bd = business days. */
|
|
11
|
-
export type DurationUnit =
|
|
10
|
+
/** Calendar units: d (days), w (weeks), m (months), q (quarters), y (years), h (hours), min (minutes). bd = business days. s = sprints. */
|
|
11
|
+
export type DurationUnit =
|
|
12
|
+
| 'd'
|
|
13
|
+
| 'bd'
|
|
14
|
+
| 'w'
|
|
15
|
+
| 'm'
|
|
16
|
+
| 'q'
|
|
17
|
+
| 'y'
|
|
18
|
+
| 'h'
|
|
19
|
+
| 'min'
|
|
20
|
+
| 's';
|
|
12
21
|
|
|
13
22
|
export interface Duration {
|
|
14
23
|
amount: number;
|
|
@@ -119,6 +128,11 @@ export interface GanttOptions {
|
|
|
119
128
|
/** Line numbers for option/block keywords — maps key to source line */
|
|
120
129
|
optionLineNumbers: Record<string, number>;
|
|
121
130
|
holidaysLineNumber: number | null;
|
|
131
|
+
// ── Sprint options ─────────────────────────────────────────
|
|
132
|
+
sprintLength: Duration | null; // default { amount: 2, unit: 'w' } when sprint mode active
|
|
133
|
+
sprintNumber: number | null; // which sprint the chart starts at (default 1)
|
|
134
|
+
sprintStart: string | null; // YYYY-MM-DD — date that sprintNumber begins
|
|
135
|
+
sprintMode: 'auto' | 'explicit' | null; // auto = activated by `s` unit, explicit = sprint-* option present
|
|
122
136
|
}
|
|
123
137
|
|
|
124
138
|
// ── Parsed Result ───────────────────────────────────────────
|
|
@@ -158,6 +172,12 @@ export interface ResolvedGroup {
|
|
|
158
172
|
depth: number;
|
|
159
173
|
}
|
|
160
174
|
|
|
175
|
+
export interface ResolvedSprint {
|
|
176
|
+
number: number;
|
|
177
|
+
startDate: Date;
|
|
178
|
+
endDate: Date;
|
|
179
|
+
}
|
|
180
|
+
|
|
161
181
|
export interface ResolvedSchedule {
|
|
162
182
|
tasks: ResolvedTask[];
|
|
163
183
|
groups: ResolvedGroup[];
|
|
@@ -167,6 +187,7 @@ export interface ResolvedSchedule {
|
|
|
167
187
|
tagGroups: TagGroup[];
|
|
168
188
|
eras: GanttEra[];
|
|
169
189
|
markers: GanttMarker[];
|
|
190
|
+
sprints: ResolvedSprint[];
|
|
170
191
|
options: GanttOptions;
|
|
171
192
|
diagnostics: DgmoError[];
|
|
172
193
|
error: string | null;
|
package/src/sharing.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
compressToEncodedURIComponent,
|
|
3
|
+
decompressFromEncodedURIComponent,
|
|
4
|
+
} from 'lz-string';
|
|
4
5
|
|
|
5
6
|
const DEFAULT_BASE_URL = 'https://online.diagrammo.app';
|
|
6
7
|
const COMPRESSED_SIZE_LIMIT = 8192; // 8 KB
|
package/src/utils/duration.ts
CHANGED
|
@@ -120,7 +120,8 @@ export function addGanttDuration(
|
|
|
120
120
|
duration: Duration,
|
|
121
121
|
holidays: GanttHolidays,
|
|
122
122
|
holidaySet: Set<string>,
|
|
123
|
-
direction: 1 | -1 = 1
|
|
123
|
+
direction: 1 | -1 = 1,
|
|
124
|
+
opts?: { sprintLength?: Duration }
|
|
124
125
|
): Date {
|
|
125
126
|
const { amount, unit } = duration;
|
|
126
127
|
|
|
@@ -197,6 +198,19 @@ export function addGanttDuration(
|
|
|
197
198
|
result.setTime(result.getTime() + amount * 60000 * direction);
|
|
198
199
|
return result;
|
|
199
200
|
}
|
|
201
|
+
|
|
202
|
+
case 's': {
|
|
203
|
+
if (!opts?.sprintLength) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
'Sprint duration unit "s" requires sprintLength configuration'
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const sl = opts.sprintLength;
|
|
209
|
+
const totalDays = amount * sl.amount * (sl.unit === 'w' ? 7 : 1);
|
|
210
|
+
const result = new Date(startDate);
|
|
211
|
+
result.setDate(result.getDate() + Math.round(totalDays) * direction);
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
200
214
|
}
|
|
201
215
|
}
|
|
202
216
|
|
|
@@ -204,7 +218,7 @@ export function addGanttDuration(
|
|
|
204
218
|
* Parse a duration string like "3bd" or "5d".
|
|
205
219
|
*/
|
|
206
220
|
export function parseDuration(s: string): Duration | null {
|
|
207
|
-
const match = s.trim().match(/^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)$/);
|
|
221
|
+
const match = s.trim().match(/^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h|s)$/);
|
|
208
222
|
if (!match) return null;
|
|
209
223
|
return { amount: parseFloat(match[1]), unit: match[2] as DurationUnit };
|
|
210
224
|
}
|
|
@@ -107,14 +107,16 @@ function capsuleWidth(
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
// Multi-row: compute how many entries fit per row
|
|
110
|
-
|
|
110
|
+
// Right boundary leaves one LEGEND_CAPSULE_PAD for right padding;
|
|
111
|
+
// left padding is already baked into the starting rowX.
|
|
112
|
+
const rowWidth = maxCapsuleW - LEGEND_CAPSULE_PAD;
|
|
111
113
|
let row = 1;
|
|
112
|
-
let rowX = pw + 4;
|
|
114
|
+
let rowX = LEGEND_CAPSULE_PAD + pw + 4 + addonWidth;
|
|
113
115
|
let visible = 0;
|
|
114
116
|
|
|
115
117
|
for (let i = 0; i < entries.length; i++) {
|
|
116
118
|
const ew2 = entryWidth(entries[i].value);
|
|
117
|
-
if (rowX + ew2 > rowWidth &&
|
|
119
|
+
if (rowX + ew2 > rowWidth && i > 0) {
|
|
118
120
|
row++;
|
|
119
121
|
rowX = 0;
|
|
120
122
|
if (row > LEGEND_MAX_ENTRY_ROWS) {
|
|
@@ -323,7 +325,9 @@ function buildCapsuleLayout(
|
|
|
323
325
|
let ex = LEGEND_CAPSULE_PAD + pw + 4 + addonWidth;
|
|
324
326
|
let ey = 0;
|
|
325
327
|
let rowX = ex;
|
|
326
|
-
|
|
328
|
+
// Right boundary: one LEGEND_CAPSULE_PAD for right padding.
|
|
329
|
+
// Left padding is already in ex/rowX starting position.
|
|
330
|
+
const maxRowW = containerWidth - LEGEND_CAPSULE_PAD;
|
|
327
331
|
let currentRow = 0;
|
|
328
332
|
|
|
329
333
|
for (let i = 0; i < info.visibleEntries; i++) {
|