@diagrammo/dgmo 0.2.7 → 0.2.9

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.2.7",
3
+ "version": "0.2.9",
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
@@ -23,6 +23,7 @@ export interface ChartDataPoint {
23
23
  export interface ParsedChart {
24
24
  type: ChartType;
25
25
  title?: string;
26
+ titleLineNumber?: number;
26
27
  series?: string;
27
28
  xlabel?: string;
28
29
  ylabel?: string;
@@ -120,6 +121,7 @@ export function parseChart(
120
121
 
121
122
  if (key === 'title') {
122
123
  result.title = value;
124
+ result.titleLineNumber = lineNumber;
123
125
  continue;
124
126
  }
125
127
 
package/src/d3.ts CHANGED
@@ -139,6 +139,7 @@ export interface D3ExportDimensions {
139
139
  export interface ParsedD3 {
140
140
  type: D3ChartType | null;
141
141
  title: string | null;
142
+ titleLineNumber: number | null;
142
143
  orientation: 'horizontal' | 'vertical';
143
144
  periods: string[];
144
145
  data: D3DataItem[];
@@ -265,6 +266,7 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
265
266
  const result: ParsedD3 = {
266
267
  type: null,
267
268
  title: null,
269
+ titleLineNumber: null,
268
270
  orientation: 'horizontal',
269
271
  periods: [],
270
272
  data: [],
@@ -609,6 +611,7 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
609
611
 
610
612
  if (key === 'title') {
611
613
  result.title = line.substring(colonIndex + 1).trim();
614
+ result.titleLineNumber = lineNumber;
612
615
  if (result.type === 'quadrant') {
613
616
  result.quadrantTitleLineNumber = lineNumber;
614
617
  }
@@ -1120,15 +1123,27 @@ export function renderSlopeChart(
1120
1123
 
1121
1124
  // Title
1122
1125
  if (title) {
1123
- svg
1126
+ const titleEl = svg
1124
1127
  .append('text')
1128
+ .attr('class', 'chart-title')
1125
1129
  .attr('x', width / 2)
1126
1130
  .attr('y', 30)
1127
1131
  .attr('text-anchor', 'middle')
1128
1132
  .attr('fill', textColor)
1129
1133
  .attr('font-size', '20px')
1130
1134
  .attr('font-weight', '700')
1135
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
1131
1136
  .text(title);
1137
+
1138
+ if (parsed.titleLineNumber) {
1139
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
1140
+ if (onClickItem) {
1141
+ titleEl
1142
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
1143
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
1144
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
1145
+ }
1146
+ }
1132
1147
  }
1133
1148
 
1134
1149
  // Period column headers
@@ -1501,15 +1516,27 @@ export function renderArcDiagram(
1501
1516
 
1502
1517
  // Title
1503
1518
  if (title) {
1504
- svg
1519
+ const titleEl = svg
1505
1520
  .append('text')
1521
+ .attr('class', 'chart-title')
1506
1522
  .attr('x', width / 2)
1507
1523
  .attr('y', 30)
1508
1524
  .attr('text-anchor', 'middle')
1509
1525
  .attr('fill', textColor)
1510
1526
  .attr('font-size', '20px')
1511
1527
  .attr('font-weight', '700')
1528
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
1512
1529
  .text(title);
1530
+
1531
+ if (parsed.titleLineNumber) {
1532
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
1533
+ if (onClickItem) {
1534
+ titleEl
1535
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
1536
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
1537
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
1538
+ }
1539
+ }
1513
1540
  }
1514
1541
 
1515
1542
  // Build adjacency map for hover interactions
@@ -2745,15 +2772,27 @@ export function renderTimeline(
2745
2772
  .attr('transform', `translate(${margin.left},${margin.top})`);
2746
2773
 
2747
2774
  if (title) {
2748
- svg
2775
+ const titleEl = svg
2749
2776
  .append('text')
2777
+ .attr('class', 'chart-title')
2750
2778
  .attr('x', width / 2)
2751
2779
  .attr('y', 30)
2752
2780
  .attr('text-anchor', 'middle')
2753
2781
  .attr('fill', textColor)
2754
2782
  .attr('font-size', '20px')
2755
2783
  .attr('font-weight', '700')
2784
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
2756
2785
  .text(title);
2786
+
2787
+ if (parsed.titleLineNumber) {
2788
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
2789
+ if (onClickItem) {
2790
+ titleEl
2791
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
2792
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
2793
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
2794
+ }
2795
+ }
2757
2796
  }
2758
2797
 
2759
2798
  renderEras(
@@ -2936,15 +2975,27 @@ export function renderTimeline(
2936
2975
  .attr('transform', `translate(${margin.left},${margin.top})`);
2937
2976
 
2938
2977
  if (title) {
2939
- svg
2978
+ const titleEl = svg
2940
2979
  .append('text')
2980
+ .attr('class', 'chart-title')
2941
2981
  .attr('x', width / 2)
2942
2982
  .attr('y', 30)
2943
2983
  .attr('text-anchor', 'middle')
2944
2984
  .attr('fill', textColor)
2945
2985
  .attr('font-size', '20px')
2946
2986
  .attr('font-weight', '700')
2987
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
2947
2988
  .text(title);
2989
+
2990
+ if (parsed.titleLineNumber) {
2991
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
2992
+ if (onClickItem) {
2993
+ titleEl
2994
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
2995
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
2996
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
2997
+ }
2998
+ }
2948
2999
  }
2949
3000
 
2950
3001
  renderEras(
@@ -3188,15 +3239,27 @@ export function renderTimeline(
3188
3239
  .attr('transform', `translate(${margin.left},${margin.top})`);
3189
3240
 
3190
3241
  if (title) {
3191
- svg
3242
+ const titleEl = svg
3192
3243
  .append('text')
3244
+ .attr('class', 'chart-title')
3193
3245
  .attr('x', width / 2)
3194
3246
  .attr('y', 30)
3195
3247
  .attr('text-anchor', 'middle')
3196
3248
  .attr('fill', textColor)
3197
3249
  .attr('font-size', '20px')
3198
3250
  .attr('font-weight', '700')
3251
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
3199
3252
  .text(title);
3253
+
3254
+ if (parsed.titleLineNumber) {
3255
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
3256
+ if (onClickItem) {
3257
+ titleEl
3258
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
3259
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
3260
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
3261
+ }
3262
+ }
3200
3263
  }
3201
3264
 
3202
3265
  renderEras(
@@ -3472,15 +3535,27 @@ export function renderTimeline(
3472
3535
  .attr('transform', `translate(${margin.left},${margin.top})`);
3473
3536
 
3474
3537
  if (title) {
3475
- svg
3538
+ const titleEl = svg
3476
3539
  .append('text')
3540
+ .attr('class', 'chart-title')
3477
3541
  .attr('x', width / 2)
3478
3542
  .attr('y', 30)
3479
3543
  .attr('text-anchor', 'middle')
3480
3544
  .attr('fill', textColor)
3481
3545
  .attr('font-size', '20px')
3482
3546
  .attr('font-weight', '700')
3547
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
3483
3548
  .text(title);
3549
+
3550
+ if (parsed.titleLineNumber) {
3551
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
3552
+ if (onClickItem) {
3553
+ titleEl
3554
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
3555
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
3556
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
3557
+ }
3558
+ }
3484
3559
  }
3485
3560
 
3486
3561
  renderEras(
@@ -3767,15 +3842,27 @@ export function renderWordCloud(
3767
3842
  .style('background', bgColor);
3768
3843
 
3769
3844
  if (title) {
3770
- svg
3845
+ const titleEl = svg
3771
3846
  .append('text')
3847
+ .attr('class', 'chart-title')
3772
3848
  .attr('x', width / 2)
3773
3849
  .attr('y', 30)
3774
3850
  .attr('text-anchor', 'middle')
3775
3851
  .attr('fill', textColor)
3776
3852
  .attr('font-size', '20px')
3777
3853
  .attr('font-weight', '700')
3854
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
3778
3855
  .text(title);
3856
+
3857
+ if (parsed.titleLineNumber) {
3858
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
3859
+ if (onClickItem) {
3860
+ titleEl
3861
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
3862
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
3863
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
3864
+ }
3865
+ }
3779
3866
  }
3780
3867
 
3781
3868
  const g = svg
@@ -3872,8 +3959,9 @@ function renderWordCloudAsync(
3872
3959
  .style('background', bgColor);
3873
3960
 
3874
3961
  if (title) {
3875
- svg
3962
+ const titleEl = svg
3876
3963
  .append('text')
3964
+ .attr('class', 'chart-title')
3877
3965
  .attr('x', width / 2)
3878
3966
  .attr('y', 30)
3879
3967
  .attr('text-anchor', 'middle')
@@ -3881,6 +3969,10 @@ function renderWordCloudAsync(
3881
3969
  .attr('font-size', '20px')
3882
3970
  .attr('font-weight', '700')
3883
3971
  .text(title);
3972
+
3973
+ if (parsed.titleLineNumber) {
3974
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
3975
+ }
3884
3976
  }
3885
3977
 
3886
3978
  const g = svg
@@ -4217,15 +4309,27 @@ export function renderVenn(
4217
4309
 
4218
4310
  // Title
4219
4311
  if (title) {
4220
- svg
4312
+ const titleEl = svg
4221
4313
  .append('text')
4314
+ .attr('class', 'chart-title')
4222
4315
  .attr('x', width / 2)
4223
4316
  .attr('y', 30)
4224
4317
  .attr('text-anchor', 'middle')
4225
4318
  .attr('fill', textColor)
4226
4319
  .attr('font-size', '20px')
4227
4320
  .attr('font-weight', '700')
4321
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
4228
4322
  .text(title);
4323
+
4324
+ if (parsed.titleLineNumber) {
4325
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
4326
+ if (onClickItem) {
4327
+ titleEl
4328
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
4329
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
4330
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
4331
+ }
4332
+ }
4229
4333
  }
4230
4334
 
4231
4335
  // ── Clip-path definitions ──
@@ -4593,6 +4697,7 @@ export function renderQuadrant(
4593
4697
  if (title) {
4594
4698
  const titleText = svg
4595
4699
  .append('text')
4700
+ .attr('class', 'chart-title')
4596
4701
  .attr('x', width / 2)
4597
4702
  .attr('y', 30)
4598
4703
  .attr('text-anchor', 'middle')
@@ -4605,6 +4710,10 @@ export function renderQuadrant(
4605
4710
  )
4606
4711
  .text(title);
4607
4712
 
4713
+ if (quadrantTitleLineNumber) {
4714
+ titleText.attr('data-line-number', quadrantTitleLineNumber);
4715
+ }
4716
+
4608
4717
  if (onClickItem && quadrantTitleLineNumber) {
4609
4718
  titleText
4610
4719
  .on('click', () => onClickItem(quadrantTitleLineNumber))
package/src/echarts.ts CHANGED
@@ -54,6 +54,7 @@ export interface ParsedHeatmapRow {
54
54
  export interface ParsedEChart {
55
55
  type: EChartsChartType;
56
56
  title?: string;
57
+ titleLineNumber?: number;
57
58
  series?: string;
58
59
  seriesNames?: string[];
59
60
  seriesNameColors?: (string | undefined)[];
@@ -174,6 +175,7 @@ export function parseEChart(
174
175
 
175
176
  if (key === 'title') {
176
177
  result.title = value;
178
+ result.titleLineNumber = lineNumber;
177
179
  continue;
178
180
  }
179
181
 
@@ -436,6 +436,7 @@ export function parseFlowchart(
436
436
 
437
437
  if (key === 'title') {
438
438
  result.title = value;
439
+ result.titleLineNumber = lineNumber;
439
440
  continue;
440
441
  }
441
442
 
@@ -257,7 +257,6 @@ export function renderFlowchart(
257
257
  .append('svg')
258
258
  .attr('width', width)
259
259
  .attr('height', height)
260
- .style('background', palette.bg)
261
260
  .style('font-family', FONT_FAMILY);
262
261
 
263
262
  // Defs: arrowhead markers
@@ -305,7 +304,7 @@ export function renderFlowchart(
305
304
 
306
305
  // Title
307
306
  if (graph.title) {
308
- mainG
307
+ const titleEl = mainG
309
308
  .append('text')
310
309
  .attr('x', diagramW / 2)
311
310
  .attr('y', TITLE_FONT_SIZE)
@@ -313,8 +312,19 @@ export function renderFlowchart(
313
312
  .attr('fill', palette.text)
314
313
  .attr('font-size', TITLE_FONT_SIZE)
315
314
  .attr('font-weight', 'bold')
316
- .attr('class', 'fc-title')
315
+ .attr('class', 'fc-title chart-title')
316
+ .style('cursor', onClickItem && graph.titleLineNumber ? 'pointer' : 'default')
317
317
  .text(graph.title);
318
+
319
+ if (graph.titleLineNumber) {
320
+ titleEl.attr('data-line-number', graph.titleLineNumber);
321
+ if (onClickItem) {
322
+ titleEl
323
+ .on('click', () => onClickItem(graph.titleLineNumber!))
324
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
325
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
326
+ }
327
+ }
318
328
  }
319
329
 
320
330
  // Content group (offset by title)
@@ -425,7 +435,8 @@ export function renderFlowchart(
425
435
  .append('g')
426
436
  .attr('transform', `translate(${node.x}, ${node.y})`)
427
437
  .attr('class', 'fc-node')
428
- .attr('data-line-number', String(node.lineNumber));
438
+ .attr('data-line-number', String(node.lineNumber))
439
+ .attr('data-node-id', node.id);
429
440
 
430
441
  if (onClickItem) {
431
442
  nodeG.style('cursor', 'pointer').on('click', () => {
@@ -36,6 +36,7 @@ export interface GraphGroup {
36
36
  export interface ParsedGraph {
37
37
  type: 'flowchart';
38
38
  title?: string;
39
+ titleLineNumber?: number;
39
40
  direction: GraphDirection;
40
41
  nodes: GraphNode[];
41
42
  edges: GraphEdge[];
@@ -97,6 +97,7 @@ export interface SequenceNote {
97
97
  position: 'right' | 'left';
98
98
  participantId: string;
99
99
  lineNumber: number;
100
+ endLineNumber: number;
100
101
  }
101
102
 
102
103
  export type SequenceElement =
@@ -132,6 +133,7 @@ export interface SequenceGroup {
132
133
  */
133
134
  export interface ParsedSequenceDgmo {
134
135
  title: string | null;
136
+ titleLineNumber: number | null;
135
137
  participants: SequenceParticipant[];
136
138
  messages: SequenceMessage[];
137
139
  elements: SequenceElement[];
@@ -165,7 +167,7 @@ const UML_RETURN_PATTERN = /^(\w+\([^)]*\))\s*:\s*(.+)$/;
165
167
 
166
168
  // Note patterns — "note: text", "note right of API: text", "note left of User"
167
169
  const NOTE_SINGLE = /^note(?:\s+(right|left)\s+of\s+(\S+))?\s*:\s*(.+)$/i;
168
- const NOTE_MULTI = /^note(?:\s+(right|left)\s+of\s+(\S+))?\s*$/i;
170
+ const NOTE_MULTI = /^note(?:\s+(right|left)\s+of\s+([^\s:]+))?\s*:?\s*$/i;
169
171
 
170
172
  /**
171
173
  * Extract return label from a message label string.
@@ -226,6 +228,7 @@ function measureIndent(line: string): number {
226
228
  export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
227
229
  const result: ParsedSequenceDgmo = {
228
230
  title: null,
231
+ titleLineNumber: null,
229
232
  participants: [],
230
233
  messages: [],
231
234
  elements: [],
@@ -366,6 +369,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
366
369
 
367
370
  if (key === 'title') {
368
371
  result.title = value;
372
+ result.titleLineNumber = lineNumber;
369
373
  continue;
370
374
  }
371
375
 
@@ -647,15 +651,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
647
651
  (noteSingleMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
648
652
  let noteParticipant = noteSingleMatch[2] || null;
649
653
  if (!noteParticipant) {
650
- if (!lastMsgFrom) {
651
- result.error = `Line ${lineNumber}: note requires a preceding message`;
652
- return result;
653
- }
654
+ if (!lastMsgFrom) continue; // incomplete — skip during live typing
654
655
  noteParticipant = lastMsgFrom;
655
656
  }
656
657
  if (!result.participants.some((p) => p.id === noteParticipant)) {
657
- result.error = `Line ${lineNumber}: note references unknown participant '${noteParticipant}'`;
658
- return result;
658
+ continue; // unknown participant skip during live typing
659
659
  }
660
660
  const note: SequenceNote = {
661
661
  kind: 'note',
@@ -663,6 +663,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
663
663
  position: notePosition,
664
664
  participantId: noteParticipant,
665
665
  lineNumber,
666
+ endLineNumber: lineNumber,
666
667
  };
667
668
  currentContainer().push(note);
668
669
  continue;
@@ -675,15 +676,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
675
676
  (noteMultiMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
676
677
  let noteParticipant = noteMultiMatch[2] || null;
677
678
  if (!noteParticipant) {
678
- if (!lastMsgFrom) {
679
- result.error = `Line ${lineNumber}: note requires a preceding message`;
680
- return result;
681
- }
679
+ if (!lastMsgFrom) continue; // incomplete — skip during live typing
682
680
  noteParticipant = lastMsgFrom;
683
681
  }
684
682
  if (!result.participants.some((p) => p.id === noteParticipant)) {
685
- result.error = `Line ${lineNumber}: note references unknown participant '${noteParticipant}'`;
686
- return result;
683
+ continue; // unknown participant skip during live typing
687
684
  }
688
685
  // Collect indented body lines
689
686
  const noteLines: string[] = [];
@@ -696,16 +693,14 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
696
693
  noteLines.push(nextTrimmed);
697
694
  i++;
698
695
  }
699
- if (noteLines.length === 0) {
700
- result.error = `Line ${lineNumber}: multi-line note has no content — add indented lines or use 'note: text'`;
701
- return result;
702
- }
696
+ if (noteLines.length === 0) continue; // no body yet — skip during live typing
703
697
  const note: SequenceNote = {
704
698
  kind: 'note',
705
699
  text: noteLines.join('\n'),
706
700
  position: notePosition,
707
701
  participantId: noteParticipant,
708
702
  lineNumber,
703
+ endLineNumber: i + 1, // i has advanced past the body lines (1-based)
709
704
  };
710
705
  currentContainer().push(note);
711
706
  continue;
@@ -52,14 +52,16 @@ interface InlineSpan {
52
52
 
53
53
  function parseInlineMarkdown(text: string): InlineSpan[] {
54
54
  const spans: InlineSpan[] = [];
55
- const regex = /\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|\[(.+?)\]\((.+?)\)|([^*`[]+)/g;
55
+ const regex = /\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)|([^*_`[]+)/g;
56
56
  let match;
57
57
  while ((match = regex.exec(text)) !== null) {
58
- if (match[1]) spans.push({ text: match[1], bold: true });
59
- else if (match[2]) spans.push({ text: match[2], italic: true });
60
- else if (match[3]) spans.push({ text: match[3], code: true });
61
- else if (match[4]) spans.push({ text: match[4], href: match[5] });
62
- else if (match[6]) spans.push({ text: match[6] });
58
+ if (match[1]) spans.push({ text: match[1], bold: true }); // **bold**
59
+ else if (match[2]) spans.push({ text: match[2], bold: true }); // __bold__
60
+ else if (match[3]) spans.push({ text: match[3], italic: true }); // *italic*
61
+ else if (match[4]) spans.push({ text: match[4], italic: true }); // _italic_
62
+ else if (match[5]) spans.push({ text: match[5], code: true }); // `code`
63
+ else if (match[6]) spans.push({ text: match[6], href: match[7] }); // [text](url)
64
+ else if (match[8]) spans.push({ text: match[8] });
63
65
  }
64
66
  return spans;
65
67
  }
@@ -915,6 +917,38 @@ export function renderSequenceDiagram(
915
917
  markBlockSpacing(elements);
916
918
  }
917
919
 
920
+ // Note spacing — add vertical room after messages that have notes attached
921
+ const NOTE_OFFSET_BELOW = 16; // gap between message arrow and top of note box
922
+ const computeNoteHeight = (text: string): number => {
923
+ const lines = wrapTextLines(text, NOTE_CHARS_PER_LINE);
924
+ return lines.length * NOTE_LINE_H + NOTE_PAD_V * 2;
925
+ };
926
+ const markNoteSpacing = (els: SequenceElement[]): void => {
927
+ for (let i = 0; i < els.length; i++) {
928
+ const el = els[i];
929
+ if (isSequenceNote(el)) {
930
+ const noteH = computeNoteHeight(el.text);
931
+ // Find the next non-note element after this note
932
+ const nextIdx =
933
+ i + 1 < els.length ? findFirstMsgIndex([els[i + 1]]) : -1;
934
+ if (nextIdx >= 0) {
935
+ addExtra(nextIdx, noteH + NOTE_OFFSET_BELOW);
936
+ }
937
+ } else if (isSequenceBlock(el)) {
938
+ markNoteSpacing(el.children);
939
+ if (el.elseIfBranches) {
940
+ for (const branch of el.elseIfBranches) {
941
+ markNoteSpacing(branch.children);
942
+ }
943
+ }
944
+ markNoteSpacing(el.elseChildren);
945
+ }
946
+ }
947
+ };
948
+ if (elements && elements.length > 0) {
949
+ markNoteSpacing(elements);
950
+ }
951
+
918
952
  // --- Section-aware Y layout ---
919
953
  // Sections get their own Y positions computed from content above them (not anchored
920
954
  // to messages below). This ensures toggling collapse/expand doesn't move the divider.
@@ -1195,8 +1229,9 @@ export function renderSequenceDiagram(
1195
1229
 
1196
1230
  // Render title
1197
1231
  if (title) {
1198
- svg
1232
+ const titleEl = svg
1199
1233
  .append('text')
1234
+ .attr('class', 'chart-title')
1200
1235
  .attr('x', svgWidth / 2)
1201
1236
  .attr('y', 30)
1202
1237
  .attr('text-anchor', 'middle')
@@ -1204,6 +1239,10 @@ export function renderSequenceDiagram(
1204
1239
  .attr('font-size', 20)
1205
1240
  .attr('font-weight', 'bold')
1206
1241
  .text(title);
1242
+
1243
+ if (parsed.titleLineNumber) {
1244
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
1245
+ }
1207
1246
  }
1208
1247
 
1209
1248
  // Render group boxes (behind participant shapes)
@@ -1865,14 +1904,15 @@ export function renderSequenceDiagram(
1865
1904
  const noteX = isRight
1866
1905
  ? px + ACTIVATION_WIDTH + NOTE_GAP
1867
1906
  : px - ACTIVATION_WIDTH - NOTE_GAP - noteW;
1868
- const noteTopY = noteY - noteH / 2;
1907
+ const noteTopY = noteY + NOTE_OFFSET_BELOW;
1869
1908
 
1870
1909
  // Wrap in <g> with data attributes for toggle support
1871
1910
  const noteG = svg
1872
1911
  .append('g')
1873
1912
  .attr('class', 'note')
1874
1913
  .attr('data-note-toggle', '')
1875
- .attr('data-line-number', String(el.lineNumber));
1914
+ .attr('data-line-number', String(el.lineNumber))
1915
+ .attr('data-line-end', String(el.endLineNumber));
1876
1916
 
1877
1917
  // Folded-corner path
1878
1918
  noteG
@@ -1909,35 +1949,32 @@ export function renderSequenceDiagram(
1909
1949
  .attr('stroke-width', 0.75)
1910
1950
  .attr('class', 'note-fold');
1911
1951
 
1912
- // Dashed connector to lifeline
1913
- const connectorNoteX = isRight ? noteX : noteX + noteW;
1914
- const connectorLifeX = isRight
1915
- ? px + ACTIVATION_WIDTH / 2
1916
- : px - ACTIVATION_WIDTH / 2;
1917
- noteG
1918
- .append('line')
1919
- .attr('x1', connectorNoteX)
1920
- .attr('y1', noteY)
1921
- .attr('x2', connectorLifeX)
1922
- .attr('y2', noteY)
1923
- .attr('stroke', palette.textMuted)
1924
- .attr('stroke-width', 0.75)
1925
- .attr('stroke-dasharray', '3 2')
1926
- .attr('class', 'note-connector');
1927
-
1928
1952
  // Render text with inline markdown
1929
1953
  wrappedLines.forEach((line, li) => {
1930
1954
  const textY =
1931
1955
  noteTopY + NOTE_PAD_V + (li + 1) * NOTE_LINE_H - 3;
1956
+ const isBullet = line.startsWith('- ');
1957
+ const bulletIndent = isBullet ? 10 : 0;
1958
+ const displayLine = isBullet ? line.slice(2) : line;
1932
1959
  const textEl = noteG
1933
1960
  .append('text')
1934
- .attr('x', noteX + NOTE_PAD_H)
1961
+ .attr('x', noteX + NOTE_PAD_H + bulletIndent)
1935
1962
  .attr('y', textY)
1936
1963
  .attr('fill', palette.text)
1937
1964
  .attr('font-size', NOTE_FONT_SIZE)
1938
1965
  .attr('class', 'note-text');
1939
1966
 
1940
- const spans = parseInlineMarkdown(line);
1967
+ if (isBullet) {
1968
+ noteG
1969
+ .append('text')
1970
+ .attr('x', noteX + NOTE_PAD_H)
1971
+ .attr('y', textY)
1972
+ .attr('fill', palette.text)
1973
+ .attr('font-size', NOTE_FONT_SIZE)
1974
+ .text('\u2022');
1975
+ }
1976
+
1977
+ const spans = parseInlineMarkdown(displayLine);
1941
1978
  for (const span of spans) {
1942
1979
  if (span.href) {
1943
1980
  const a = textEl