@diagrammo/dgmo 0.8.1 → 0.8.3

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/src/d3.ts CHANGED
@@ -182,7 +182,7 @@ import { getSeriesColors } from './palettes';
182
182
  import { mix } from './palettes/color-utils';
183
183
  import type { DgmoError } from './diagnostics';
184
184
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
185
- import { collectIndentedValues, extractColor, normalizeDirection, parseFirstLine, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from './utils/parsing';
185
+ import { collectIndentedValues, extractColor, parseFirstLine, parsePipeMetadata, MULTIPLE_PIPE_ERROR } from './utils/parsing';
186
186
  import { matchTagBlockHeading, validateTagValues, resolveTagColor } from './utils/tag-groups';
187
187
  import type { TagGroup } from './utils/tag-groups';
188
188
  import {
@@ -463,6 +463,10 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
463
463
  let currentArcGroup: string | null = null;
464
464
  let currentTimelineGroup: string | null = null;
465
465
  let currentTimelineTagGroup: TagGroup | null = null;
466
+ let inTimelineEraBlock = false;
467
+ let timelineEraBlockIndent = 0;
468
+ let inTimelineMarkerBlock = false;
469
+ let timelineMarkerBlockIndent = 0;
466
470
  const timelineAliasMap = new Map<string, string>();
467
471
  const VALID_D3_TYPES = new Set(['slope', 'wordcloud', 'arc', 'timeline', 'venn', 'quadrant', 'sequence']);
468
472
  let firstLineParsed = false;
@@ -494,14 +498,10 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
494
498
  // Not a bare chart type — fall through to normal parsing
495
499
  }
496
500
 
497
- // Timeline tag group heading: `tag: Name [alias X]`
501
+ // Timeline tag group heading: `tag Name [alias X]`
498
502
  if (result.type === 'timeline' && indent === 0) {
499
503
  const tagBlockMatch = matchTagBlockHeading(line);
500
504
  if (tagBlockMatch) {
501
- if (tagBlockMatch.deprecated) {
502
- result.diagnostics.push(makeDgmoError(lineNumber,
503
- `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`, 'warning'));
504
- }
505
505
  currentTimelineTagGroup = {
506
506
  name: tagBlockMatch.name,
507
507
  alias: tagBlockMatch.alias,
@@ -605,10 +605,80 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
605
605
  }
606
606
  }
607
607
 
608
- // Timeline era lines: era YYYY->YYYY Label (color)
608
+ // Timeline era block entries (indented under bare `era`)
609
+ if (result.type === 'timeline' && inTimelineEraBlock) {
610
+ if (indent <= timelineEraBlockIndent) {
611
+ inTimelineEraBlock = false;
612
+ // fall through to process this line normally
613
+ } else {
614
+ if (line.startsWith('//')) continue;
615
+ const eraEntryMatch = line.match(
616
+ /^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*(?:->|\u2013>)\s*(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*:?\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
617
+ );
618
+ if (eraEntryMatch) {
619
+ const colorAnnotation = eraEntryMatch[4]?.trim() || null;
620
+ result.timelineEras.push({
621
+ startDate: eraEntryMatch[1],
622
+ endDate: eraEntryMatch[2],
623
+ label: eraEntryMatch[3].trim(),
624
+ color: colorAnnotation
625
+ ? resolveColor(colorAnnotation, palette)
626
+ : null,
627
+ lineNumber,
628
+ });
629
+ } else {
630
+ warn(lineNumber, `Unrecognized era entry: "${line}"`);
631
+ }
632
+ continue;
633
+ }
634
+ }
635
+
636
+ // Timeline marker block entries (indented under bare `marker`)
637
+ if (result.type === 'timeline' && inTimelineMarkerBlock) {
638
+ if (indent <= timelineMarkerBlockIndent) {
639
+ inTimelineMarkerBlock = false;
640
+ // fall through to process this line normally
641
+ } else {
642
+ if (line.startsWith('//')) continue;
643
+ const markerEntryMatch = line.match(
644
+ /^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
645
+ );
646
+ if (markerEntryMatch) {
647
+ const colorAnnotation = markerEntryMatch[3]?.trim() || null;
648
+ result.timelineMarkers.push({
649
+ date: markerEntryMatch[1],
650
+ label: markerEntryMatch[2].trim(),
651
+ color: colorAnnotation
652
+ ? resolveColor(colorAnnotation, palette)
653
+ : null,
654
+ lineNumber,
655
+ });
656
+ } else {
657
+ warn(lineNumber, `Unrecognized marker entry: "${line}"`);
658
+ }
659
+ continue;
660
+ }
661
+ }
662
+
663
+ // Timeline era/marker block starters and inline forms
609
664
  if (result.type === 'timeline') {
665
+ // Bare `era` keyword starts a block
666
+ if (line.toLowerCase() === 'era') {
667
+ inTimelineEraBlock = true;
668
+ timelineEraBlockIndent = indent;
669
+ continue;
670
+ }
671
+
672
+ // Bare `marker` keyword starts a block
673
+ if (line.toLowerCase() === 'marker') {
674
+ inTimelineMarkerBlock = true;
675
+ timelineMarkerBlockIndent = indent;
676
+ continue;
677
+ }
678
+
679
+ // Timeline era lines (inline): era YYYY->YYYY Label (color)
610
680
  const eraMatch = line.match(
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*$/
681
+ /^era\s+(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*(?:->|\u2013>)\s*(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*:?\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
612
682
  );
613
683
  if (eraMatch) {
614
684
  const colorAnnotation = eraMatch[4]?.trim() || null;
@@ -624,7 +694,7 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
624
694
  continue;
625
695
  }
626
696
 
627
- // Timeline marker lines: marker YYYY Label (color)
697
+ // Timeline marker lines (inline): marker YYYY Label (color)
628
698
  const markerMatch = line.match(
629
699
  /^marker:?\s+(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
630
700
  );
@@ -647,8 +717,9 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
647
717
  // Duration event: 2026-07-15->30d: description (d=days, w=weeks, m=months, y=years, h=hours, min=minutes)
648
718
  // Supports decimals up to 2 places (e.g., 1.25y = 1 year 3 months)
649
719
  // Supports uncertain end with ? suffix (e.g., ->3m?: fades out the last 20%)
720
+ // Accepts both -> (hyphen) and –> (en-dash U+2013)
650
721
  const durationMatch = line.match(
651
- /^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*->\s*(\d+(?:\.\d{1,2})?)(min|[dwmyh])(\?)?(?:\s*:\s*|\s+)(.+)$/
722
+ /^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*(?:->|\u2013>)\s*(\d+(?:\.\d{1,2})?)(min|[dwmyh])(\?)?(?:\s*:\s*|\s+)(.+)$/
652
723
  );
653
724
  if (durationMatch) {
654
725
  const startDate = durationMatch[1];
@@ -658,7 +729,7 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
658
729
  const endDate = addDurationToDate(startDate, amount, unit);
659
730
  const segments = durationMatch[5].split('|');
660
731
  const metadata = segments.length > 1
661
- ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING))
732
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_ERROR))
662
733
  : {};
663
734
  result.timelineEvents.push({
664
735
  date: startDate,
@@ -673,13 +744,15 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
673
744
  }
674
745
 
675
746
  // Range event: 1655->1667 description (supports uncertain end: 1655->1667?)
747
+ // Also supports YYYY-MM-DD HH:MM in both start and end dates
748
+ // Accepts both -> (hyphen) and –> (en-dash U+2013)
676
749
  const rangeMatch = line.match(
677
- /^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)(\?)?(?:\s*:\s*|\s+)(.+)$/
750
+ /^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*(?:->|\u2013>)\s*(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)(\?)?(?:\s*:\s*|\s+)(.+)$/
678
751
  );
679
752
  if (rangeMatch) {
680
753
  const segments = rangeMatch[4].split('|');
681
754
  const metadata = segments.length > 1
682
- ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING))
755
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_ERROR))
683
756
  : {};
684
757
  result.timelineEvents.push({
685
758
  date: rangeMatch[1],
@@ -700,7 +773,7 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
700
773
  if (pointMatch) {
701
774
  const segments = pointMatch[2].split('|');
702
775
  const metadata = segments.length > 1
703
- ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING))
776
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_ERROR))
704
777
  : {};
705
778
  result.timelineEvents.push({
706
779
  date: pointMatch[1],
@@ -716,20 +789,52 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
716
789
 
717
790
  // Venn diagram DSL
718
791
  if (result.type === 'venn') {
719
- // Intersection line: "A + B: Label" / "A + B" / "A + B + C: Label"
792
+ // Intersection line: "A + B Label" / "A + B" / "A + B + C Label"
793
+ // Also accepts deprecated colon syntax: "A + B: Label"
720
794
  if (/\+/.test(line)) {
721
- const colonIdx = line.indexOf(':');
722
- let setsPart: string;
723
- let label: string | null;
724
- if (colonIdx >= 0) {
725
- setsPart = line.substring(0, colonIdx).trim();
726
- label = line.substring(colonIdx + 1).trim() || null;
727
- } else {
728
- setsPart = line.trim();
729
- label = null;
795
+ // Build lookup of known set names and aliases for label extraction
796
+ const knownSetRefs = new Set<string>();
797
+ for (const s of result.vennSets) {
798
+ knownSetRefs.add(s.name.toLowerCase());
799
+ if (s.alias) knownSetRefs.add(s.alias.toLowerCase());
730
800
  }
731
- const rawSets = setsPart.split('+').map((s) => s.trim()).filter(Boolean);
732
- if (rawSets.length >= 2) {
801
+
802
+ const segments = line.split('+').map((s) => s.trim()).filter(Boolean);
803
+ if (segments.length >= 2) {
804
+ // All segments except the last are pure set references
805
+ const rawSets = segments.slice(0, -1);
806
+ const lastSeg = segments[segments.length - 1];
807
+
808
+ // For the last segment, extract set reference and optional label.
809
+ // Support deprecated colon: "SetRef: Label"
810
+ const colonIdx = lastSeg.indexOf(':');
811
+ let lastSetRef: string;
812
+ let label: string | null;
813
+ if (colonIdx >= 0) {
814
+ lastSetRef = lastSeg.substring(0, colonIdx).trim();
815
+ label = lastSeg.substring(colonIdx + 1).trim() || null;
816
+ } else {
817
+ // No colon — find where the set reference ends and label begins.
818
+ // Try progressively shorter prefixes against known set names/aliases.
819
+ const words = lastSeg.split(/\s+/);
820
+ let matchLen = 0;
821
+ for (let w = words.length; w >= 1; w--) {
822
+ const candidate = words.slice(0, w).join(' ');
823
+ if (knownSetRefs.has(candidate.toLowerCase())) {
824
+ matchLen = w;
825
+ break;
826
+ }
827
+ }
828
+ if (matchLen > 0) {
829
+ lastSetRef = words.slice(0, matchLen).join(' ');
830
+ label = words.length > matchLen ? words.slice(matchLen).join(' ') : null;
831
+ } else {
832
+ // No known set matched — assume first word is the set ref, rest is label
833
+ lastSetRef = words[0];
834
+ label = words.length > 1 ? words.slice(1).join(' ') : null;
835
+ }
836
+ }
837
+ rawSets.push(lastSetRef);
733
838
  result.vennOverlaps.push({ sets: rawSets, label, lineNumber });
734
839
  continue;
735
840
  }
@@ -866,20 +971,6 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
866
971
  continue;
867
972
  }
868
973
 
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
974
  if (firstToken === 'order') {
884
975
  const v = restValue.toLowerCase();
885
976
  if (v === 'name' || v === 'group' || v === 'degree') {
@@ -888,29 +979,6 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
888
979
  continue;
889
980
  }
890
981
 
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
982
  if (firstToken === 'rotate') {
915
983
  const v = restValue.toLowerCase();
916
984
  if (v === 'none' || v === 'mixed' || v === 'angled') {
@@ -951,23 +1019,6 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
951
1019
  // Check for color annotation in raw key: "Label(color)"
952
1020
  const colorMatch = rawKey.match(/^(.+?)\(([^)]+)\)\s*$/);
953
1021
 
954
- if (key === 'chart') {
955
- const value = line
956
- .substring(colonIndex + 1)
957
- .trim()
958
- .toLowerCase();
959
- if (VALID_D3_TYPES.has(value)) {
960
- result.type = value as ParsedVisualization['type'];
961
- } else {
962
- const validD3Types = [...VALID_D3_TYPES];
963
- let msg = `Unsupported chart type: ${value}. Supported types: ${validD3Types.join(', ')}`;
964
- const hint = suggest(value, validD3Types);
965
- if (hint) msg += `. ${hint}`;
966
- return fail(lineNumber, msg);
967
- }
968
- continue;
969
- }
970
-
971
1022
  if (key === 'title') {
972
1023
  result.title = line.substring(colonIndex + 1).trim();
973
1024
  result.titleLineNumber = lineNumber;
@@ -977,23 +1028,6 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
977
1028
  continue;
978
1029
  }
979
1030
 
980
- if (key === 'orientation' || key === 'direction') {
981
- // Only arc and timeline support orientation
982
- if (result.type === 'arc' || result.type === 'timeline') {
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';
992
- }
993
- }
994
- continue;
995
- }
996
-
997
1031
  if (key === 'order') {
998
1032
  const v = line
999
1033
  .substring(colonIndex + 1)
@@ -1005,39 +1039,6 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
1005
1039
  continue;
1006
1040
  }
1007
1041
 
1008
- if (key === 'sort') {
1009
- const v = line
1010
- .substring(colonIndex + 1)
1011
- .trim();
1012
- const vLower = v.toLowerCase();
1013
- if (vLower === 'time' || vLower === 'group') {
1014
- result.timelineSort = vLower;
1015
- } else if (vLower === 'tag' || vLower.startsWith('tag:')) {
1016
- result.timelineSort = 'tag';
1017
- if (vLower.startsWith('tag:')) {
1018
- // Extract group name (preserving original case for display)
1019
- const groupRef = v.substring(4).trim();
1020
- if (groupRef) {
1021
- result.timelineDefaultSwimlaneTG = groupRef;
1022
- }
1023
- }
1024
- }
1025
- continue;
1026
- }
1027
-
1028
- if (key === 'swimlanes') {
1029
- const v = line
1030
- .substring(colonIndex + 1)
1031
- .trim()
1032
- .toLowerCase();
1033
- if (v === 'on') {
1034
- result.timelineSwimlanes = true;
1035
- } else if (v === 'off') {
1036
- result.timelineSwimlanes = false;
1037
- }
1038
- continue;
1039
- }
1040
-
1041
1042
  if (key === 'rotate') {
1042
1043
  const v = line
1043
1044
  .substring(colonIndex + 1)
@@ -1092,22 +1093,16 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
1092
1093
  }
1093
1094
 
1094
1095
  if (allNumeric && numericValues.length > 0) {
1095
- // For wordcloud, single numeric value = word weight
1096
- if (result.type === 'wordcloud' && numericValues.length === 1) {
1097
- result.words.push({
1098
- text: labelPart,
1099
- weight: numericValues[0],
1100
- lineNumber,
1101
- });
1102
- } else {
1096
+ // Wordcloud does not use colon data format — skip to freeform handling
1097
+ if (result.type !== 'wordcloud') {
1103
1098
  result.data.push({
1104
1099
  label: labelPart,
1105
1100
  values: numericValues,
1106
1101
  color: colorPart,
1107
1102
  lineNumber,
1108
1103
  });
1104
+ continue;
1109
1105
  }
1110
- continue;
1111
1106
  }
1112
1107
  }
1113
1108
 
@@ -1171,7 +1166,7 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
1171
1166
  result.words = tokenizeFreeformText(freeformLines.join(' '));
1172
1167
  }
1173
1168
  if (result.words.length === 0) {
1174
- warn(1, 'No words found. Add words as "word: weight", one per line, or paste freeform text');
1169
+ warn(1, 'No words found. Add words as "word weight" (space-separated), one per line, or paste freeform text');
1175
1170
  }
1176
1171
  // Apply max word limit (words are already sorted by weight desc for freeform)
1177
1172
  if (
@@ -1226,29 +1221,6 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
1226
1221
  }
1227
1222
  }
1228
1223
 
1229
- // Resolve sort: tag default swimlane group
1230
- if (result.timelineSort === 'tag') {
1231
- if (result.timelineTagGroups.length === 0) {
1232
- warn(1, '"sort: tag" requires at least one tag group definition');
1233
- result.timelineSort = 'time';
1234
- } else if (result.timelineDefaultSwimlaneTG) {
1235
- // Resolve alias → full group name
1236
- const ref = result.timelineDefaultSwimlaneTG.toLowerCase();
1237
- const match = result.timelineTagGroups.find(
1238
- (g) => g.name.toLowerCase() === ref || g.alias?.toLowerCase() === ref
1239
- );
1240
- if (match) {
1241
- result.timelineDefaultSwimlaneTG = match.name;
1242
- } else {
1243
- warn(1, `"sort: tag:${result.timelineDefaultSwimlaneTG}" — no tag group matches "${result.timelineDefaultSwimlaneTG}"`);
1244
- result.timelineDefaultSwimlaneTG = result.timelineTagGroups[0].name;
1245
- }
1246
- } else {
1247
- // Default to first tag group
1248
- result.timelineDefaultSwimlaneTG = result.timelineTagGroups[0].name;
1249
- }
1250
- }
1251
-
1252
1224
  return result;
1253
1225
  }
1254
1226
 
@@ -2570,6 +2542,26 @@ export function formatDateLabel(dateStr: string): string {
2570
2542
  return `${month} ${day}, ${year}${timeSuffix}`;
2571
2543
  }
2572
2544
 
2545
+ /**
2546
+ * Formats a boundary label for the time axis.
2547
+ * When both boundaries fall on the same calendar day and have a time component,
2548
+ * returns just the time (e.g. "12:15") to avoid collisions with regular ticks.
2549
+ * Otherwise falls back to the full formatDateLabel.
2550
+ */
2551
+ function formatBoundaryLabel(dateStr: string, otherDateStr: string): string {
2552
+ const spaceIdx = dateStr.indexOf(' ');
2553
+ const otherSpaceIdx = otherDateStr.indexOf(' ');
2554
+ // Both must have time components and share the same date portion
2555
+ if (spaceIdx !== -1 && otherSpaceIdx !== -1) {
2556
+ const datePart = dateStr.slice(0, spaceIdx);
2557
+ const otherDatePart = otherDateStr.slice(0, otherSpaceIdx);
2558
+ if (datePart === otherDatePart) {
2559
+ return dateStr.slice(spaceIdx + 1); // just "HH:MM"
2560
+ }
2561
+ }
2562
+ return formatDateLabel(dateStr);
2563
+ }
2564
+
2573
2565
  /**
2574
2566
  * Computes adaptive tick marks for a timeline scale.
2575
2567
  * - Multi-year spans → year ticks
@@ -2662,6 +2654,9 @@ export function computeTimeTicks(
2662
2654
  else if (spanHours > 24) stepHour = 3;
2663
2655
  else if (spanHours > 12) stepHour = 2;
2664
2656
 
2657
+ // For single-day spans, just show HH:MM without the date prefix
2658
+ const singleDay = spanHours <= 24;
2659
+
2665
2660
  const startDate = fractionalYearToDate(domainMin);
2666
2661
  // Round down to nearest step boundary
2667
2662
  startDate.setHours(Math.floor(startDate.getHours() / stepHour) * stepHour, 0, 0, 0);
@@ -2670,11 +2665,15 @@ export function computeTimeTicks(
2670
2665
  const val = dateToFractionalYear(startDate);
2671
2666
  if (val > domainMax) break;
2672
2667
  if (val >= domainMin) {
2673
- const mon = MONTH_ABBR[startDate.getMonth()];
2674
- const d = startDate.getDate();
2675
2668
  const hh = String(startDate.getHours()).padStart(2, '0');
2676
2669
  const mm = String(startDate.getMinutes()).padStart(2, '0');
2677
- ticks.push({ pos: scale(val), label: `${mon} ${d} ${hh}:${mm}` });
2670
+ if (singleDay) {
2671
+ ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
2672
+ } else {
2673
+ const mon = MONTH_ABBR[startDate.getMonth()];
2674
+ const d = startDate.getDate();
2675
+ ticks.push({ pos: scale(val), label: `${mon} ${d} ${hh}:${mm}` });
2676
+ }
2678
2677
  }
2679
2678
  startDate.setHours(startDate.getHours() + stepHour);
2680
2679
  }
@@ -3409,8 +3408,8 @@ export function renderTimeline(
3409
3408
  textColor,
3410
3409
  minDate,
3411
3410
  maxDate,
3412
- formatDateLabel(earliestStartDateStr),
3413
- formatDateLabel(latestEndDateStr)
3411
+ formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
3412
+ formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
3414
3413
  );
3415
3414
  }
3416
3415
 
@@ -3659,8 +3658,8 @@ export function renderTimeline(
3659
3658
  textColor,
3660
3659
  minDate,
3661
3660
  maxDate,
3662
- formatDateLabel(earliestStartDateStr),
3663
- formatDateLabel(latestEndDateStr)
3661
+ formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
3662
+ formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
3664
3663
  );
3665
3664
  }
3666
3665
 
@@ -3960,8 +3959,8 @@ export function renderTimeline(
3960
3959
  textColor,
3961
3960
  minDate,
3962
3961
  maxDate,
3963
- formatDateLabel(earliestStartDateStr),
3964
- formatDateLabel(latestEndDateStr)
3962
+ formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
3963
+ formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
3965
3964
  );
3966
3965
  }
3967
3966
 
@@ -4261,8 +4260,8 @@ export function renderTimeline(
4261
4260
  textColor,
4262
4261
  minDate,
4263
4262
  maxDate,
4264
- formatDateLabel(earliestStartDateStr),
4265
- formatDateLabel(latestEndDateStr)
4263
+ formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
4264
+ formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
4266
4265
  );
4267
4266
  }
4268
4267
 
@@ -68,7 +68,7 @@ export function looksLikeC4(content: string): boolean {
68
68
  /**
69
69
  * Extracts the chart type from raw file content.
70
70
  * First tries the first non-empty, non-comment line as a bare chart type name
71
- * (e.g., `gantt Product Launch`). Falls back to old `chart: type` syntax.
71
+ * (e.g., `gantt Product Launch`).
72
72
  * Falls back to inference when no explicit chart type is found.
73
73
  */
74
74
  export function parseDgmoChartType(content: string): string | null {