@diagrammo/dgmo 0.4.3 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/chart.ts CHANGED
@@ -108,8 +108,11 @@ export function parseChart(
108
108
  // Skip empty lines
109
109
  if (!trimmed) continue;
110
110
 
111
- // Recognize ## section headers (skip, but don't treat as comments)
112
- if (/^#{2,}\s+/.test(trimmed)) continue;
111
+ // Reject legacy ## section headers
112
+ if (/^#{2,}\s+/.test(trimmed)) {
113
+ result.diagnostics.push(makeDgmoError(lineNumber, `'${trimmed}' — ## syntax is no longer supported. Use [Group] containers instead`));
114
+ continue;
115
+ }
113
116
 
114
117
  // Skip comments
115
118
  if (trimmed.startsWith('//')) continue;
package/src/d3.ts CHANGED
@@ -72,6 +72,7 @@ export interface TimelineEvent {
72
72
  endDate: string | null;
73
73
  label: string;
74
74
  group: string | null;
75
+ metadata: Record<string, string>;
75
76
  lineNumber: number;
76
77
  uncertain?: boolean;
77
78
  }
@@ -153,6 +154,7 @@ export interface ParsedD3 {
153
154
  timelineGroups: TimelineGroup[];
154
155
  timelineEras: TimelineEra[];
155
156
  timelineMarkers: TimelineMarker[];
157
+ timelineTagGroups: TagGroup[];
156
158
  timelineSort: TimelineSort;
157
159
  timelineScale: boolean;
158
160
  timelineSwimlanes: boolean;
@@ -178,9 +180,12 @@ export interface ParsedD3 {
178
180
  import { resolveColor } from './colors';
179
181
  import type { PaletteColors } from './palettes';
180
182
  import { getSeriesColors } from './palettes';
183
+ import { mix } from './palettes/color-utils';
181
184
  import type { DgmoError } from './diagnostics';
182
185
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
183
- import { collectIndentedValues } from './utils/parsing';
186
+ import { collectIndentedValues, extractColor, parsePipeMetadata } from './utils/parsing';
187
+ import { matchTagBlockHeading, validateTagValues, resolveTagColor } from './utils/tag-groups';
188
+ import type { TagGroup } from './utils/tag-groups';
184
189
 
185
190
  // ============================================================
186
191
  // Shared Rendering Helpers
@@ -342,6 +347,7 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
342
347
  timelineGroups: [],
343
348
  timelineEras: [],
344
349
  timelineMarkers: [],
350
+ timelineTagGroups: [],
345
351
  timelineSort: 'time',
346
352
  timelineScale: true,
347
353
  timelineSwimlanes: false,
@@ -379,28 +385,74 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
379
385
  const freeformLines: string[] = [];
380
386
  let currentArcGroup: string | null = null;
381
387
  let currentTimelineGroup: string | null = null;
388
+ let currentTimelineTagGroup: TagGroup | null = null;
389
+ const timelineAliasMap = new Map<string, string>();
382
390
 
383
391
  for (let i = 0; i < lines.length; i++) {
384
- const line = lines[i].trim();
392
+ const rawLine = lines[i];
393
+ const line = rawLine.trim();
394
+ const indent = rawLine.length - rawLine.trimStart().length;
385
395
  const lineNumber = i + 1;
386
396
 
387
397
  // Skip empty lines
388
398
  if (!line) continue;
389
399
 
390
- // ## Section headers for arc diagram node grouping (before # comment check)
391
- const sectionMatch = line.match(/^#{2,}\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/);
392
- if (sectionMatch) {
400
+ // Timeline tag group heading: `tag: Name [alias X]`
401
+ if (result.type === 'timeline' && indent === 0) {
402
+ const tagBlockMatch = matchTagBlockHeading(line);
403
+ if (tagBlockMatch) {
404
+ if (tagBlockMatch.deprecated) {
405
+ result.diagnostics.push(makeDgmoError(lineNumber,
406
+ `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`, 'warning'));
407
+ }
408
+ currentTimelineTagGroup = {
409
+ name: tagBlockMatch.name,
410
+ alias: tagBlockMatch.alias,
411
+ entries: [],
412
+ lineNumber,
413
+ };
414
+ if (tagBlockMatch.alias) {
415
+ timelineAliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
416
+ }
417
+ result.timelineTagGroups.push(currentTimelineTagGroup);
418
+ continue;
419
+ }
420
+ }
421
+
422
+ // Timeline tag group entries (indented under tag: heading)
423
+ if (currentTimelineTagGroup && indent > 0) {
424
+ const trimmedEntry = line;
425
+ const isDefault = /\bdefault\s*$/.test(trimmedEntry);
426
+ const entryText = isDefault
427
+ ? trimmedEntry.replace(/\s+default\s*$/, '').trim()
428
+ : trimmedEntry;
429
+ const { label, color } = extractColor(entryText, palette);
430
+ if (color) {
431
+ if (isDefault) currentTimelineTagGroup.defaultValue = label;
432
+ currentTimelineTagGroup.entries.push({ value: label, color, lineNumber });
433
+ continue;
434
+ }
435
+ }
436
+
437
+ // End tag group on non-indented line
438
+ if (currentTimelineTagGroup && indent === 0) {
439
+ currentTimelineTagGroup = null;
440
+ }
441
+
442
+ // [Group] container headers for arc diagram node grouping and timeline eras
443
+ const groupMatch = line.match(/^\[(.+?)\](?:\s*\(([^)]+)\))?\s*$/);
444
+ if (groupMatch) {
393
445
  if (result.type === 'arc') {
394
- const name = sectionMatch[1].trim();
395
- const color = sectionMatch[2]
396
- ? resolveColor(sectionMatch[2].trim(), palette)
446
+ const name = groupMatch[1].trim();
447
+ const color = groupMatch[2]
448
+ ? resolveColor(groupMatch[2].trim(), palette)
397
449
  : null;
398
450
  result.arcNodeGroups.push({ name, nodes: [], color, lineNumber });
399
451
  currentArcGroup = name;
400
452
  } else if (result.type === 'timeline') {
401
- const name = sectionMatch[1].trim();
402
- const color = sectionMatch[2]
403
- ? resolveColor(sectionMatch[2].trim(), palette)
453
+ const name = groupMatch[1].trim();
454
+ const color = groupMatch[2]
455
+ ? resolveColor(groupMatch[2].trim(), palette)
404
456
  : null;
405
457
  result.timelineGroups.push({ name, color, lineNumber });
406
458
  currentTimelineGroup = name;
@@ -408,6 +460,19 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
408
460
  continue;
409
461
  }
410
462
 
463
+ // Reject legacy ## group syntax
464
+ if (/^#{2,}\s+/.test(line) && (result.type === 'arc' || result.type === 'timeline')) {
465
+ const name = line.replace(/^#{2,}\s+/, '').replace(/\s*\([^)]*\)\s*$/, '').trim();
466
+ result.diagnostics.push(makeDgmoError(lineNumber, `'## ${name}' is no longer supported. Use '[${name}]' instead`, 'warning'));
467
+ continue;
468
+ }
469
+
470
+ // Clear group context on un-indented lines (except [Group] already handled above)
471
+ if (indent === 0) {
472
+ currentArcGroup = null;
473
+ currentTimelineGroup = null;
474
+ }
475
+
411
476
  // Skip comments
412
477
  if (line.startsWith('//')) {
413
478
  continue;
@@ -498,11 +563,16 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
498
563
  const amount = parseFloat(durationMatch[2]);
499
564
  const unit = durationMatch[3] as 'd' | 'w' | 'm' | 'y';
500
565
  const endDate = addDurationToDate(startDate, amount, unit);
566
+ const segments = durationMatch[5].split('|');
567
+ const metadata = segments.length > 1
568
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
569
+ : {};
501
570
  result.timelineEvents.push({
502
571
  date: startDate,
503
572
  endDate,
504
- label: durationMatch[5].trim(),
573
+ label: segments[0].trim(),
505
574
  group: currentTimelineGroup,
575
+ metadata,
506
576
  lineNumber,
507
577
  uncertain,
508
578
  });
@@ -514,11 +584,16 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
514
584
  /^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)(\?)?\s*:\s*(.+)$/
515
585
  );
516
586
  if (rangeMatch) {
587
+ const segments = rangeMatch[4].split('|');
588
+ const metadata = segments.length > 1
589
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
590
+ : {};
517
591
  result.timelineEvents.push({
518
592
  date: rangeMatch[1],
519
593
  endDate: rangeMatch[2],
520
- label: rangeMatch[4].trim(),
594
+ label: segments[0].trim(),
521
595
  group: currentTimelineGroup,
596
+ metadata,
522
597
  lineNumber,
523
598
  uncertain: rangeMatch[3] === '?',
524
599
  });
@@ -530,11 +605,16 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
530
605
  /^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+)$/
531
606
  );
532
607
  if (pointMatch) {
608
+ const segments = pointMatch[2].split('|');
609
+ const metadata = segments.length > 1
610
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
611
+ : {};
533
612
  result.timelineEvents.push({
534
613
  date: pointMatch[1],
535
614
  endDate: null,
536
- label: pointMatch[2].trim(),
615
+ label: segments[0].trim(),
537
616
  group: currentTimelineGroup,
617
+ metadata,
538
618
  lineNumber,
539
619
  });
540
620
  continue;
@@ -914,7 +994,7 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
914
994
  // Validate arc ordering vs groups
915
995
  if (result.arcNodeGroups.length > 0) {
916
996
  if (result.arcOrder === 'name' || result.arcOrder === 'degree') {
917
- warn(1, `Cannot use "order: ${result.arcOrder}" with ## section headers. Use "order: group" or remove section headers.`);
997
+ warn(1, `Cannot use "order: ${result.arcOrder}" with [Group] headers. Use "order: group" or remove group headers.`);
918
998
  result.arcOrder = 'group';
919
999
  }
920
1000
  if (result.arcOrder === 'appearance') {
@@ -928,6 +1008,24 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
928
1008
  if (result.timelineEvents.length === 0) {
929
1009
  warn(1, 'No events found. Add events as "YYYY: description" or "YYYY->YYYY: description"');
930
1010
  }
1011
+ // Validate tag values and inject defaults
1012
+ if (result.timelineTagGroups.length > 0) {
1013
+ validateTagValues(
1014
+ result.timelineEvents,
1015
+ result.timelineTagGroups,
1016
+ (line, msg) => result.diagnostics.push(makeDgmoError(line, msg, 'warning')),
1017
+ suggest,
1018
+ );
1019
+ for (const group of result.timelineTagGroups) {
1020
+ if (!group.defaultValue) continue;
1021
+ const key = group.name.toLowerCase();
1022
+ for (const event of result.timelineEvents) {
1023
+ if (!event.metadata[key]) {
1024
+ event.metadata[key] = group.defaultValue;
1025
+ }
1026
+ }
1027
+ }
1028
+ }
931
1029
  return result;
932
1030
  }
933
1031
 
@@ -2682,7 +2780,8 @@ export function renderTimeline(
2682
2780
  palette: PaletteColors,
2683
2781
  isDark: boolean,
2684
2782
  onClickItem?: (lineNumber: number) => void,
2685
- exportDims?: D3ExportDimensions
2783
+ exportDims?: D3ExportDimensions,
2784
+ activeTagGroup?: string | null
2686
2785
  ): void {
2687
2786
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
2688
2787
 
@@ -2720,6 +2819,11 @@ export function renderTimeline(
2720
2819
  });
2721
2820
 
2722
2821
  function eventColor(ev: TimelineEvent): string {
2822
+ // Tag color takes priority when a tag group is active
2823
+ if (activeTagGroup) {
2824
+ const tagColor = resolveTagColor(ev.metadata, parsed.timelineTagGroups, activeTagGroup);
2825
+ if (tagColor) return tagColor;
2826
+ }
2723
2827
  if (ev.group && groupColorMap.has(ev.group)) {
2724
2828
  return groupColorMap.get(ev.group)!;
2725
2829
  }
@@ -2811,11 +2915,49 @@ export function renderTimeline(
2811
2915
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
2812
2916
  ) {
2813
2917
  g.selectAll<SVGGElement, unknown>(
2814
- '.tl-event, .tl-legend-item, .tl-lane-header, .tl-marker'
2918
+ '.tl-event, .tl-legend-item, .tl-lane-header, .tl-marker, .tl-tag-legend-entry'
2815
2919
  ).attr('opacity', 1);
2816
2920
  g.selectAll<SVGGElement, unknown>('.tl-era').attr('opacity', 1);
2817
2921
  }
2818
2922
 
2923
+ function fadeToTagValue(
2924
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2925
+ tagKey: string,
2926
+ tagValue: string
2927
+ ) {
2928
+ const attrName = `data-tag-${tagKey}`;
2929
+ g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
2930
+ const el = d3Selection.select(this);
2931
+ const val = el.attr(attrName);
2932
+ el.attr('opacity', val === tagValue ? 1 : FADE_OPACITY);
2933
+ });
2934
+ g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').attr(
2935
+ 'opacity', FADE_OPACITY
2936
+ );
2937
+ g.selectAll<SVGGElement, unknown>('.tl-marker').attr('opacity', FADE_OPACITY);
2938
+ // Fade legend entry dots/labels that don't match (keep group pill visible)
2939
+ g.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
2940
+ const el = d3Selection.select(this);
2941
+ const entryValue = el.attr('data-legend-entry');
2942
+ if (entryValue === '__group__') return; // keep group pill at full opacity
2943
+ const entryGroup = el.attr('data-tag-group');
2944
+ el.attr('opacity', entryGroup === tagKey && entryValue === tagValue ? 1 : FADE_OPACITY);
2945
+ });
2946
+ }
2947
+
2948
+ /** Attach data-tag-* attributes on an event group element */
2949
+ function setTagAttrs(
2950
+ evG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2951
+ ev: TimelineEvent
2952
+ ) {
2953
+ for (const [key, value] of Object.entries(ev.metadata)) {
2954
+ evG.attr(`data-tag-${key}`, value.toLowerCase());
2955
+ }
2956
+ }
2957
+
2958
+ // Reserve space for tag legend between title and chart content
2959
+ const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;
2960
+
2819
2961
  // ================================================================
2820
2962
  // VERTICAL orientation (time flows top→bottom)
2821
2963
  // ================================================================
@@ -2833,7 +2975,7 @@ export function renderTimeline(
2833
2975
  const scaleMargin = timelineScale ? 40 : 0;
2834
2976
  const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
2835
2977
  const margin = {
2836
- top: 104 + markerMargin,
2978
+ top: 104 + markerMargin + tagLegendReserve,
2837
2979
  right: 40 + scaleMargin,
2838
2980
  bottom: 40,
2839
2981
  left: 60 + scaleMargin,
@@ -2966,6 +3108,7 @@ export function renderTimeline(
2966
3108
  .on('click', () => {
2967
3109
  if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
2968
3110
  });
3111
+ setTagAttrs(evG, ev);
2969
3112
 
2970
3113
  if (ev.endDate) {
2971
3114
  const y2 = yScale(parseTimelineDate(ev.endDate));
@@ -3039,7 +3182,7 @@ export function renderTimeline(
3039
3182
  const scaleMargin = timelineScale ? 40 : 0;
3040
3183
  const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3041
3184
  const margin = {
3042
- top: 104 + markerMargin,
3185
+ top: 104 + markerMargin + tagLegendReserve,
3043
3186
  right: 200,
3044
3187
  bottom: 40,
3045
3188
  left: 60 + scaleMargin,
@@ -3183,6 +3326,7 @@ export function renderTimeline(
3183
3326
  .on('click', () => {
3184
3327
  if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
3185
3328
  });
3329
+ setTagAttrs(evG, ev);
3186
3330
 
3187
3331
  if (ev.endDate) {
3188
3332
  const y2 = yScale(parseTimelineDate(ev.endDate));
@@ -3313,7 +3457,7 @@ export function renderTimeline(
3313
3457
  // Group-sorted doesn't need legend space (group names shown on left)
3314
3458
  const baseTopMargin = title ? 50 : 20;
3315
3459
  const margin = {
3316
- top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin,
3460
+ top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,
3317
3461
  right: 40,
3318
3462
  bottom: 40 + scaleMargin,
3319
3463
  left: dynamicLeftMargin,
@@ -3480,6 +3624,7 @@ export function renderTimeline(
3480
3624
  .on('click', () => {
3481
3625
  if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
3482
3626
  });
3627
+ setTagAttrs(evG, ev);
3483
3628
 
3484
3629
  if (ev.endDate) {
3485
3630
  const x2 = xScale(parseTimelineDate(ev.endDate));
@@ -3589,7 +3734,7 @@ export function renderTimeline(
3589
3734
  const scaleMargin = timelineScale ? 24 : 0;
3590
3735
  const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3591
3736
  const margin = {
3592
- top: 104 + (timelineScale ? 40 : 0) + markerMargin,
3737
+ top: 104 + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,
3593
3738
  right: 40,
3594
3739
  bottom: 40 + scaleMargin,
3595
3740
  left: 60,
@@ -3739,6 +3884,7 @@ export function renderTimeline(
3739
3884
  .on('click', () => {
3740
3885
  if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
3741
3886
  });
3887
+ setTagAttrs(evG, ev);
3742
3888
 
3743
3889
  if (ev.endDate) {
3744
3890
  const x2 = xScale(parseTimelineDate(ev.endDate));
@@ -3837,6 +3983,227 @@ export function renderTimeline(
3837
3983
  }
3838
3984
  });
3839
3985
  }
3986
+
3987
+ // ── Tag Legend (org-chart-style pills) ──
3988
+ if (parsed.timelineTagGroups.length > 0) {
3989
+ const LG_HEIGHT = 28;
3990
+ const LG_PILL_PAD = 16;
3991
+ const LG_PILL_FONT_SIZE = 11;
3992
+ const LG_PILL_FONT_W = LG_PILL_FONT_SIZE * 0.6;
3993
+ const LG_CAPSULE_PAD = 4;
3994
+ const LG_DOT_R = 4;
3995
+ const LG_ENTRY_FONT_SIZE = 10;
3996
+ const LG_ENTRY_FONT_W = LG_ENTRY_FONT_SIZE * 0.6;
3997
+ const LG_ENTRY_DOT_GAP = 4;
3998
+ const LG_ENTRY_TRAIL = 8;
3999
+ const LG_GROUP_GAP = 12;
4000
+
4001
+ const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
4002
+ const mainG = mainSvg.select<SVGGElement>('g');
4003
+ if (!mainSvg.empty() && !mainG.empty()) {
4004
+ // Legend goes in the reserved space between title and chart content.
4005
+ // Title is at y=30 in SVG coords; we place the legend centered in the
4006
+ // tagLegendReserve gap just above where g starts (margin.top).
4007
+ // Render legend directly on SVG (not inside g) for clean centering.
4008
+ const legendY = title ? 50 : 10;
4009
+
4010
+ const groupBg = isDark
4011
+ ? mix(palette.surface, palette.bg, 50)
4012
+ : mix(palette.surface, palette.bg, 30);
4013
+
4014
+ // Pre-compute group widths (minified and expanded)
4015
+ type LegendGroup = {
4016
+ group: TagGroup;
4017
+ minifiedWidth: number;
4018
+ expandedWidth: number;
4019
+ };
4020
+ const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
4021
+ const pillW = g.name.length * LG_PILL_FONT_W + LG_PILL_PAD;
4022
+ let entryX = LG_CAPSULE_PAD + pillW + 4;
4023
+ for (const entry of g.entries) {
4024
+ const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
4025
+ entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
4026
+ }
4027
+ return {
4028
+ group: g,
4029
+ minifiedWidth: pillW,
4030
+ expandedWidth: entryX + LG_CAPSULE_PAD,
4031
+ };
4032
+ });
4033
+
4034
+ // Current active state (for standalone interactivity)
4035
+ let currentActiveGroup: string | null = activeTagGroup ?? null;
4036
+
4037
+ function drawLegend() {
4038
+ // Remove previous legend
4039
+ mainSvg.selectAll('.tl-tag-legend-group').remove();
4040
+
4041
+ // Compute total width and center horizontally in SVG
4042
+ const totalW = legendGroups.reduce((s, lg) => {
4043
+ const isActive = currentActiveGroup != null &&
4044
+ lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase();
4045
+ return s + (isActive ? lg.expandedWidth : lg.minifiedWidth);
4046
+ }, 0) + (legendGroups.length - 1) * LG_GROUP_GAP;
4047
+
4048
+ let cx = (width - totalW) / 2;
4049
+
4050
+ for (const lg of legendGroups) {
4051
+ const isActive = currentActiveGroup != null &&
4052
+ lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase();
4053
+
4054
+ const pillLabel = lg.group.name;
4055
+ const pillWidth = pillLabel.length * LG_PILL_FONT_W + LG_PILL_PAD;
4056
+
4057
+ const gEl = mainSvg
4058
+ .append('g')
4059
+ .attr('transform', `translate(${cx}, ${legendY})`)
4060
+ .attr('class', 'tl-tag-legend-group tl-tag-legend-entry')
4061
+ .attr('data-legend-group', lg.group.name.toLowerCase())
4062
+ .attr('data-tag-group', lg.group.name.toLowerCase())
4063
+ .attr('data-legend-entry', '__group__')
4064
+ .style('cursor', 'pointer')
4065
+ .on('click', () => {
4066
+ const groupKey = lg.group.name.toLowerCase();
4067
+ currentActiveGroup = currentActiveGroup === groupKey ? null : groupKey;
4068
+ drawLegend();
4069
+ recolorEvents();
4070
+ });
4071
+
4072
+ // Outer capsule background (active only)
4073
+ if (isActive) {
4074
+ gEl.append('rect')
4075
+ .attr('width', lg.expandedWidth)
4076
+ .attr('height', LG_HEIGHT)
4077
+ .attr('rx', LG_HEIGHT / 2)
4078
+ .attr('fill', groupBg);
4079
+ }
4080
+
4081
+ const pillXOff = isActive ? LG_CAPSULE_PAD : 0;
4082
+ const pillYOff = isActive ? LG_CAPSULE_PAD : 0;
4083
+ const pillH = LG_HEIGHT - (isActive ? LG_CAPSULE_PAD * 2 : 0);
4084
+
4085
+ // Pill background
4086
+ gEl.append('rect')
4087
+ .attr('x', pillXOff)
4088
+ .attr('y', pillYOff)
4089
+ .attr('width', pillWidth)
4090
+ .attr('height', pillH)
4091
+ .attr('rx', pillH / 2)
4092
+ .attr('fill', isActive ? palette.bg : groupBg);
4093
+
4094
+ // Active pill border
4095
+ if (isActive) {
4096
+ gEl.append('rect')
4097
+ .attr('x', pillXOff)
4098
+ .attr('y', pillYOff)
4099
+ .attr('width', pillWidth)
4100
+ .attr('height', pillH)
4101
+ .attr('rx', pillH / 2)
4102
+ .attr('fill', 'none')
4103
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
4104
+ .attr('stroke-width', 0.75);
4105
+ }
4106
+
4107
+ // Pill text
4108
+ gEl.append('text')
4109
+ .attr('x', pillXOff + pillWidth / 2)
4110
+ .attr('y', LG_HEIGHT / 2 + LG_PILL_FONT_SIZE / 2 - 2)
4111
+ .attr('font-size', LG_PILL_FONT_SIZE)
4112
+ .attr('font-weight', '500')
4113
+ .attr('font-family', FONT_FAMILY)
4114
+ .attr('fill', isActive ? palette.text : palette.textMuted)
4115
+ .attr('text-anchor', 'middle')
4116
+ .text(pillLabel);
4117
+
4118
+ // Entries inside capsule (active only)
4119
+ if (isActive) {
4120
+ let entryX = pillXOff + pillWidth + 4;
4121
+ for (const entry of lg.group.entries) {
4122
+ const tagKey = lg.group.name.toLowerCase();
4123
+ const tagVal = entry.value.toLowerCase();
4124
+
4125
+ const entryG = gEl.append('g')
4126
+ .attr('class', 'tl-tag-legend-entry')
4127
+ .attr('data-tag-group', tagKey)
4128
+ .attr('data-legend-entry', tagVal)
4129
+ .style('cursor', 'pointer')
4130
+ .on('mouseenter', (event: MouseEvent) => {
4131
+ event.stopPropagation();
4132
+ fadeToTagValue(mainG, tagKey, tagVal);
4133
+ // Also fade legend entries on the SVG level
4134
+ mainSvg.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
4135
+ const el = d3Selection.select(this);
4136
+ const ev = el.attr('data-legend-entry');
4137
+ if (ev === '__group__') return;
4138
+ const eg = el.attr('data-tag-group');
4139
+ el.attr('opacity', eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY);
4140
+ });
4141
+ })
4142
+ .on('mouseleave', (event: MouseEvent) => {
4143
+ event.stopPropagation();
4144
+ fadeReset(mainG);
4145
+ mainSvg.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry')
4146
+ .attr('opacity', 1);
4147
+ })
4148
+ .on('click', (event: MouseEvent) => {
4149
+ event.stopPropagation(); // don't toggle group
4150
+ });
4151
+
4152
+ entryG.append('circle')
4153
+ .attr('cx', entryX + LG_DOT_R)
4154
+ .attr('cy', LG_HEIGHT / 2)
4155
+ .attr('r', LG_DOT_R)
4156
+ .attr('fill', entry.color);
4157
+
4158
+ const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
4159
+ entryG.append('text')
4160
+ .attr('x', textX)
4161
+ .attr('y', LG_HEIGHT / 2 + LG_ENTRY_FONT_SIZE / 2 - 1)
4162
+ .attr('font-size', LG_ENTRY_FONT_SIZE)
4163
+ .attr('font-family', FONT_FAMILY)
4164
+ .attr('fill', palette.textMuted)
4165
+ .text(entry.value);
4166
+
4167
+ entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
4168
+ }
4169
+ }
4170
+
4171
+ cx += (isActive ? lg.expandedWidth : lg.minifiedWidth) + LG_GROUP_GAP;
4172
+ }
4173
+ }
4174
+
4175
+ // Build a quick lineNumber→event lookup
4176
+ const eventByLine = new Map<string, TimelineEvent>();
4177
+ for (const ev of timelineEvents) {
4178
+ eventByLine.set(String(ev.lineNumber), ev);
4179
+ }
4180
+
4181
+ function recolorEvents() {
4182
+ mainG.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
4183
+ const el = d3Selection.select(this);
4184
+ const lineNum = el.attr('data-line-number');
4185
+ const ev = lineNum ? eventByLine.get(lineNum) : undefined;
4186
+ if (!ev) return;
4187
+
4188
+ let color: string;
4189
+ if (currentActiveGroup) {
4190
+ const tagColor = resolveTagColor(
4191
+ ev.metadata, parsed.timelineTagGroups, currentActiveGroup
4192
+ );
4193
+ color = tagColor ?? (ev.group && groupColorMap.has(ev.group)
4194
+ ? groupColorMap.get(ev.group)! : textColor);
4195
+ } else {
4196
+ color = ev.group && groupColorMap.has(ev.group)
4197
+ ? groupColorMap.get(ev.group)! : textColor;
4198
+ }
4199
+ el.selectAll('rect').attr('fill', color);
4200
+ el.selectAll('circle:not(.tl-event-point-outline)').attr('fill', color);
4201
+ });
4202
+ }
4203
+
4204
+ drawLegend();
4205
+ }
4206
+ }
3840
4207
  }
3841
4208
 
3842
4209
  // ============================================================
package/src/echarts.ts CHANGED
@@ -142,25 +142,26 @@ export function parseEChart(
142
142
  // Skip empty lines
143
143
  if (!trimmed) continue;
144
144
 
145
- // Check for markdown-style category header: ## Category Name or ## Category Name(color)
146
- const mdCategoryMatch = trimmed.match(/^#{2,}\s+(.+)$/);
147
- if (mdCategoryMatch) {
148
- const { label: catName, color: catColor } = extractColor(mdCategoryMatch[1].trim(), palette);
149
- if (catColor) {
150
- if (!result.categoryColors) result.categoryColors = {};
151
- result.categoryColors[catName] = catColor;
152
- }
153
- currentCategory = catName;
145
+ // Reject legacy ## category syntax
146
+ if (/^#{2,}\s+/.test(trimmed)) {
147
+ const name = trimmed.replace(/^#{2,}\s+/, '').replace(/\s*\([^)]*\)\s*$/, '').trim();
148
+ result.diagnostics.push(makeDgmoError(lineNumber, `'## ${name}' is no longer supported. Use '[${name}]' instead`));
154
149
  continue;
155
150
  }
156
151
 
157
152
  // Skip comments
158
153
  if (trimmed.startsWith('//')) continue;
159
154
 
160
- // Check for category header: [Category Name]
161
- const categoryMatch = trimmed.match(/^\[(.+)\]$/);
155
+ // [Category] container header with optional color: [Category Name] or [Category Name](color)
156
+ const categoryMatch = trimmed.match(/^\[(.+?)\](?:\s*\(([^)]+)\))?\s*$/);
162
157
  if (categoryMatch) {
163
- currentCategory = categoryMatch[1].trim();
158
+ const catName = categoryMatch[1].trim();
159
+ const catColor = categoryMatch[2] ? resolveColor(categoryMatch[2].trim(), palette) : null;
160
+ if (catColor) {
161
+ if (!result.categoryColors) result.categoryColors = {};
162
+ result.categoryColors[catName] = catColor;
163
+ }
164
+ currentCategory = catName;
164
165
  continue;
165
166
  }
166
167