@diagrammo/dgmo 0.7.2 → 0.8.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.
- package/AGENTS.md +15 -20
- package/README.md +56 -58
- package/dist/cli.cjs +188 -181
- package/dist/index.cjs +3529 -1061
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +196 -43
- package/dist/index.d.ts +196 -43
- package/dist/index.js +3516 -1061
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +629 -289
- package/package.json +1 -1
- package/src/c4/layout.ts +6 -9
- package/src/c4/parser.ts +189 -83
- package/src/c4/renderer.ts +8 -9
- package/src/chart.ts +296 -83
- package/src/class/parser.ts +54 -37
- package/src/class/renderer.ts +8 -8
- package/src/cli.ts +8 -8
- package/src/colors.ts +4 -1
- package/src/completion.ts +757 -10
- package/src/d3.ts +312 -73
- package/src/dgmo-router.ts +63 -8
- package/src/echarts.ts +726 -231
- package/src/er/parser.ts +94 -76
- package/src/er/renderer.ts +6 -5
- package/src/gantt/parser.ts +144 -69
- package/src/gantt/renderer.ts +50 -14
- package/src/gantt/types.ts +3 -3
- package/src/graph/flowchart-parser.ts +97 -37
- package/src/graph/flowchart-renderer.ts +4 -3
- package/src/graph/state-parser.ts +50 -31
- package/src/graph/state-renderer.ts +4 -3
- package/src/index.ts +14 -5
- package/src/infra/compute.ts +1 -0
- package/src/infra/layout.ts +3 -0
- package/src/infra/parser.ts +291 -92
- package/src/infra/renderer.ts +172 -30
- package/src/infra/types.ts +5 -0
- package/src/initiative-status/layout.ts +1 -1
- package/src/initiative-status/parser.ts +121 -47
- package/src/initiative-status/renderer.ts +82 -31
- package/src/initiative-status/types.ts +10 -2
- package/src/kanban/parser.ts +60 -37
- package/src/kanban/renderer.ts +2 -2
- package/src/kanban/types.ts +1 -0
- package/src/org/layout.ts +9 -9
- package/src/org/parser.ts +39 -40
- package/src/org/renderer.ts +5 -6
- package/src/org/resolver.ts +26 -19
- package/src/render.ts +1 -1
- package/src/sequence/parser.ts +304 -95
- package/src/sequence/renderer.ts +9 -9
- package/src/sitemap/layout.ts +3 -4
- package/src/sitemap/parser.ts +57 -49
- package/src/sitemap/renderer.ts +6 -7
- package/src/utils/arrows.ts +25 -6
- package/src/utils/duration.ts +43 -7
- package/src/utils/legend-constants.ts +26 -0
- package/src/utils/legend-svg.ts +167 -0
- package/src/utils/parsing.ts +247 -7
- package/src/utils/tag-groups.ts +160 -15
- package/src/utils/title-constants.ts +9 -0
package/src/d3.ts
CHANGED
|
@@ -88,6 +88,7 @@ export interface TimelineEra {
|
|
|
88
88
|
endDate: string;
|
|
89
89
|
label: string;
|
|
90
90
|
color: string | null;
|
|
91
|
+
lineNumber: number;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
export interface TimelineMarker {
|
|
@@ -181,22 +182,22 @@ import { getSeriesColors } from './palettes';
|
|
|
181
182
|
import { mix } from './palettes/color-utils';
|
|
182
183
|
import type { DgmoError } from './diagnostics';
|
|
183
184
|
import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
|
|
184
|
-
import { collectIndentedValues, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from './utils/parsing';
|
|
185
|
+
import { collectIndentedValues, extractColor, normalizeDirection, parseFirstLine, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from './utils/parsing';
|
|
185
186
|
import { matchTagBlockHeading, validateTagValues, resolveTagColor } from './utils/tag-groups';
|
|
186
187
|
import type { TagGroup } from './utils/tag-groups';
|
|
187
188
|
import {
|
|
188
189
|
LEGEND_HEIGHT as TL_LEGEND_HEIGHT,
|
|
189
190
|
LEGEND_PILL_PAD as TL_LEGEND_PILL_PAD,
|
|
190
191
|
LEGEND_PILL_FONT_SIZE as TL_LEGEND_PILL_FONT_SIZE,
|
|
191
|
-
LEGEND_PILL_FONT_W as TL_LEGEND_PILL_FONT_W,
|
|
192
192
|
LEGEND_CAPSULE_PAD as TL_LEGEND_CAPSULE_PAD,
|
|
193
193
|
LEGEND_DOT_R as TL_LEGEND_DOT_R,
|
|
194
194
|
LEGEND_ENTRY_FONT_SIZE as TL_LEGEND_ENTRY_FONT_SIZE,
|
|
195
|
-
LEGEND_ENTRY_FONT_W as TL_LEGEND_ENTRY_FONT_W,
|
|
196
195
|
LEGEND_ENTRY_DOT_GAP as TL_LEGEND_ENTRY_DOT_GAP,
|
|
197
196
|
LEGEND_ENTRY_TRAIL as TL_LEGEND_ENTRY_TRAIL,
|
|
198
197
|
LEGEND_GROUP_GAP as TL_LEGEND_GROUP_GAP,
|
|
198
|
+
measureLegendText,
|
|
199
199
|
} from './utils/legend-constants';
|
|
200
|
+
import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y } from './utils/title-constants';
|
|
200
201
|
|
|
201
202
|
// ============================================================
|
|
202
203
|
// Shared Rendering Helpers
|
|
@@ -217,11 +218,11 @@ function renderChartTitle(
|
|
|
217
218
|
const titleEl = svg.append('text')
|
|
218
219
|
.attr('class', 'chart-title')
|
|
219
220
|
.attr('x', width / 2)
|
|
220
|
-
.attr('y',
|
|
221
|
+
.attr('y', TITLE_Y)
|
|
221
222
|
.attr('text-anchor', 'middle')
|
|
222
223
|
.attr('fill', textColor)
|
|
223
|
-
.attr('font-size',
|
|
224
|
-
.attr('font-weight',
|
|
224
|
+
.attr('font-size', TITLE_FONT_SIZE)
|
|
225
|
+
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
225
226
|
.style('cursor', onClickItem && titleLineNumber ? 'pointer' : 'default')
|
|
226
227
|
.text(title);
|
|
227
228
|
if (titleLineNumber) {
|
|
@@ -261,45 +262,97 @@ function initD3Chart(
|
|
|
261
262
|
// ============================================================
|
|
262
263
|
|
|
263
264
|
/**
|
|
264
|
-
* Converts a date string (YYYY, YYYY-MM, YYYY-MM-DD) to a fractional year number.
|
|
265
|
+
* Converts a date string (YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM) to a fractional year number.
|
|
265
266
|
*/
|
|
266
267
|
export function parseTimelineDate(s: string): number {
|
|
267
|
-
|
|
268
|
+
// Split off optional time component
|
|
269
|
+
const spaceIdx = s.indexOf(' ');
|
|
270
|
+
let datePart = s;
|
|
271
|
+
let hour = 0;
|
|
272
|
+
let minute = 0;
|
|
273
|
+
|
|
274
|
+
if (spaceIdx !== -1) {
|
|
275
|
+
datePart = s.slice(0, spaceIdx);
|
|
276
|
+
const timePart = s.slice(spaceIdx + 1);
|
|
277
|
+
const timeParts = timePart.split(':');
|
|
278
|
+
if (timeParts.length === 2) {
|
|
279
|
+
hour = parseInt(timeParts[0], 10);
|
|
280
|
+
minute = parseInt(timeParts[1], 10);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const parts = datePart.split('-').map((p) => parseInt(p, 10));
|
|
268
285
|
const year = parts[0];
|
|
269
286
|
const month = parts.length >= 2 ? parts[1] : 1;
|
|
270
287
|
const day = parts.length >= 3 ? parts[2] : 1;
|
|
271
|
-
return year + (month - 1) / 12 + (day - 1) / 365;
|
|
288
|
+
return year + (month - 1) / 12 + (day - 1) / 365 + hour / 8760 + minute / 525600;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Convert a fractional year number back to a Date (inverse of parseTimelineDate). */
|
|
292
|
+
function fractionalYearToDate(frac: number): Date {
|
|
293
|
+
const year = Math.floor(frac);
|
|
294
|
+
const remainder = frac - year;
|
|
295
|
+
// Inverse of: (month-1)/12 + (day-1)/365 + hour/8760 + minute/525600
|
|
296
|
+
const monthFrac = remainder * 12;
|
|
297
|
+
const month = Math.floor(monthFrac); // 0-based
|
|
298
|
+
const monthRemainder = remainder - month / 12;
|
|
299
|
+
const dayFrac = monthRemainder * 365; // fractional day-of-year offset
|
|
300
|
+
const day = Math.floor(dayFrac) + 1;
|
|
301
|
+
const dayRemainder = dayFrac - Math.floor(dayFrac);
|
|
302
|
+
const hourFrac = dayRemainder * 24;
|
|
303
|
+
const hour = Math.floor(hourFrac);
|
|
304
|
+
const minute = Math.round((hourFrac - hour) * 60);
|
|
305
|
+
return new Date(year, month, day, hour, minute);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Convert a Date to a fractional year number. */
|
|
309
|
+
function dateToFractionalYear(d: Date): number {
|
|
310
|
+
return d.getFullYear() + d.getMonth() / 12 + (d.getDate() - 1) / 365
|
|
311
|
+
+ d.getHours() / 8760 + d.getMinutes() / 525600;
|
|
272
312
|
}
|
|
273
313
|
|
|
274
314
|
/**
|
|
275
315
|
* Adds a duration to a date string and returns the resulting date string.
|
|
276
|
-
* Supports: d (days), w (weeks), m (months), y (years)
|
|
316
|
+
* Supports: d (days), w (weeks), m (months), y (years), h (hours), min (minutes)
|
|
277
317
|
* Supports decimals up to 2 places (e.g., 1.25y = 1 year 3 months)
|
|
278
|
-
* Preserves the precision of the input date (YYYY, YYYY-MM, or YYYY-MM-DD).
|
|
318
|
+
* Preserves the precision of the input date (YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM).
|
|
279
319
|
*/
|
|
280
320
|
export function addDurationToDate(
|
|
281
321
|
startDate: string,
|
|
282
322
|
amount: number,
|
|
283
|
-
unit: 'd' | 'w' | 'm' | 'y'
|
|
323
|
+
unit: 'd' | 'w' | 'm' | 'y' | 'h' | 'min'
|
|
284
324
|
): string {
|
|
285
|
-
|
|
325
|
+
// Split off optional time component
|
|
326
|
+
const spaceIdx = startDate.indexOf(' ');
|
|
327
|
+
let datePart = startDate;
|
|
328
|
+
let hour = 0;
|
|
329
|
+
let minute = 0;
|
|
330
|
+
|
|
331
|
+
if (spaceIdx !== -1) {
|
|
332
|
+
datePart = startDate.slice(0, spaceIdx);
|
|
333
|
+
const timePart = startDate.slice(spaceIdx + 1);
|
|
334
|
+
const tp = timePart.split(':');
|
|
335
|
+
if (tp.length === 2) {
|
|
336
|
+
hour = parseInt(tp[0], 10);
|
|
337
|
+
minute = parseInt(tp[1], 10);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const parts = datePart.split('-').map((p) => parseInt(p, 10));
|
|
286
342
|
const year = parts[0];
|
|
287
343
|
const month = parts.length >= 2 ? parts[1] : 1;
|
|
288
344
|
const day = parts.length >= 3 ? parts[2] : 1;
|
|
289
345
|
|
|
290
|
-
const date = new Date(year, month - 1, day);
|
|
346
|
+
const date = new Date(year, month - 1, day, hour, minute);
|
|
291
347
|
|
|
292
348
|
switch (unit) {
|
|
293
349
|
case 'd':
|
|
294
|
-
// Round days to nearest integer
|
|
295
350
|
date.setDate(date.getDate() + Math.round(amount));
|
|
296
351
|
break;
|
|
297
352
|
case 'w':
|
|
298
|
-
// Convert weeks to days, round to nearest integer
|
|
299
353
|
date.setDate(date.getDate() + Math.round(amount * 7));
|
|
300
354
|
break;
|
|
301
355
|
case 'm': {
|
|
302
|
-
// Add whole months, then remaining days
|
|
303
356
|
const wholeMonths = Math.floor(amount);
|
|
304
357
|
const fractionalDays = Math.round((amount - wholeMonths) * 30);
|
|
305
358
|
date.setMonth(date.getMonth() + wholeMonths);
|
|
@@ -309,7 +362,6 @@ export function addDurationToDate(
|
|
|
309
362
|
break;
|
|
310
363
|
}
|
|
311
364
|
case 'y': {
|
|
312
|
-
// Add whole years, then remaining months
|
|
313
365
|
const wholeYears = Math.floor(amount);
|
|
314
366
|
const fractionalMonths = Math.round((amount - wholeYears) * 12);
|
|
315
367
|
date.setFullYear(date.getFullYear() + wholeYears);
|
|
@@ -318,17 +370,28 @@ export function addDurationToDate(
|
|
|
318
370
|
}
|
|
319
371
|
break;
|
|
320
372
|
}
|
|
373
|
+
case 'h':
|
|
374
|
+
date.setTime(date.getTime() + amount * 3600000);
|
|
375
|
+
break;
|
|
376
|
+
case 'min':
|
|
377
|
+
date.setTime(date.getTime() + amount * 60000);
|
|
378
|
+
break;
|
|
321
379
|
}
|
|
322
380
|
|
|
323
381
|
// Preserve original precision
|
|
324
382
|
const endYear = date.getFullYear();
|
|
325
383
|
const endMonth = String(date.getMonth() + 1).padStart(2, '0');
|
|
326
384
|
const endDay = String(date.getDate()).padStart(2, '0');
|
|
385
|
+
const endHour = String(date.getHours()).padStart(2, '0');
|
|
386
|
+
const endMinute = String(date.getMinutes()).padStart(2, '0');
|
|
387
|
+
const hasTime = unit === 'h' || unit === 'min' || spaceIdx !== -1;
|
|
327
388
|
|
|
328
389
|
if (parts.length === 1) {
|
|
329
390
|
return String(endYear);
|
|
330
391
|
} else if (parts.length === 2) {
|
|
331
392
|
return `${endYear}-${endMonth}`;
|
|
393
|
+
} else if (hasTime && (date.getHours() !== 0 || date.getMinutes() !== 0)) {
|
|
394
|
+
return `${endYear}-${endMonth}-${endDay} ${endHour}:${endMinute}`;
|
|
332
395
|
} else {
|
|
333
396
|
return `${endYear}-${endMonth}-${endDay}`;
|
|
334
397
|
}
|
|
@@ -401,6 +464,8 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
401
464
|
let currentTimelineGroup: string | null = null;
|
|
402
465
|
let currentTimelineTagGroup: TagGroup | null = null;
|
|
403
466
|
const timelineAliasMap = new Map<string, string>();
|
|
467
|
+
const VALID_D3_TYPES = new Set(['slope', 'wordcloud', 'arc', 'timeline', 'venn', 'quadrant', 'sequence']);
|
|
468
|
+
let firstLineParsed = false;
|
|
404
469
|
|
|
405
470
|
for (let i = 0; i < lines.length; i++) {
|
|
406
471
|
const rawLine = lines[i];
|
|
@@ -411,6 +476,24 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
411
476
|
// Skip empty lines
|
|
412
477
|
if (!line) continue;
|
|
413
478
|
|
|
479
|
+
// Skip comments
|
|
480
|
+
if (line.startsWith('//')) continue;
|
|
481
|
+
|
|
482
|
+
// First non-empty, non-comment line: chart type + optional title
|
|
483
|
+
if (!firstLineParsed) {
|
|
484
|
+
firstLineParsed = true;
|
|
485
|
+
const firstLineResult = parseFirstLine(line);
|
|
486
|
+
if (firstLineResult && VALID_D3_TYPES.has(firstLineResult.chartType)) {
|
|
487
|
+
result.type = firstLineResult.chartType as ParsedVisualization['type'];
|
|
488
|
+
if (firstLineResult.title) {
|
|
489
|
+
result.title = firstLineResult.title;
|
|
490
|
+
result.titleLineNumber = lineNumber;
|
|
491
|
+
}
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// Not a bare chart type — fall through to normal parsing
|
|
495
|
+
}
|
|
496
|
+
|
|
414
497
|
// Timeline tag group heading: `tag: Name [alias X]`
|
|
415
498
|
if (result.type === 'timeline' && indent === 0) {
|
|
416
499
|
const tagBlockMatch = matchTagBlockHeading(line);
|
|
@@ -487,11 +570,6 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
487
570
|
currentTimelineGroup = null;
|
|
488
571
|
}
|
|
489
572
|
|
|
490
|
-
// Skip comments
|
|
491
|
-
if (line.startsWith('//')) {
|
|
492
|
-
continue;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
573
|
// Arc link line: source -> target(color): weight
|
|
496
574
|
if (result.type === 'arc') {
|
|
497
575
|
const linkMatch = line.match(
|
|
@@ -527,10 +605,10 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
527
605
|
}
|
|
528
606
|
}
|
|
529
607
|
|
|
530
|
-
// Timeline era lines: era YYYY->YYYY
|
|
608
|
+
// Timeline era lines: era YYYY->YYYY Label (color)
|
|
531
609
|
if (result.type === 'timeline') {
|
|
532
610
|
const eraMatch = line.match(
|
|
533
|
-
/^era\s+(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)\s
|
|
611
|
+
/^era\s+(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*:?\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
|
|
534
612
|
);
|
|
535
613
|
if (eraMatch) {
|
|
536
614
|
const colorAnnotation = eraMatch[4]?.trim() || null;
|
|
@@ -541,13 +619,14 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
541
619
|
color: colorAnnotation
|
|
542
620
|
? resolveColor(colorAnnotation, palette)
|
|
543
621
|
: null,
|
|
622
|
+
lineNumber,
|
|
544
623
|
});
|
|
545
624
|
continue;
|
|
546
625
|
}
|
|
547
626
|
|
|
548
|
-
// Timeline marker lines: marker
|
|
627
|
+
// Timeline marker lines: marker YYYY Label (color)
|
|
549
628
|
const markerMatch = line.match(
|
|
550
|
-
/^marker
|
|
629
|
+
/^marker:?\s+(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
|
|
551
630
|
);
|
|
552
631
|
if (markerMatch) {
|
|
553
632
|
const colorAnnotation = markerMatch[3]?.trim() || null;
|
|
@@ -565,17 +644,17 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
565
644
|
|
|
566
645
|
// Timeline event lines: duration, range, or point
|
|
567
646
|
if (result.type === 'timeline') {
|
|
568
|
-
// Duration event: 2026-07-15->30d: description (d=days, w=weeks, m=months, y=years)
|
|
647
|
+
// Duration event: 2026-07-15->30d: description (d=days, w=weeks, m=months, y=years, h=hours, min=minutes)
|
|
569
648
|
// Supports decimals up to 2 places (e.g., 1.25y = 1 year 3 months)
|
|
570
649
|
// Supports uncertain end with ? suffix (e.g., ->3m?: fades out the last 20%)
|
|
571
650
|
const durationMatch = line.match(
|
|
572
|
-
/^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d+(?:\.\d{1,2})?)([
|
|
651
|
+
/^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*->\s*(\d+(?:\.\d{1,2})?)(min|[dwmyh])(\?)?(?:\s*:\s*|\s+)(.+)$/
|
|
573
652
|
);
|
|
574
653
|
if (durationMatch) {
|
|
575
654
|
const startDate = durationMatch[1];
|
|
576
655
|
const uncertain = durationMatch[4] === '?';
|
|
577
656
|
const amount = parseFloat(durationMatch[2]);
|
|
578
|
-
const unit = durationMatch[3] as 'd' | 'w' | 'm' | 'y';
|
|
657
|
+
const unit = durationMatch[3] as 'd' | 'w' | 'm' | 'y' | 'h' | 'min';
|
|
579
658
|
const endDate = addDurationToDate(startDate, amount, unit);
|
|
580
659
|
const segments = durationMatch[5].split('|');
|
|
581
660
|
const metadata = segments.length > 1
|
|
@@ -593,9 +672,9 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
593
672
|
continue;
|
|
594
673
|
}
|
|
595
674
|
|
|
596
|
-
// Range event: 1655->1667
|
|
675
|
+
// Range event: 1655->1667 description (supports uncertain end: 1655->1667?)
|
|
597
676
|
const rangeMatch = line.match(
|
|
598
|
-
/^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)(\?)
|
|
677
|
+
/^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)(\?)?(?:\s*:\s*|\s+)(.+)$/
|
|
599
678
|
);
|
|
600
679
|
if (rangeMatch) {
|
|
601
680
|
const segments = rangeMatch[4].split('|');
|
|
@@ -614,9 +693,9 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
614
693
|
continue;
|
|
615
694
|
}
|
|
616
695
|
|
|
617
|
-
// Point event: 1718: description
|
|
696
|
+
// Point event: 1718 description (or legacy 1718: description)
|
|
618
697
|
const pointMatch = line.match(
|
|
619
|
-
/^(\d{4}(?:-\d{2})?(?:-\d{2})?)
|
|
698
|
+
/^(\d{4}(?:-\d{2})?(?:-\d{2})?)(?:\s*:\s*|\s+)(.+)$/
|
|
620
699
|
);
|
|
621
700
|
if (pointMatch) {
|
|
622
701
|
const segments = pointMatch[2].split('|');
|
|
@@ -664,7 +743,9 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
664
743
|
let color: string | null = null;
|
|
665
744
|
if (colorName) {
|
|
666
745
|
const resolved = resolveColor(colorName, palette);
|
|
667
|
-
if (resolved
|
|
746
|
+
if (resolved === null) {
|
|
747
|
+
warn(lineNumber, `Hex colors are not supported — use named colors (blue, red, green, etc.)`);
|
|
748
|
+
} else if (resolved.startsWith('#')) {
|
|
668
749
|
color = resolved;
|
|
669
750
|
} else {
|
|
670
751
|
warn(lineNumber, `Unknown color "${colorName}" on set "${name}". Using auto-assigned color.`);
|
|
@@ -765,7 +846,102 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
765
846
|
}
|
|
766
847
|
}
|
|
767
848
|
|
|
768
|
-
//
|
|
849
|
+
// ── Space-separated options (no colon) ──────────────────
|
|
850
|
+
const spaceIdx = line.indexOf(' ');
|
|
851
|
+
if (spaceIdx >= 0) {
|
|
852
|
+
const firstToken = line.substring(0, spaceIdx).toLowerCase();
|
|
853
|
+
const restValue = line.substring(spaceIdx + 1).trim();
|
|
854
|
+
|
|
855
|
+
if (firstToken === 'chart' && VALID_D3_TYPES.has(restValue.toLowerCase())) {
|
|
856
|
+
result.type = restValue.toLowerCase() as ParsedVisualization['type'];
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (firstToken === 'title') {
|
|
861
|
+
result.title = restValue;
|
|
862
|
+
result.titleLineNumber = lineNumber;
|
|
863
|
+
if (result.type === 'quadrant') {
|
|
864
|
+
result.quadrantTitleLineNumber = lineNumber;
|
|
865
|
+
}
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (firstToken === 'orientation' || firstToken === 'direction') {
|
|
870
|
+
if (result.type === 'arc' || result.type === 'timeline') {
|
|
871
|
+
const vLower = restValue.toLowerCase();
|
|
872
|
+
if (vLower === 'horizontal' || vLower === 'vertical') {
|
|
873
|
+
result.orientation = vLower;
|
|
874
|
+
} else {
|
|
875
|
+
const dir = normalizeDirection(restValue);
|
|
876
|
+
if (dir === 'LR') result.orientation = 'horizontal';
|
|
877
|
+
else if (dir === 'TB') result.orientation = 'vertical';
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (firstToken === 'order') {
|
|
884
|
+
const v = restValue.toLowerCase();
|
|
885
|
+
if (v === 'name' || v === 'group' || v === 'degree') {
|
|
886
|
+
result.arcOrder = v;
|
|
887
|
+
}
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (firstToken === 'sort') {
|
|
892
|
+
const vLower = restValue.toLowerCase();
|
|
893
|
+
if (vLower === 'time' || vLower === 'group') {
|
|
894
|
+
result.timelineSort = vLower;
|
|
895
|
+
} else if (vLower === 'tag' || vLower.startsWith('tag:')) {
|
|
896
|
+
result.timelineSort = 'tag';
|
|
897
|
+
if (vLower.startsWith('tag:')) {
|
|
898
|
+
const groupRef = restValue.substring(4).trim();
|
|
899
|
+
if (groupRef) {
|
|
900
|
+
result.timelineDefaultSwimlaneTG = groupRef;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (firstToken === 'swimlanes') {
|
|
908
|
+
const v = restValue.toLowerCase();
|
|
909
|
+
if (v === 'on') result.timelineSwimlanes = true;
|
|
910
|
+
else if (v === 'off') result.timelineSwimlanes = false;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (firstToken === 'rotate') {
|
|
915
|
+
const v = restValue.toLowerCase();
|
|
916
|
+
if (v === 'none' || v === 'mixed' || v === 'angled') {
|
|
917
|
+
result.cloudOptions.rotate = v;
|
|
918
|
+
}
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (firstToken === 'max') {
|
|
923
|
+
const v = parseInt(restValue, 10);
|
|
924
|
+
if (!isNaN(v) && v > 0) {
|
|
925
|
+
result.cloudOptions.max = v;
|
|
926
|
+
}
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (firstToken === 'size') {
|
|
931
|
+
const parts = restValue.split(',').map((s) => parseInt(s.trim(), 10));
|
|
932
|
+
if (
|
|
933
|
+
parts.length === 2 &&
|
|
934
|
+
parts.every((n) => !isNaN(n) && n > 0) &&
|
|
935
|
+
parts[0] < parts[1]
|
|
936
|
+
) {
|
|
937
|
+
result.cloudOptions.minSize = parts[0];
|
|
938
|
+
result.cloudOptions.maxSize = parts[1];
|
|
939
|
+
}
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// ── Colon-separated metadata / options (legacy + data lines) ──
|
|
769
945
|
const colonIndex = line.indexOf(':');
|
|
770
946
|
|
|
771
947
|
if (colonIndex !== -1) {
|
|
@@ -780,18 +956,10 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
780
956
|
.substring(colonIndex + 1)
|
|
781
957
|
.trim()
|
|
782
958
|
.toLowerCase();
|
|
783
|
-
if (
|
|
784
|
-
value
|
|
785
|
-
value === 'wordcloud' ||
|
|
786
|
-
value === 'arc' ||
|
|
787
|
-
value === 'timeline' ||
|
|
788
|
-
value === 'venn' ||
|
|
789
|
-
value === 'quadrant' ||
|
|
790
|
-
value === 'sequence'
|
|
791
|
-
) {
|
|
792
|
-
result.type = value;
|
|
959
|
+
if (VALID_D3_TYPES.has(value)) {
|
|
960
|
+
result.type = value as ParsedVisualization['type'];
|
|
793
961
|
} else {
|
|
794
|
-
const validD3Types = [
|
|
962
|
+
const validD3Types = [...VALID_D3_TYPES];
|
|
795
963
|
let msg = `Unsupported chart type: ${value}. Supported types: ${validD3Types.join(', ')}`;
|
|
796
964
|
const hint = suggest(value, validD3Types);
|
|
797
965
|
if (hint) msg += `. ${hint}`;
|
|
@@ -809,15 +977,18 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
809
977
|
continue;
|
|
810
978
|
}
|
|
811
979
|
|
|
812
|
-
if (key === 'orientation') {
|
|
980
|
+
if (key === 'orientation' || key === 'direction') {
|
|
813
981
|
// Only arc and timeline support orientation
|
|
814
982
|
if (result.type === 'arc' || result.type === 'timeline') {
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
983
|
+
const raw = line.substring(colonIndex + 1).trim();
|
|
984
|
+
// Accept horizontal/vertical directly, or LR/TB via normalizeDirection
|
|
985
|
+
const vLower = raw.toLowerCase();
|
|
986
|
+
if (vLower === 'horizontal' || vLower === 'vertical') {
|
|
987
|
+
result.orientation = vLower;
|
|
988
|
+
} else {
|
|
989
|
+
const dir = normalizeDirection(raw);
|
|
990
|
+
if (dir === 'LR') result.orientation = 'horizontal';
|
|
991
|
+
else if (dir === 'TB') result.orientation = 'vertical';
|
|
821
992
|
}
|
|
822
993
|
}
|
|
823
994
|
continue;
|
|
@@ -972,7 +1143,12 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
|
|
|
972
1143
|
|
|
973
1144
|
// Validation
|
|
974
1145
|
if (!result.type) {
|
|
975
|
-
|
|
1146
|
+
const validD3Types = [...VALID_D3_TYPES];
|
|
1147
|
+
const firstNonEmpty = lines.find(l => l.trim() && !l.trim().startsWith('//'))?.trim() ?? '';
|
|
1148
|
+
const hint = suggest(firstNonEmpty.split(/\s/)[0].toLowerCase(), validD3Types);
|
|
1149
|
+
let msg = `Unsupported chart type: "${firstNonEmpty.split(/\s/)[0]}". Supported types: ${validD3Types.join(', ')}`;
|
|
1150
|
+
if (hint) msg += `. ${hint}`;
|
|
1151
|
+
return fail(1, msg);
|
|
976
1152
|
}
|
|
977
1153
|
|
|
978
1154
|
// Sequence diagrams are parsed by their own dedicated parser
|
|
@@ -2155,6 +2331,7 @@ function renderEras(
|
|
|
2155
2331
|
const eraG = g
|
|
2156
2332
|
.append('g')
|
|
2157
2333
|
.attr('class', 'tl-era')
|
|
2334
|
+
.attr('data-line-number', String(era.lineNumber))
|
|
2158
2335
|
.attr('data-era-start', String(startVal))
|
|
2159
2336
|
.attr('data-era-end', String(endVal))
|
|
2160
2337
|
.style('cursor', 'pointer')
|
|
@@ -2358,19 +2535,30 @@ const MONTH_ABBR = [
|
|
|
2358
2535
|
];
|
|
2359
2536
|
|
|
2360
2537
|
/**
|
|
2361
|
-
* Converts a DSL date string (YYYY, YYYY-MM, YYYY-MM-DD) to a human-readable label.
|
|
2362
|
-
* '1718'
|
|
2363
|
-
* '1718-05'
|
|
2364
|
-
* '1718-05-22'
|
|
2538
|
+
* Converts a DSL date string (YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM) to a human-readable label.
|
|
2539
|
+
* '1718' → '1718'
|
|
2540
|
+
* '1718-05' → 'May 1718'
|
|
2541
|
+
* '1718-05-22' → 'May 22, 1718'
|
|
2542
|
+
* '2024-06-15 14:30' → 'Jun 15, 2024 14:30'
|
|
2365
2543
|
*/
|
|
2366
2544
|
export function formatDateLabel(dateStr: string): string {
|
|
2367
|
-
|
|
2545
|
+
// Split off optional time component
|
|
2546
|
+
const spaceIdx = dateStr.indexOf(' ');
|
|
2547
|
+
let datePart = dateStr;
|
|
2548
|
+
let timeSuffix = '';
|
|
2549
|
+
|
|
2550
|
+
if (spaceIdx !== -1) {
|
|
2551
|
+
datePart = dateStr.slice(0, spaceIdx);
|
|
2552
|
+
timeSuffix = ' ' + dateStr.slice(spaceIdx + 1);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
const parts = datePart.split('-');
|
|
2368
2556
|
const year = parts[0];
|
|
2369
|
-
if (parts.length === 1) return year;
|
|
2557
|
+
if (parts.length === 1) return year + timeSuffix;
|
|
2370
2558
|
const month = MONTH_ABBR[parseInt(parts[1], 10) - 1];
|
|
2371
|
-
if (parts.length === 2) return `${month} ${year}`;
|
|
2559
|
+
if (parts.length === 2) return `${month} ${year}${timeSuffix}`;
|
|
2372
2560
|
const day = parseInt(parts[2], 10);
|
|
2373
|
-
return `${month} ${day}, ${year}`;
|
|
2561
|
+
return `${month} ${day}, ${year}${timeSuffix}`;
|
|
2374
2562
|
}
|
|
2375
2563
|
|
|
2376
2564
|
/**
|
|
@@ -2432,6 +2620,55 @@ export function computeTimeTicks(
|
|
|
2432
2620
|
}
|
|
2433
2621
|
}
|
|
2434
2622
|
}
|
|
2623
|
+
} else if (span <= 0.000685) {
|
|
2624
|
+
// Minute ticks for spans ≤ ~6 hours
|
|
2625
|
+
// Adaptive step: >3h → 30min, >1h → 15min, >30min → 10min, else 5min
|
|
2626
|
+
let stepMin = 5;
|
|
2627
|
+
const spanHours = span * 8760;
|
|
2628
|
+
if (spanHours > 3) stepMin = 30;
|
|
2629
|
+
else if (spanHours > 1) stepMin = 15;
|
|
2630
|
+
else if (spanHours > 0.5) stepMin = 10;
|
|
2631
|
+
|
|
2632
|
+
// Iterate from the start hour boundary
|
|
2633
|
+
const startDate = fractionalYearToDate(domainMin);
|
|
2634
|
+
// Round down to nearest step boundary
|
|
2635
|
+
startDate.setMinutes(Math.floor(startDate.getMinutes() / stepMin) * stepMin, 0, 0);
|
|
2636
|
+
|
|
2637
|
+
while (true) {
|
|
2638
|
+
const val = dateToFractionalYear(startDate);
|
|
2639
|
+
if (val > domainMax) break;
|
|
2640
|
+
if (val >= domainMin) {
|
|
2641
|
+
const hh = String(startDate.getHours()).padStart(2, '0');
|
|
2642
|
+
const mm = String(startDate.getMinutes()).padStart(2, '0');
|
|
2643
|
+
ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
|
|
2644
|
+
}
|
|
2645
|
+
startDate.setMinutes(startDate.getMinutes() + stepMin);
|
|
2646
|
+
}
|
|
2647
|
+
} else if (span <= 0.00822) {
|
|
2648
|
+
// Hour ticks for spans ≤ ~3 days
|
|
2649
|
+
// Adaptive step: >2d → 6h, >1d → 3h, >12h → 2h, else 1h
|
|
2650
|
+
let stepHour = 1;
|
|
2651
|
+
const spanHours = span * 8760;
|
|
2652
|
+
if (spanHours > 48) stepHour = 6;
|
|
2653
|
+
else if (spanHours > 24) stepHour = 3;
|
|
2654
|
+
else if (spanHours > 12) stepHour = 2;
|
|
2655
|
+
|
|
2656
|
+
const startDate = fractionalYearToDate(domainMin);
|
|
2657
|
+
// Round down to nearest step boundary
|
|
2658
|
+
startDate.setHours(Math.floor(startDate.getHours() / stepHour) * stepHour, 0, 0, 0);
|
|
2659
|
+
|
|
2660
|
+
while (true) {
|
|
2661
|
+
const val = dateToFractionalYear(startDate);
|
|
2662
|
+
if (val > domainMax) break;
|
|
2663
|
+
if (val >= domainMin) {
|
|
2664
|
+
const mon = MONTH_ABBR[startDate.getMonth()];
|
|
2665
|
+
const d = startDate.getDate();
|
|
2666
|
+
const hh = String(startDate.getHours()).padStart(2, '0');
|
|
2667
|
+
const mm = String(startDate.getMinutes()).padStart(2, '0');
|
|
2668
|
+
ticks.push({ pos: scale(val), label: `${mon} ${d} ${hh}:${mm}` });
|
|
2669
|
+
}
|
|
2670
|
+
startDate.setHours(startDate.getHours() + stepHour);
|
|
2671
|
+
}
|
|
2435
2672
|
} else {
|
|
2436
2673
|
// Week ticks for spans ≤ ~3 months (1st, 8th, 15th, 22nd of each month)
|
|
2437
2674
|
for (let y = minYear; y <= maxYear + 1; y++) {
|
|
@@ -3116,8 +3353,9 @@ export function renderTimeline(
|
|
|
3116
3353
|
const svg = d3Selection
|
|
3117
3354
|
.select(container)
|
|
3118
3355
|
.append('svg')
|
|
3119
|
-
.attr('
|
|
3120
|
-
.attr('
|
|
3356
|
+
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
3357
|
+
.attr('width', exportDims ? width : '100%')
|
|
3358
|
+
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
3121
3359
|
.style('background', bgColor);
|
|
3122
3360
|
|
|
3123
3361
|
const g = svg
|
|
@@ -3365,8 +3603,9 @@ export function renderTimeline(
|
|
|
3365
3603
|
const svg = d3Selection
|
|
3366
3604
|
.select(container)
|
|
3367
3605
|
.append('svg')
|
|
3368
|
-
.attr('
|
|
3369
|
-
.attr('
|
|
3606
|
+
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
3607
|
+
.attr('width', exportDims ? width : '100%')
|
|
3608
|
+
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
3370
3609
|
.style('background', bgColor);
|
|
3371
3610
|
|
|
3372
3611
|
const g = svg
|
|
@@ -4228,11 +4467,9 @@ export function renderTimeline(
|
|
|
4228
4467
|
const LG_HEIGHT = TL_LEGEND_HEIGHT;
|
|
4229
4468
|
const LG_PILL_PAD = TL_LEGEND_PILL_PAD;
|
|
4230
4469
|
const LG_PILL_FONT_SIZE = TL_LEGEND_PILL_FONT_SIZE;
|
|
4231
|
-
const LG_PILL_FONT_W = TL_LEGEND_PILL_FONT_W;
|
|
4232
4470
|
const LG_CAPSULE_PAD = TL_LEGEND_CAPSULE_PAD;
|
|
4233
4471
|
const LG_DOT_R = TL_LEGEND_DOT_R;
|
|
4234
4472
|
const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;
|
|
4235
|
-
const LG_ENTRY_FONT_W = TL_LEGEND_ENTRY_FONT_W;
|
|
4236
4473
|
const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;
|
|
4237
4474
|
const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;
|
|
4238
4475
|
const LG_GROUP_GAP = TL_LEGEND_GROUP_GAP;
|
|
@@ -4255,13 +4492,13 @@ export function renderTimeline(
|
|
|
4255
4492
|
expandedWidth: number;
|
|
4256
4493
|
};
|
|
4257
4494
|
const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
|
|
4258
|
-
const pillW = g.name
|
|
4495
|
+
const pillW = measureLegendText(g.name, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
|
|
4259
4496
|
// Expanded: pill + icon (unless viewMode) + entries
|
|
4260
4497
|
const iconSpace = viewMode ? 8 : LG_ICON_W + 4;
|
|
4261
4498
|
let entryX = LG_CAPSULE_PAD + pillW + iconSpace;
|
|
4262
4499
|
for (const entry of g.entries) {
|
|
4263
4500
|
const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
|
|
4264
|
-
entryX = textX + entry.value
|
|
4501
|
+
entryX = textX + measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) + LG_ENTRY_TRAIL;
|
|
4265
4502
|
}
|
|
4266
4503
|
return {
|
|
4267
4504
|
group: g,
|
|
@@ -4360,7 +4597,7 @@ export function renderTimeline(
|
|
|
4360
4597
|
currentSwimlaneGroup.toLowerCase() === groupKey;
|
|
4361
4598
|
|
|
4362
4599
|
const pillLabel = lg.group.name;
|
|
4363
|
-
const pillWidth = pillLabel
|
|
4600
|
+
const pillWidth = measureLegendText(pillLabel, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
|
|
4364
4601
|
|
|
4365
4602
|
const gEl = legendContainer
|
|
4366
4603
|
.append('g')
|
|
@@ -4497,7 +4734,7 @@ export function renderTimeline(
|
|
|
4497
4734
|
.attr('fill', palette.textMuted)
|
|
4498
4735
|
.text(entry.value);
|
|
4499
4736
|
|
|
4500
|
-
entryX = textX + entry.value
|
|
4737
|
+
entryX = textX + measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) + LG_ENTRY_TRAIL;
|
|
4501
4738
|
}
|
|
4502
4739
|
}
|
|
4503
4740
|
|
|
@@ -5816,6 +6053,8 @@ function finalizeSvgExport(
|
|
|
5816
6053
|
}
|
|
5817
6054
|
svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
5818
6055
|
svgEl.style.fontFamily = FONT_FAMILY;
|
|
6056
|
+
// Strip elements marked for export exclusion (e.g., inactive legend pills)
|
|
6057
|
+
svgEl.querySelectorAll('[data-export-ignore]').forEach((el) => el.remove());
|
|
5819
6058
|
const svgHtml = svgEl.outerHTML;
|
|
5820
6059
|
document.body.removeChild(container);
|
|
5821
6060
|
if (options?.branding !== false) {
|