@diagrammo/dgmo 0.2.6 → 0.2.8

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.
@@ -11,9 +11,10 @@ import type {
11
11
  SequenceElement,
12
12
  SequenceGroup,
13
13
  SequenceMessage,
14
+ SequenceNote,
14
15
  SequenceParticipant,
15
16
  } from './parser';
16
- import { isSequenceBlock, isSequenceSection } from './parser';
17
+ import { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';
17
18
 
18
19
  // ============================================================
19
20
  // Layout Constants
@@ -30,6 +31,64 @@ const MESSAGE_START_OFFSET = 30;
30
31
  const LIFELINE_TAIL = 30;
31
32
  const ARROWHEAD_SIZE = 8;
32
33
 
34
+ // Note rendering constants
35
+ const NOTE_MAX_W = 200;
36
+ const NOTE_FOLD = 10;
37
+ const NOTE_PAD_H = 8;
38
+ const NOTE_PAD_V = 6;
39
+ const NOTE_FONT_SIZE = 10;
40
+ const NOTE_LINE_H = 14;
41
+ const NOTE_GAP = 15;
42
+ const NOTE_CHAR_W = 6;
43
+ const NOTE_CHARS_PER_LINE = Math.floor((NOTE_MAX_W - NOTE_PAD_H * 2 - NOTE_FOLD) / NOTE_CHAR_W);
44
+
45
+ interface InlineSpan {
46
+ text: string;
47
+ bold?: boolean;
48
+ italic?: boolean;
49
+ code?: boolean;
50
+ href?: string;
51
+ }
52
+
53
+ function parseInlineMarkdown(text: string): InlineSpan[] {
54
+ const spans: InlineSpan[] = [];
55
+ const regex = /\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)|([^*_`[]+)/g;
56
+ let match;
57
+ while ((match = regex.exec(text)) !== null) {
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] });
65
+ }
66
+ return spans;
67
+ }
68
+
69
+ function wrapTextLines(text: string, maxChars: number): string[] {
70
+ const rawLines = text.split('\n');
71
+ const wrapped: string[] = [];
72
+ for (const line of rawLines) {
73
+ if (line.length <= maxChars) {
74
+ wrapped.push(line);
75
+ } else {
76
+ const words = line.split(' ');
77
+ let current = '';
78
+ for (const word of words) {
79
+ if (current && (current + ' ' + word).length > maxChars) {
80
+ wrapped.push(current);
81
+ current = word;
82
+ } else {
83
+ current = current ? current + ' ' + word : word;
84
+ }
85
+ }
86
+ if (current) wrapped.push(current);
87
+ }
88
+ }
89
+ return wrapped;
90
+ }
91
+
33
92
  // Mix two hex colors in sRGB: pct% of a, rest of b
34
93
  function mix(a: string, b: string, pct: number): string {
35
94
  const parse = (h: string) => {
@@ -440,11 +499,16 @@ export function groupMessagesBySection(
440
499
  ...collectIndices(el.children),
441
500
  ...collectIndices(el.elseChildren)
442
501
  );
443
- } else if (isSequenceSection(el)) {
444
- // Sections inside blocks are not top-level — skip
502
+ if (el.elseIfBranches) {
503
+ for (const branch of el.elseIfBranches) {
504
+ indices.push(...collectIndices(branch.children));
505
+ }
506
+ }
507
+ } else if (isSequenceSection(el) || isSequenceNote(el)) {
508
+ // Sections and notes inside blocks are not messages — skip
445
509
  continue;
446
510
  } else {
447
- const idx = messages.indexOf(el);
511
+ const idx = messages.indexOf(el as SequenceMessage);
448
512
  if (idx >= 0) indices.push(idx);
449
513
  }
450
514
  }
@@ -460,8 +524,8 @@ export function groupMessagesBySection(
460
524
  // Collect messages from this element into the current group
461
525
  if (isSequenceBlock(el)) {
462
526
  currentGroup.messageIndices.push(...collectIndices([el]));
463
- } else {
464
- const idx = messages.indexOf(el);
527
+ } else if (!isSequenceNote(el)) {
528
+ const idx = messages.indexOf(el as SequenceMessage);
465
529
  if (idx >= 0) currentGroup.messageIndices.push(idx);
466
530
  }
467
531
  }
@@ -793,8 +857,16 @@ export function renderSequenceDiagram(
793
857
  if (isSequenceBlock(el)) {
794
858
  const idx = findFirstMsgIndex(el.children);
795
859
  if (idx >= 0) return idx;
796
- } else if (!isSequenceSection(el)) {
797
- const idx = messages.indexOf(el);
860
+ if (el.elseIfBranches) {
861
+ for (const branch of el.elseIfBranches) {
862
+ const branchIdx = findFirstMsgIndex(branch.children);
863
+ if (branchIdx >= 0) return branchIdx;
864
+ }
865
+ }
866
+ const elseIdx = findFirstMsgIndex(el.elseChildren);
867
+ if (elseIdx >= 0) return elseIdx;
868
+ } else if (!isSequenceSection(el) && !isSequenceNote(el)) {
869
+ const idx = messages.indexOf(el as SequenceMessage);
798
870
  if (idx >= 0 && !hiddenMsgIndices.has(idx)) return idx;
799
871
  }
800
872
  }
@@ -820,6 +892,14 @@ export function renderSequenceDiagram(
820
892
  const firstIdx = findFirstMsgIndex(el.children);
821
893
  if (firstIdx >= 0) addExtra(firstIdx, BLOCK_HEADER_SPACE);
822
894
 
895
+ if (el.elseIfBranches) {
896
+ for (const branch of el.elseIfBranches) {
897
+ const firstBranchIdx = findFirstMsgIndex(branch.children);
898
+ if (firstBranchIdx >= 0) addExtra(firstBranchIdx, BLOCK_HEADER_SPACE);
899
+ markBlockSpacing(branch.children);
900
+ }
901
+ }
902
+
823
903
  const firstElseIdx = findFirstMsgIndex(el.elseChildren);
824
904
  if (firstElseIdx >= 0) addExtra(firstElseIdx, BLOCK_HEADER_SPACE);
825
905
 
@@ -837,6 +917,38 @@ export function renderSequenceDiagram(
837
917
  markBlockSpacing(elements);
838
918
  }
839
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
+
840
952
  // --- Section-aware Y layout ---
841
953
  // Sections get their own Y positions computed from content above them (not anchored
842
954
  // to messages below). This ensures toggling collapse/expand doesn't move the divider.
@@ -856,16 +968,28 @@ export function renderSequenceDiagram(
856
968
  for (const child of block.children) {
857
969
  if (isSequenceBlock(child)) {
858
970
  indices.push(...collectMsgIndicesFromBlock(child));
859
- } else if (!isSequenceSection(child)) {
860
- const idx = messages.indexOf(child);
971
+ } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
972
+ const idx = messages.indexOf(child as SequenceMessage);
861
973
  if (idx >= 0) indices.push(idx);
862
974
  }
863
975
  }
976
+ if (block.elseIfBranches) {
977
+ for (const branch of block.elseIfBranches) {
978
+ for (const child of branch.children) {
979
+ if (isSequenceBlock(child)) {
980
+ indices.push(...collectMsgIndicesFromBlock(child));
981
+ } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
982
+ const idx = messages.indexOf(child as SequenceMessage);
983
+ if (idx >= 0) indices.push(idx);
984
+ }
985
+ }
986
+ }
987
+ }
864
988
  for (const child of block.elseChildren) {
865
989
  if (isSequenceBlock(child)) {
866
990
  indices.push(...collectMsgIndicesFromBlock(child));
867
- } else if (!isSequenceSection(child)) {
868
- const idx = messages.indexOf(child);
991
+ } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
992
+ const idx = messages.indexOf(child as SequenceMessage);
869
993
  if (idx >= 0) indices.push(idx);
870
994
  }
871
995
  }
@@ -881,7 +1005,7 @@ export function renderSequenceDiagram(
881
1005
  } else if (isSequenceBlock(el)) {
882
1006
  currentTarget.push(...collectMsgIndicesFromBlock(el));
883
1007
  } else {
884
- const idx = messages.indexOf(el);
1008
+ const idx = messages.indexOf(el as SequenceMessage);
885
1009
  if (idx >= 0) currentTarget.push(idx);
886
1010
  }
887
1011
  }
@@ -1212,8 +1336,13 @@ export function renderSequenceDiagram(
1212
1336
  ...collectMsgIndices(el.children),
1213
1337
  ...collectMsgIndices(el.elseChildren)
1214
1338
  );
1215
- } else if (!isSequenceSection(el)) {
1216
- const idx = messages.indexOf(el);
1339
+ if (el.elseIfBranches) {
1340
+ for (const branch of el.elseIfBranches) {
1341
+ indices.push(...collectMsgIndices(branch.children));
1342
+ }
1343
+ }
1344
+ } else if (!isSequenceSection(el) && !isSequenceNote(el)) {
1345
+ const idx = messages.indexOf(el as SequenceMessage);
1217
1346
  if (idx >= 0) indices.push(idx);
1218
1347
  }
1219
1348
  }
@@ -1242,8 +1371,21 @@ export function renderSequenceDiagram(
1242
1371
  if (!isSequenceBlock(el)) continue;
1243
1372
 
1244
1373
  const ifIndices = collectMsgIndices(el.children);
1374
+ const elseIfBranchData: { label: string; indices: number[] }[] = [];
1375
+ if (el.elseIfBranches) {
1376
+ for (const branch of el.elseIfBranches) {
1377
+ elseIfBranchData.push({
1378
+ label: branch.label,
1379
+ indices: collectMsgIndices(branch.children),
1380
+ });
1381
+ }
1382
+ }
1245
1383
  const elseIndices = collectMsgIndices(el.elseChildren);
1246
- const allIndices = [...ifIndices, ...elseIndices];
1384
+ const allIndices = [
1385
+ ...ifIndices,
1386
+ ...elseIfBranchData.flatMap((b) => b.indices),
1387
+ ...elseIndices,
1388
+ ];
1247
1389
  if (allIndices.length === 0) continue;
1248
1390
 
1249
1391
  // Find render step range
@@ -1308,6 +1450,34 @@ export function renderSequenceDiagram(
1308
1450
  blockLine: el.lineNumber,
1309
1451
  });
1310
1452
 
1453
+ // Else-if dividers
1454
+ for (const branchData of elseIfBranchData) {
1455
+ if (branchData.indices.length > 0) {
1456
+ let firstBranchStep = Infinity;
1457
+ for (const mi of branchData.indices) {
1458
+ const first = msgToFirstStep.get(mi);
1459
+ if (first !== undefined)
1460
+ firstBranchStep = Math.min(firstBranchStep, first);
1461
+ }
1462
+ if (firstBranchStep < Infinity) {
1463
+ const dividerY = stepY(firstBranchStep) - stepSpacing / 2;
1464
+ deferredLines.push({
1465
+ x1: frameX,
1466
+ y1: dividerY,
1467
+ x2: frameX + frameW,
1468
+ y2: dividerY,
1469
+ });
1470
+ deferredLabels.push({
1471
+ x: frameX + 6,
1472
+ y: dividerY + 14,
1473
+ text: `else if ${branchData.label}`,
1474
+ bold: false,
1475
+ italic: true,
1476
+ });
1477
+ }
1478
+ }
1479
+ }
1480
+
1311
1481
  // Else divider
1312
1482
  if (elseIndices.length > 0) {
1313
1483
  let firstElseStep = Infinity;
@@ -1336,6 +1506,11 @@ export function renderSequenceDiagram(
1336
1506
 
1337
1507
  // Recurse into nested blocks
1338
1508
  renderBlockFrames(el.children, depth + 1);
1509
+ if (el.elseIfBranches) {
1510
+ for (const branch of el.elseIfBranches) {
1511
+ renderBlockFrames(branch.children, depth + 1);
1512
+ }
1513
+ }
1339
1514
  renderBlockFrames(el.elseChildren, depth + 1);
1340
1515
  }
1341
1516
  };
@@ -1681,6 +1856,157 @@ export function renderSequenceDiagram(
1681
1856
  }
1682
1857
  }
1683
1858
  });
1859
+
1860
+ // Render notes — folded-corner boxes attached to participant lifelines
1861
+ const noteFill = isDark
1862
+ ? mix(palette.surface, palette.bg, 50)
1863
+ : mix(palette.bg, palette.surface, 15);
1864
+
1865
+ const findAssociatedStep = (note: SequenceNote): number => {
1866
+ let bestMsgIndex = -1;
1867
+ let bestLine = -1;
1868
+ for (let mi = 0; mi < messages.length; mi++) {
1869
+ if (
1870
+ messages[mi].lineNumber < note.lineNumber &&
1871
+ messages[mi].lineNumber > bestLine &&
1872
+ !hiddenMsgIndices.has(mi)
1873
+ ) {
1874
+ bestLine = messages[mi].lineNumber;
1875
+ bestMsgIndex = mi;
1876
+ }
1877
+ }
1878
+ if (bestMsgIndex < 0) return -1;
1879
+ return msgToFirstStep.get(bestMsgIndex) ?? -1;
1880
+ };
1881
+
1882
+ const renderNoteElements = (els: SequenceElement[]): void => {
1883
+ for (const el of els) {
1884
+ if (isSequenceNote(el)) {
1885
+ const px = participantX.get(el.participantId);
1886
+ if (px === undefined) continue;
1887
+ const si = findAssociatedStep(el);
1888
+ if (si < 0) continue;
1889
+ const noteY = stepY(si);
1890
+
1891
+ const wrappedLines = wrapTextLines(el.text, NOTE_CHARS_PER_LINE);
1892
+ const noteH = wrappedLines.length * NOTE_LINE_H + NOTE_PAD_V * 2;
1893
+ const maxLineLen = Math.max(...wrappedLines.map((l) => l.length));
1894
+ const noteW = Math.min(
1895
+ NOTE_MAX_W,
1896
+ Math.max(80, maxLineLen * NOTE_CHAR_W + NOTE_PAD_H * 2 + NOTE_FOLD)
1897
+ );
1898
+ const isRight = el.position === 'right';
1899
+ const noteX = isRight
1900
+ ? px + ACTIVATION_WIDTH + NOTE_GAP
1901
+ : px - ACTIVATION_WIDTH - NOTE_GAP - noteW;
1902
+ const noteTopY = noteY + NOTE_OFFSET_BELOW;
1903
+
1904
+ // Wrap in <g> with data attributes for toggle support
1905
+ const noteG = svg
1906
+ .append('g')
1907
+ .attr('class', 'note')
1908
+ .attr('data-note-toggle', '')
1909
+ .attr('data-line-number', String(el.lineNumber))
1910
+ .attr('data-line-end', String(el.endLineNumber));
1911
+
1912
+ // Folded-corner path
1913
+ noteG
1914
+ .append('path')
1915
+ .attr(
1916
+ 'd',
1917
+ [
1918
+ `M ${noteX} ${noteTopY}`,
1919
+ `L ${noteX + noteW - NOTE_FOLD} ${noteTopY}`,
1920
+ `L ${noteX + noteW} ${noteTopY + NOTE_FOLD}`,
1921
+ `L ${noteX + noteW} ${noteTopY + noteH}`,
1922
+ `L ${noteX} ${noteTopY + noteH}`,
1923
+ 'Z',
1924
+ ].join(' ')
1925
+ )
1926
+ .attr('fill', noteFill)
1927
+ .attr('stroke', palette.textMuted)
1928
+ .attr('stroke-width', 0.75)
1929
+ .attr('class', 'note-box');
1930
+
1931
+ // Fold triangle
1932
+ noteG
1933
+ .append('path')
1934
+ .attr(
1935
+ 'd',
1936
+ [
1937
+ `M ${noteX + noteW - NOTE_FOLD} ${noteTopY}`,
1938
+ `L ${noteX + noteW - NOTE_FOLD} ${noteTopY + NOTE_FOLD}`,
1939
+ `L ${noteX + noteW} ${noteTopY + NOTE_FOLD}`,
1940
+ ].join(' ')
1941
+ )
1942
+ .attr('fill', 'none')
1943
+ .attr('stroke', palette.textMuted)
1944
+ .attr('stroke-width', 0.75)
1945
+ .attr('class', 'note-fold');
1946
+
1947
+ // Render text with inline markdown
1948
+ wrappedLines.forEach((line, li) => {
1949
+ const textY =
1950
+ noteTopY + NOTE_PAD_V + (li + 1) * NOTE_LINE_H - 3;
1951
+ const isBullet = line.startsWith('- ');
1952
+ const bulletIndent = isBullet ? 10 : 0;
1953
+ const displayLine = isBullet ? line.slice(2) : line;
1954
+ const textEl = noteG
1955
+ .append('text')
1956
+ .attr('x', noteX + NOTE_PAD_H + bulletIndent)
1957
+ .attr('y', textY)
1958
+ .attr('fill', palette.text)
1959
+ .attr('font-size', NOTE_FONT_SIZE)
1960
+ .attr('class', 'note-text');
1961
+
1962
+ if (isBullet) {
1963
+ noteG
1964
+ .append('text')
1965
+ .attr('x', noteX + NOTE_PAD_H)
1966
+ .attr('y', textY)
1967
+ .attr('fill', palette.text)
1968
+ .attr('font-size', NOTE_FONT_SIZE)
1969
+ .text('\u2022');
1970
+ }
1971
+
1972
+ const spans = parseInlineMarkdown(displayLine);
1973
+ for (const span of spans) {
1974
+ if (span.href) {
1975
+ const a = textEl
1976
+ .append('a')
1977
+ .attr('href', span.href);
1978
+ a.append('tspan')
1979
+ .text(span.text)
1980
+ .attr('fill', palette.primary)
1981
+ .style('text-decoration', 'underline');
1982
+ } else {
1983
+ const tspan = textEl
1984
+ .append('tspan')
1985
+ .text(span.text);
1986
+ if (span.bold) tspan.attr('font-weight', 'bold');
1987
+ if (span.italic) tspan.attr('font-style', 'italic');
1988
+ if (span.code)
1989
+ tspan
1990
+ .attr('font-family', 'monospace')
1991
+ .attr('font-size', NOTE_FONT_SIZE - 1);
1992
+ }
1993
+ }
1994
+ });
1995
+ } else if (isSequenceBlock(el)) {
1996
+ renderNoteElements(el.children);
1997
+ if (el.elseIfBranches) {
1998
+ for (const branch of el.elseIfBranches) {
1999
+ renderNoteElements(branch.children);
2000
+ }
2001
+ }
2002
+ renderNoteElements(el.elseChildren);
2003
+ }
2004
+ }
2005
+ };
2006
+
2007
+ if (elements && elements.length > 0) {
2008
+ renderNoteElements(elements);
2009
+ }
1684
2010
  }
1685
2011
 
1686
2012
  function renderParticipant(