@diagrammo/dgmo 0.8.22 → 0.8.23

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.
Files changed (53) hide show
  1. package/dist/cli.cjs +111 -109
  2. package/dist/editor.cjs +3 -0
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.js +3 -0
  5. package/dist/editor.js.map +1 -1
  6. package/dist/highlight.cjs +3 -0
  7. package/dist/highlight.cjs.map +1 -1
  8. package/dist/highlight.js +3 -0
  9. package/dist/highlight.js.map +1 -1
  10. package/dist/index.cjs +1010 -215
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +97 -11
  13. package/dist/index.d.ts +97 -11
  14. package/dist/index.js +1001 -213
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal.cjs +380 -0
  17. package/dist/internal.cjs.map +1 -0
  18. package/dist/internal.d.cts +179 -0
  19. package/dist/internal.d.ts +179 -0
  20. package/dist/internal.js +337 -0
  21. package/dist/internal.js.map +1 -0
  22. package/docs/guide/chart-cycle.md +156 -0
  23. package/docs/guide/chart-journey-map.md +179 -0
  24. package/docs/guide/chart-pyramid.md +111 -0
  25. package/docs/guide/registry.json +5 -0
  26. package/docs/language-reference.md +62 -1
  27. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  28. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  29. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  30. package/package.json +11 -1
  31. package/src/cli.ts +5 -35
  32. package/src/completion.ts +9 -44
  33. package/src/cycle/layout.ts +19 -28
  34. package/src/cycle/renderer.ts +59 -32
  35. package/src/cycle/types.ts +21 -0
  36. package/src/d3.ts +21 -1
  37. package/src/dgmo-router.ts +73 -3
  38. package/src/echarts.ts +1 -1
  39. package/src/editor/keywords.ts +3 -0
  40. package/src/index.ts +13 -2
  41. package/src/infra/parser.ts +2 -2
  42. package/src/internal.ts +16 -0
  43. package/src/journey-map/renderer.ts +112 -47
  44. package/src/org/collapse.ts +81 -0
  45. package/src/org/renderer.ts +212 -4
  46. package/src/pyramid/parser.ts +172 -0
  47. package/src/pyramid/renderer.ts +684 -0
  48. package/src/pyramid/types.ts +28 -0
  49. package/src/render.ts +2 -8
  50. package/src/sequence/parser.ts +62 -20
  51. package/src/sequence/renderer.ts +2 -2
  52. package/src/tech-radar/interactive.ts +54 -0
  53. package/src/utils/parsing.ts +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/utils/parsing.ts","../src/chart.ts","../src/kanban/mutations.ts","../src/sequence/renderer.ts","../src/sequence/parser.ts"],"sourcesContent":["/**\n * Shared parser utilities — extracted from individual parsers to eliminate\n * duplication of measureIndent, extractColor, header regexes, and\n * pipe-metadata parsing.\n */\n\nimport { resolveColor, resolveColorWithDiagnostic } from '../colors';\nimport type { DgmoError } from '../diagnostics';\nimport type { PaletteColors } from '../palettes';\n\n// ── All known chart types ────────────────────────────────────\n/** Complete set of recognized chart type identifiers. */\nexport const ALL_CHART_TYPES = new Set([\n // data charts\n 'bar',\n 'line',\n 'pie',\n 'doughnut',\n 'area',\n 'polar-area',\n 'radar',\n 'bar-stacked',\n 'multi-line',\n 'scatter',\n 'sankey',\n 'chord',\n 'function',\n 'heatmap',\n 'funnel',\n // visualizations\n 'slope',\n 'wordcloud',\n 'arc',\n 'timeline',\n 'venn',\n 'quadrant',\n // diagrams\n 'sequence',\n 'flowchart',\n 'class',\n 'er',\n 'org',\n 'kanban',\n 'c4',\n 'state',\n 'sitemap',\n 'infra',\n 'gantt',\n 'boxes-and-lines',\n 'mindmap',\n 'wireframe',\n 'tech-radar',\n 'cycle',\n 'journey-map',\n 'pyramid',\n]);\n\n/** Measure leading whitespace of a line, normalizing tabs to 4 spaces. */\nexport function measureIndent(line: string): number {\n let indent = 0;\n for (const ch of line) {\n if (ch === ' ') indent++;\n else if (ch === '\\t') indent += 4;\n else break;\n }\n return indent;\n}\n\n/** Matches a trailing `(colorName)` suffix on a label. */\nexport const COLOR_SUFFIX_RE = /\\(([^)]+)\\)\\s*$/;\n\n/** Extract an optional trailing color suffix from a label, resolving via palette. */\nexport function extractColor(\n label: string,\n palette?: PaletteColors,\n diagnostics?: DgmoError[],\n line?: number\n): { label: string; color?: string } {\n const m = label.match(COLOR_SUFFIX_RE);\n if (!m) return { label };\n const colorName = m[1].trim();\n let color: string | undefined;\n if (diagnostics && line !== undefined) {\n color = resolveColorWithDiagnostic(colorName, line, diagnostics, palette);\n } else {\n color = resolveColor(colorName, palette) ?? undefined;\n }\n return {\n label: label.substring(0, m.index!).trim(),\n color,\n };\n}\n\n/** Matches `option value` header lines (space-separated, no colon). */\nexport const OPTION_NOCOLON_RE = /^([a-z][a-z0-9-]*)\\s+(.+)$/i;\n\n// ── New shared utilities ─────────────────────────────────────\n\n/**\n * Parse the first non-empty, non-comment line to extract chart type and optional title.\n * The first token is matched against `ALL_CHART_TYPES`; the remainder is the title.\n *\n * Returns `null` if the first token is not a recognized chart type.\n */\nexport function parseFirstLine(\n line: string\n): { chartType: string; title: string | undefined } | null {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith('//')) return null;\n\n // First token is chart type, rest is title\n const spaceIdx = trimmed.indexOf(' ');\n if (spaceIdx === -1) {\n const ct = trimmed.toLowerCase();\n return ALL_CHART_TYPES.has(ct) ? { chartType: ct, title: undefined } : null;\n }\n const firstToken = trimmed.substring(0, spaceIdx).toLowerCase();\n if (!ALL_CHART_TYPES.has(firstToken)) return null;\n return {\n chartType: firstToken,\n title: trimmed.substring(spaceIdx + 1).trim() || undefined,\n };\n}\n\n/** Result of `prescanOptions()` — options collected from a two-pass scan. */\ninterface PrescanResult {\n /** Key-value options, e.g., `direction LR` → `{ direction: 'LR' }` */\n options: Record<string, string>;\n /** Presence-based boolean options, e.g., `critical-path` → Set('critical-path') */\n booleans: Set<string>;\n /** Negated booleans, e.g., `no-dependencies` → Set('dependencies') */\n negated: Set<string>;\n}\n\n/**\n * Pre-scan all lines to collect options that can appear anywhere in the file.\n *\n * For each non-indented, non-comment line:\n * - If the first token is a known option key and the line has more tokens → key-value option\n * - If the first token is a known boolean key (bare keyword) → boolean enabled\n * - If the first token starts with `no-` and the rest is a known boolean → negated\n *\n * Comment handling: full comment lines (`// ...`) are skipped. Inline comments\n * are stripped before extraction (`direction LR // override` → option `direction: LR`).\n *\n * @param lines All lines of the document\n * @param knownOptions Set of recognized option key names (e.g., `direction`, `start`, `notation`)\n * @param knownBooleans Set of recognized boolean option names (e.g., `critical-path`, `animate`)\n */\nexport function prescanOptions(\n lines: string[],\n knownOptions: Set<string>,\n knownBooleans: Set<string> = new Set()\n): PrescanResult {\n const options: Record<string, string> = {};\n const booleans = new Set<string>();\n const negated = new Set<string>();\n\n for (const raw of lines) {\n // Skip indented lines — these are content, not top-level options\n if (raw.length > 0 && (raw[0] === ' ' || raw[0] === '\\t')) continue;\n\n const trimmed = raw.trim();\n if (!trimmed || trimmed.startsWith('//')) continue;\n\n // Strip inline comments\n const commentIdx = trimmed.indexOf(' //');\n const effective =\n commentIdx >= 0 ? trimmed.substring(0, commentIdx).trim() : trimmed;\n if (!effective) continue;\n\n // Extract first token\n const spaceIdx = effective.indexOf(' ');\n const firstToken = (\n spaceIdx === -1 ? effective : effective.substring(0, spaceIdx)\n ).toLowerCase();\n\n // Check for bare boolean (presence = on)\n if (spaceIdx === -1 && knownBooleans.has(firstToken)) {\n booleans.add(firstToken);\n continue;\n }\n\n // Check for negated boolean: `no-X` where X is a known boolean\n if (spaceIdx === -1 && firstToken.startsWith('no-')) {\n const base = firstToken.substring(3);\n if (knownBooleans.has(base)) {\n negated.add(base);\n continue;\n }\n }\n\n // Check for boolean with a value (e.g., `today-marker 2026-03-26`) —\n // must come before pure key-value check so booleans flag is also set\n if (spaceIdx !== -1 && knownBooleans.has(firstToken)) {\n booleans.add(firstToken);\n options[firstToken] = effective.substring(spaceIdx + 1).trim();\n continue;\n }\n\n // Check for key-value option\n if (spaceIdx !== -1 && knownOptions.has(firstToken)) {\n options[firstToken] = effective.substring(spaceIdx + 1).trim();\n continue;\n }\n }\n\n return { options, booleans, negated };\n}\n\n/**\n * Normalize a numeric token with visual separators (commas or underscores) to a plain number string.\n *\n * Supported formats:\n * - Comma-grouped integers: `1,000` → `'1000'`, `1,234,567` → `'1234567'` (strict 3-digit grouping)\n * - Comma-grouped decimals: `1,234.56` → `'1234.56'`\n * - Underscore-separated integers: `1_000` → `'1000'`, `10_00_000` → `'1000000'` (any grouping)\n * - Underscore-separated decimals: `1_234.56` → `'1234.56'` (no underscores in decimal part)\n * - Negatives: `-1,000` → `'-1000'`, `-1_000` → `'-1000'`\n *\n * Returns `null` if:\n * - Token has no commas or underscores (caller should use raw token as-is)\n * - Token has BOTH commas and underscores (mixed separators rejected)\n * - Token has separators but doesn't match any valid pattern\n */\nexport function normalizeNumericToken(token: string): string | null {\n // No separators → null (caller uses raw token)\n if (!token.includes(',') && !token.includes('_')) return null;\n // Mixed separators → rejected\n if (token.includes(',') && token.includes('_')) return null;\n\n // Strip optional leading minus sign\n let sign = '';\n let unsigned = token;\n if (unsigned.startsWith('-')) {\n sign = '-';\n unsigned = unsigned.substring(1);\n }\n if (!unsigned) return null;\n\n if (unsigned.includes(',')) {\n // Comma-grouped integers: 1,000 or 1,234,567\n if (/^\\d{1,3}(,\\d{3})+$/.test(unsigned))\n return sign + unsigned.replace(/,/g, '');\n // Comma-grouped decimals: 1,234.56\n if (/^\\d{1,3}(,\\d{3})+\\.\\d+$/.test(unsigned))\n return sign + unsigned.replace(/,/g, '');\n return null;\n }\n\n // Underscore-separated integers: 1_000, 10_00_000\n if (/^\\d+(_\\d+)+$/.test(unsigned)) return sign + unsigned.replace(/_/g, '');\n // Underscore-separated decimals: 1_234.56 (no underscores in decimal part)\n if (/^\\d+(_\\d+)*\\.\\d+$/.test(unsigned) && unsigned.includes('_'))\n return sign + unsigned.replace(/_/g, '');\n return null;\n}\n\n/**\n * Strip surrounding quotes (`\"` or `'`) from a token.\n * Returns the unquoted content, or the original string if not quoted.\n */\nexport function stripQuotes(token: string): string {\n if (token.length >= 2) {\n if (\n (token[0] === '\"' && token[token.length - 1] === '\"') ||\n (token[0] === \"'\" && token[token.length - 1] === \"'\")\n ) {\n return token.substring(1, token.length - 1);\n }\n }\n return token;\n}\n\n/**\n * Quote-aware tokenizer — splits a string by whitespace but keeps quoted\n * substrings (`\"double\"` or `'single'`) as single tokens.\n * Quotes are preserved in the output tokens — call `stripQuotes()` to remove them.\n */\nexport function tokenizeQuoteAware(input: string): string[] {\n const tokens: string[] = [];\n let i = 0;\n while (i < input.length) {\n // Skip whitespace\n if (input[i] === ' ' || input[i] === '\\t') {\n i++;\n continue;\n }\n\n // Quoted token\n if (input[i] === '\"' || input[i] === \"'\") {\n const quote = input[i];\n const start = i;\n i++; // skip opening quote\n while (i < input.length && input[i] !== quote) i++;\n if (i < input.length) i++; // skip closing quote\n tokens.push(input.substring(start, i));\n continue;\n }\n\n // Unquoted token\n const start = i;\n while (i < input.length && input[i] !== ' ' && input[i] !== '\\t') i++;\n tokens.push(input.substring(start, i));\n }\n return tokens;\n}\n\n/**\n * Collect indented continuation lines as individual values.\n * Used when a property like `series:` has an empty value — subsequent\n * indented lines each become one value entry.\n *\n * - Skips blank lines and `//` comment lines within the block\n * - Stops at first non-indented non-empty line (or EOF)\n * - Strips trailing commas from values (user habit tolerance)\n * - Returns `newIndex` so caller does `i = newIndex` and the loop's `i++` lands correctly\n */\nexport function collectIndentedValues(\n lines: string[],\n startIndex: number\n): { values: string[]; lineNumbers: number[]; newIndex: number } {\n const values: string[] = [];\n const lineNumbers: number[] = [];\n let j = startIndex + 1;\n for (; j < lines.length; j++) {\n const raw = lines[j];\n const trimmed = raw.trim();\n // Skip blank lines within the block\n if (!trimmed) continue;\n // Skip comment lines within the block\n if (trimmed.startsWith('//')) continue;\n // Stop at non-indented lines (first char is not whitespace)\n if (raw[0] !== ' ' && raw[0] !== '\\t') break;\n // Strip trailing comma and collect\n values.push(trimmed.replace(/,\\s*$/, ''));\n lineNumbers.push(j + 1); // 1-based\n }\n return { values, lineNumbers, newIndex: j - 1 };\n}\n\n/**\n * Parse series names from a `series:` value or indented block, extracting\n * optional per-name color suffixes. Shared between chart.ts and echarts.ts.\n *\n * Returns the parsed names, optional colors, and the raw series string\n * (for single-series display), plus `newIndex` if indented values were consumed.\n */\nexport function parseSeriesNames(\n value: string,\n lines: string[],\n lineIndex: number,\n palette?: PaletteColors,\n diagnostics?: DgmoError[]\n): {\n series: string;\n names: string[];\n nameColors: (string | undefined)[];\n nameLineNumbers: number[];\n newIndex: number;\n} {\n let rawNames: string[];\n let series: string;\n let newIndex = lineIndex;\n let nameLineNumbers: number[] = []; // eslint-disable-line no-useless-assignment\n if (value) {\n series = value;\n rawNames = value\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n // Inline series names all share the same line number\n nameLineNumbers = rawNames.map(() => lineIndex + 1);\n } else {\n const collected = collectIndentedValues(lines, lineIndex);\n newIndex = collected.newIndex;\n rawNames = collected.values;\n nameLineNumbers = collected.lineNumbers;\n series = rawNames.join(', ');\n }\n const names: string[] = [];\n const nameColors: (string | undefined)[] = [];\n for (let i = 0; i < rawNames.length; i++) {\n const raw = rawNames[i];\n const extracted = extractColor(\n raw,\n palette,\n diagnostics,\n nameLineNumbers[i]\n );\n nameColors.push(extracted.color);\n names.push(extracted.label);\n }\n if (names.length === 1) {\n series = names[0];\n }\n return { series, names, nameColors, nameLineNumbers, newIndex };\n}\n\n/**\n * Infer arrow color from label text.\n * Returns a named palette color or undefined if no inference applies.\n * Case-insensitive, exact match only (not prefix/substring).\n */\nexport function inferArrowColor(label: string): string | undefined {\n const lower = label.toLowerCase();\n // Green: positive/affirmative\n if (\n lower === 'yes' ||\n lower === 'success' ||\n lower === 'ok' ||\n lower === 'true'\n )\n return 'green';\n // Red: negative/failure\n if (\n lower === 'no' ||\n lower === 'fail' ||\n lower === 'error' ||\n lower === 'false'\n )\n return 'red';\n // Orange: uncertain/warning\n if (lower === 'maybe' || lower === 'warning') return 'orange';\n return undefined;\n}\n\n/** Error message for multiple pipes on a single line. */\nexport const MULTIPLE_PIPE_ERROR =\n 'Use a single \"|\" to start metadata, then separate items with commas.';\n\n/**\n * Parse metadata from segments after the first (name) segment.\n * A single `|` separates the label from metadata; items after the pipe are comma-delimited.\n * Multiple pipes produce an error.\n */\nexport function parsePipeMetadata(\n segments: string[],\n aliasMap: Map<string, string> = new Map(),\n errorMultiplePipes?: () => void\n): Record<string, string> {\n if (segments.length > 2) {\n if (errorMultiplePipes) errorMultiplePipes();\n return {};\n }\n const metadata: Record<string, string> = {};\n const raw = segments.slice(1).join(',');\n for (const part of raw.split(',')) {\n const trimmedPart = part.trim();\n if (!trimmedPart) continue;\n const colonIdx = trimmedPart.indexOf(':');\n if (colonIdx > 0) {\n const rawKey = trimmedPart.substring(0, colonIdx).trim().toLowerCase();\n const key = aliasMap.get(rawKey) ?? rawKey;\n const value = trimmedPart.substring(colonIdx + 1).trim();\n metadata[key] = value;\n }\n }\n return metadata;\n}\n","// ============================================================\n// Types\n// ============================================================\n\nexport type ChartType =\n | 'bar'\n | 'line'\n | 'pie'\n | 'doughnut'\n | 'area'\n | 'polar-area'\n | 'radar'\n | 'bar-stacked';\n\nexport interface ChartDataPoint {\n label: string;\n value: number;\n extraValues?: number[];\n color?: string;\n lineNumber: number;\n}\n\nexport interface ChartEra {\n start: string; // exact category label, e.g. \"'77\"\n end: string; // exact category label, e.g. \"'81\"\n label: string; // display name, e.g. \"Carter\"\n color: string | null; // resolved CSS color, or null → palette default\n lineNumber: number;\n}\n\nimport type { DgmoError } from './diagnostics';\n\nexport interface ParsedChart {\n type: ChartType;\n title?: string;\n titleLineNumber?: number;\n series?: string;\n seriesLineNumber?: number;\n xlabel?: string;\n xlabelLineNumber?: number;\n ylabel?: string;\n ylabelLineNumber?: number;\n seriesNames?: string[];\n seriesNameLineNumbers?: number[];\n seriesNameColors?: (string | undefined)[];\n orientation?: 'horizontal' | 'vertical';\n color?: string;\n label?: string;\n noLabelName?: boolean;\n noLabelValue?: boolean;\n noLabelPercent?: boolean;\n data: ChartDataPoint[];\n eras?: ChartEra[];\n diagnostics: DgmoError[];\n error: string | null;\n}\n\n// ============================================================\n// Colors\n// ============================================================\n\nimport { resolveColorWithDiagnostic } from './colors';\nimport type { PaletteColors } from './palettes';\nimport { makeDgmoError, formatDgmoError, suggest } from './diagnostics';\nimport {\n extractColor,\n normalizeNumericToken,\n parseFirstLine,\n parseSeriesNames,\n} from './utils/parsing';\n\n// ============================================================\n// Parser\n// ============================================================\n\nconst VALID_TYPES = new Set<ChartType>([\n 'bar',\n 'line',\n 'pie',\n 'doughnut',\n 'area',\n 'polar-area',\n 'radar',\n 'bar-stacked',\n]);\n\nconst TYPE_ALIASES: Record<string, ChartType> = {\n 'multi-line': 'line',\n};\n\n/** Known option keywords for the simple chart parser. */\nconst KNOWN_OPTIONS = new Set([\n 'chart',\n 'title',\n 'series',\n 'x-label',\n 'y-label',\n 'label',\n 'no-label-name',\n 'no-label-value',\n 'no-label-percent',\n 'color',\n]);\n\n/** Known boolean options for the simple chart parser. */\nconst KNOWN_BOOLEANS = new Set(['orientation-horizontal']);\n\n/**\n * Parses the simple chart text format into a structured object.\n *\n * Format (colon-free):\n * ```\n * bar My Chart\n * series Revenue\n *\n * Jan 120\n * Feb 200\n * Mar 150\n * ```\n */\nexport function parseChart(\n content: string,\n palette?: PaletteColors\n): ParsedChart {\n const lines = content.split('\\n');\n const parsedEras: ChartEra[] = [];\n const rawEras: {\n start: string;\n afterArrow: string;\n color: string | null;\n lineNumber: number;\n }[] = [];\n const result: ParsedChart = {\n type: 'bar',\n data: [],\n eras: parsedEras,\n diagnostics: [],\n error: null,\n };\n\n const fail = (line: number, message: string): ParsedChart => {\n const diag = makeDgmoError(line, message);\n result.diagnostics.push(diag);\n result.error = formatDgmoError(diag);\n return result;\n };\n\n let firstLineParsed = false;\n\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i].trim();\n const lineNumber = i + 1;\n\n // Skip empty lines\n if (!trimmed) continue;\n\n // Reject legacy ## section headers\n if (/^#{2,}\\s+/.test(trimmed)) {\n result.diagnostics.push(\n makeDgmoError(\n lineNumber,\n `'${trimmed}' — ## syntax is no longer supported. Use [Group] containers instead`\n )\n );\n continue;\n }\n\n // Skip comments\n if (trimmed.startsWith('//')) continue;\n\n // First non-empty, non-comment line: chart type + optional title\n if (!firstLineParsed) {\n firstLineParsed = true;\n const firstLine = parseFirstLine(trimmed);\n if (firstLine) {\n const raw = firstLine.chartType.toLowerCase();\n const chartType = (TYPE_ALIASES[raw] ?? raw) as ChartType;\n if (VALID_TYPES.has(chartType)) {\n result.type = chartType;\n if (firstLine.title) {\n result.title = firstLine.title;\n result.titleLineNumber = lineNumber;\n }\n continue;\n } else {\n let msg = `Unsupported chart type: ${firstLine.chartType}. Supported types: ${[...VALID_TYPES].join(', ')}.`;\n const hint = suggest(raw, [...VALID_TYPES]);\n if (hint) msg += ` ${hint}`;\n return fail(lineNumber, msg);\n }\n }\n // If the first line is a single word (no spaces, no colon, no numbers),\n // treat it as an unrecognized chart type rather than falling through\n if (\n !trimmed.includes(' ') &&\n !trimmed.includes(':') &&\n !/\\d/.test(trimmed)\n ) {\n let msg = `Unsupported chart type: ${trimmed}. Supported types: ${[...VALID_TYPES].join(', ')}.`;\n const hint = suggest(trimmed.toLowerCase(), [...VALID_TYPES]);\n if (hint) msg += ` ${hint}`;\n return fail(lineNumber, msg);\n }\n // Fall through — first line might be a data row or option\n }\n\n // Era line: era Day 1 -> Day 3 Rough Seas (blue) — colon-free\n const eraMatch = trimmed.match(\n /^era\\s+(.+?)\\s*->\\s*(.+?)(?:\\s*\\(([^)]+)\\))?\\s*$/\n );\n if (eraMatch) {\n // Store start and raw afterArrow — resolved against data labels after parsing\n const afterArrow = eraMatch[2].trim();\n const spaceIdx = afterArrow.indexOf(' ');\n if (spaceIdx >= 0) {\n rawEras.push({\n start: eraMatch[1].trim(),\n afterArrow,\n color: eraMatch[3]\n ? (resolveColorWithDiagnostic(\n eraMatch[3].trim(),\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null,\n lineNumber,\n });\n }\n continue;\n }\n\n // Extract first token to check for known options\n const spaceIdx = trimmed.indexOf(' ');\n const firstToken = (\n spaceIdx >= 0 ? trimmed.substring(0, spaceIdx) : trimmed\n ).toLowerCase();\n\n // Bare boolean options (e.g. orientation-horizontal)\n if (KNOWN_BOOLEANS.has(firstToken) && spaceIdx < 0) {\n if (firstToken === 'orientation-horizontal') {\n result.orientation = 'horizontal';\n }\n continue;\n }\n\n // Known option with a value\n if (KNOWN_OPTIONS.has(firstToken) && spaceIdx >= 0) {\n const value = trimmed.substring(spaceIdx + 1).trim();\n\n if (firstToken === 'chart') {\n const raw = value.toLowerCase();\n const chartType = (TYPE_ALIASES[raw] ?? raw) as ChartType;\n if (VALID_TYPES.has(chartType)) {\n result.type = chartType;\n } else {\n let msg = `Unsupported chart type: ${value}. Supported types: ${[...VALID_TYPES].join(', ')}.`;\n const hint = suggest(raw, [...VALID_TYPES]);\n if (hint) msg += ` ${hint}`;\n return fail(lineNumber, msg);\n }\n continue;\n }\n\n if (firstToken === 'title') {\n result.title = value;\n result.titleLineNumber = lineNumber;\n continue;\n }\n\n if (firstToken === 'x-label') {\n result.xlabel = value;\n result.xlabelLineNumber = lineNumber;\n continue;\n }\n\n if (firstToken === 'y-label') {\n result.ylabel = value;\n result.ylabelLineNumber = lineNumber;\n continue;\n }\n\n if (firstToken === 'label') {\n result.label = value;\n continue;\n }\n\n if (firstToken === 'color') {\n result.color = resolveColorWithDiagnostic(\n value.trim(),\n lineNumber,\n result.diagnostics,\n palette\n );\n continue;\n }\n\n if (firstToken === 'series') {\n const parsed = parseSeriesNames(\n value,\n lines,\n i,\n palette,\n result.diagnostics\n );\n i = parsed.newIndex;\n result.series = parsed.series;\n result.seriesLineNumber = lineNumber;\n if (parsed.names.length > 1) {\n result.seriesNames = parsed.names;\n result.seriesNameLineNumbers = parsed.nameLineNumbers;\n }\n if (parsed.nameColors.some(Boolean))\n result.seriesNameColors = parsed.nameColors;\n continue;\n }\n }\n\n // Bare boolean options: no-label-name, no-label-value, no-label-percent\n if (firstToken === 'no-label-name') {\n result.noLabelName = true;\n continue;\n }\n if (firstToken === 'no-label-value') {\n result.noLabelValue = true;\n continue;\n }\n if (firstToken === 'no-label-percent') {\n result.noLabelPercent = true;\n continue;\n }\n\n // Bare \"series\" keyword with no value — collect indented names\n if (firstToken === 'series' && spaceIdx === -1) {\n const parsed = parseSeriesNames('', lines, i, palette);\n i = parsed.newIndex;\n result.series = parsed.series;\n result.seriesLineNumber = lineNumber;\n if (parsed.names.length > 1) {\n result.seriesNames = parsed.names;\n result.seriesNameLineNumbers = parsed.nameLineNumbers;\n }\n if (parsed.nameColors.some(Boolean))\n result.seriesNameColors = parsed.nameColors;\n continue;\n }\n\n // Data row: parse from the right — rightmost numeric token(s) = value(s), everything left = label\n // Supports comma-separated multi-values: \"Jan 100, 200, 300\"\n // Supports space-separated multi-values when series are defined: \"Jan 100 200 300\"\n // Supports comma-grouped numbers: \"Revenue 1,200, 1,500\" → [1200, 1500]\n const seriesCount = result.seriesNames?.length ?? 0;\n const multiValue = seriesCount >= 2;\n const dataValues = parseDataRowValues(trimmed, {\n multiValue,\n expectedValues: multiValue ? seriesCount : undefined,\n });\n if (dataValues) {\n const { label: rawLabel, color: pointColor } = extractColor(\n dataValues.label,\n palette\n );\n const [first, ...rest] = dataValues.values;\n result.data.push({\n label: rawLabel,\n value: first,\n ...(rest.length > 0 && { extraValues: rest }),\n ...(pointColor && { color: pointColor }),\n lineNumber,\n });\n continue;\n }\n\n // Catch-all: nothing matched this line\n let msg = `Unexpected line: '${trimmed}'.`;\n const hint = suggest(firstToken, [...KNOWN_OPTIONS, ...KNOWN_BOOLEANS]);\n if (hint) msg += ` ${hint}`;\n result.diagnostics.push(makeDgmoError(lineNumber, msg, 'warning'));\n }\n\n // Resolve raw eras against known data labels (longest-prefix match for multi-word labels)\n const knownLabels = new Set(result.data.map((d) => d.label));\n for (const raw of rawEras) {\n // Find the longest prefix of afterArrow that matches a known label\n const words = raw.afterArrow.split(' ');\n let end = '';\n let label = '';\n let matched = false;\n for (let w = words.length - 1; w >= 1; w--) {\n const candidateEnd = words.slice(0, w).join(' ');\n if (knownLabels.has(candidateEnd)) {\n end = candidateEnd;\n label = words.slice(w).join(' ');\n matched = true;\n break;\n }\n }\n if (!matched) {\n // Fallback: first token = end, rest = label\n end = words[0];\n label = words.slice(1).join(' ');\n }\n parsedEras.push({\n start: raw.start,\n end,\n label,\n color: raw.color,\n lineNumber: raw.lineNumber,\n });\n }\n\n // Eras are only valid for line, multi-line (aliased to 'line'), and area chart types\n if (result.type !== 'line' && result.type !== 'area') {\n result.eras = undefined;\n }\n\n // Validation\n const setChartError = (line: number, message: string) => {\n const diag = makeDgmoError(line, message);\n result.diagnostics.push(diag);\n result.error = formatDgmoError(diag);\n };\n\n const warn = (line: number, message: string): void => {\n result.diagnostics.push(makeDgmoError(line, message, 'warning'));\n };\n\n if (!result.error && result.data.length === 0) {\n warn(1, 'No data points found. Add data in format: Label 123');\n }\n\n if (!result.error && result.type === 'bar-stacked' && !result.seriesNames) {\n setChartError(\n 1,\n 'Chart type \"bar-stacked\" requires multiple series names. Use: series Name1, Name2, Name3'\n );\n }\n\n if (!result.error && result.seriesNames) {\n const expectedCount = result.seriesNames.length;\n for (const dp of result.data) {\n const actualCount = 1 + (dp.extraValues?.length ?? 0);\n if (actualCount !== expectedCount) {\n warn(\n dp.lineNumber,\n `Data point \"${dp.label}\" has ${actualCount} value(s), but ${expectedCount} series defined. Each row must have ${expectedCount} values.`\n );\n }\n }\n // Filter out mismatched data points so renderers get clean data\n result.data = result.data.filter((dp) => {\n const actualCount = 1 + (dp.extraValues?.length ?? 0);\n return actualCount === expectedCount;\n });\n }\n\n return result;\n}\n\n// ============================================================\n// Data Row Parser\n// ============================================================\n\n/**\n * Parse a data row line: everything before the last numeric token(s) is the label,\n * numeric tokens at the end are the values. Supports comma-separated multi-values,\n * space-separated multi-values, and comma-grouped numbers (e.g., \"1,087\").\n *\n * Examples:\n * \"Jan 120\" → { label: \"Jan\", values: [120] }\n * \"North America 250\" → { label: \"North America\", values: [250] }\n * \"Q1 10, 20, 30\" → { label: \"Q1\", values: [10, 20, 30] }\n * \"Q1 10 20 30\" → { label: \"Q1\", values: [10, 20, 30] }\n * \"Revenue 1,200\" → { label: \"Revenue\", values: [1200] }\n * \"Revenue 3,984,078.65\"→ { label: \"Revenue\", values: [3984078.65] }\n *\n * Returns null if the line has no numeric value at the end.\n */\nexport function parseDataRowValues(\n line: string,\n options?: { multiValue?: boolean; expectedValues?: number }\n): { label: string; values: number[] } | null {\n // First, normalize comma-grouped numbers: replace patterns like \"1,087\" with \"1087\"\n // We need to be careful: commas also separate multi-values.\n // Strategy: tokenize by commas, normalize grouped numbers, then re-parse.\n\n // Split by comma to get segments\n const segments = line.split(',');\n\n // Normalize each segment: if a segment (trimmed) matches grouped number pattern,\n // merge it with the previous segment\n const normalized: string[] = [];\n for (let i = 0; i < segments.length; i++) {\n const seg = segments[i].trim();\n // Check if this segment is a continuation of a grouped number.\n // A continuation starts with exactly 3 digits (possibly followed by a decimal like \".65\")\n // and follows a segment ending in digits.\n // Grouped numbers have NO space around the comma (e.g., \"1,087\"), so skip if\n // the raw segment has leading whitespace (e.g., \", 350\" is a value separator).\n if (i > 0 && /^\\d{3}(\\.\\d+)?$/.test(seg) && !/^\\s/.test(segments[i])) {\n const prevSeg = normalized[normalized.length - 1].trimEnd();\n // Check if previous segment ends with a number (1-3 digits at the end of the last token)\n if (/\\d{1,3}$/.test(prevSeg)) {\n // Check if the combined token would be a valid grouped number\n // Extract the trailing number from prev\n const prevMatch = prevSeg.match(/(\\d{1,3})$/);\n if (prevMatch) {\n // Tentatively merge and validate\n // Build full token by looking at what's left in normalized\n // Simple approach: just merge\n normalized[normalized.length - 1] = prevSeg + seg;\n continue;\n }\n }\n }\n normalized.push(segments[i]);\n }\n\n const rebuilt = normalized.join(',');\n\n // Now check for comma-separated values at the end\n // Strategy: find where the label ends and values begin\n // Values are comma-separated numeric tokens at the end of the line\n\n // Try splitting by comma first — if the line has commas, the last comma-separated tokens\n // that are all numeric form the values\n const commaParts = rebuilt.split(',');\n if (commaParts.length > 1) {\n // Find how many trailing comma-separated parts are numeric\n let numericCount = 0;\n for (let j = commaParts.length - 1; j >= 0; j--) {\n const part =\n normalizeNumericToken(commaParts[j].trim()) ?? commaParts[j].trim();\n if (part && !isNaN(parseFloat(part)) && isFinite(Number(part))) {\n numericCount++;\n } else {\n break;\n }\n }\n if (numericCount > 0) {\n // Pure numeric trailing comma-parts are extra values.\n // Everything before them (joined by comma) contains \"label firstValue\".\n const splitAt = commaParts.length - numericCount;\n const extraValueParts = commaParts.slice(splitAt);\n const firstPart = commaParts.slice(0, splitAt).join(',').trim();\n\n // Split firstPart from the right: last space-separated token must be numeric\n const lastSpaceIdx = firstPart.lastIndexOf(' ');\n if (lastSpaceIdx >= 0) {\n const rawFirstVal = firstPart.substring(lastSpaceIdx + 1).trim();\n const possibleFirstVal =\n normalizeNumericToken(rawFirstVal) ?? rawFirstVal;\n if (\n possibleFirstVal &&\n !isNaN(parseFloat(possibleFirstVal)) &&\n isFinite(Number(possibleFirstVal))\n ) {\n const label = firstPart.substring(0, lastSpaceIdx).trim();\n if (label) {\n const values = [parseFloat(possibleFirstVal)];\n for (const p of extraValueParts) {\n const normP = normalizeNumericToken(p.trim()) ?? p.trim();\n values.push(parseFloat(normP));\n }\n return { label, values };\n }\n }\n }\n }\n }\n\n // No commas or comma parsing didn't work — split by spaces from right.\n // When multiValue is enabled, walk backward collecting consecutive numeric tokens.\n // Otherwise (default), take only the last token — preserving labels that contain\n // numbers (e.g., \"Region 5 300\" → label \"Region 5\", value 300).\n const tokens = rebuilt.split(/\\s+/);\n if (tokens.length < 2) return null;\n\n if (options?.multiValue) {\n const limit = options.expectedValues ?? Infinity;\n const values: number[] = [];\n let idx = tokens.length - 1;\n while (idx >= 1 && values.length < limit) {\n const tok = tokens[idx];\n const normTok = normalizeNumericToken(tok) ?? tok;\n const num = parseFloat(normTok);\n if (isNaN(num) || !isFinite(Number(normTok))) break;\n values.unshift(num);\n idx--;\n }\n if (values.length === 0) return null;\n const label = tokens.slice(0, idx + 1).join(' ');\n if (!label) return null;\n return { label, values };\n }\n\n // Single-value mode: only the last space-separated token\n const lastToken = tokens[tokens.length - 1];\n const normalizedLast = normalizeNumericToken(lastToken) ?? lastToken;\n const num = parseFloat(normalizedLast);\n if (isNaN(num) || !isFinite(Number(normalizedLast))) return null;\n\n const label = tokens.slice(0, -1).join(' ');\n if (!label) return null;\n\n return { label, values: [num] };\n}\n","import type { ParsedKanban, KanbanCard, KanbanColumn } from './types';\n\nconst ARCHIVE_COLUMN_NAME = 'archive';\n\n// ============================================================\n// computeCardMove — pure function for source text mutation\n// ============================================================\n\n/**\n * Compute new file content after moving a card to a different position.\n *\n * @param content - original file content string\n * @param parsed - parsed kanban board\n * @param cardId - id of the card to move\n * @param targetColumnId - id of the destination column\n * @param targetIndex - position within target column (0 = first card)\n * @returns new content string, or null if move is invalid\n */\nexport function computeCardMove(\n content: string,\n parsed: ParsedKanban,\n cardId: string,\n targetColumnId: string,\n targetIndex: number\n): string | null {\n // Find source card and column\n let sourceCard: KanbanCard | null = null;\n let sourceColumn: KanbanColumn | null = null;\n\n for (const col of parsed.columns) {\n for (const card of col.cards) {\n if (card.id === cardId) {\n sourceCard = card;\n sourceColumn = col;\n break;\n }\n }\n if (sourceCard) break;\n }\n\n if (!sourceCard || !sourceColumn) return null;\n\n const targetColumn = parsed.columns.find((c) => c.id === targetColumnId);\n if (!targetColumn) return null;\n\n const lines = content.split('\\n');\n\n // Extract the card's lines (0-based indices)\n const startIdx = sourceCard.lineNumber - 1;\n const endIdx = sourceCard.endLineNumber - 1;\n const cardLines = lines.slice(startIdx, endIdx + 1);\n\n // Remove the card lines from content\n const withoutCard = [\n ...lines.slice(0, startIdx),\n ...lines.slice(endIdx + 1),\n ];\n\n // Compute insertion point (0-based index in withoutCard)\n let insertIdx: number;\n\n // Adjust target column and card line numbers after removal\n // Lines after the removed range shift up by the number of removed lines\n const removedCount = endIdx - startIdx + 1;\n const adjustLine = (ln: number): number => {\n // ln is 1-based\n if (ln > endIdx + 1) return ln - removedCount;\n return ln;\n };\n\n if (targetIndex === 0) {\n // Insert right after column header line\n const adjColLine = adjustLine(targetColumn.lineNumber);\n insertIdx = adjColLine; // 0-based: insert after the column header line\n } else {\n // Insert after the preceding card's last line\n // Get the cards in the target column, excluding the moved card\n const targetCards = targetColumn.cards.filter((c) => c.id !== cardId);\n const clampedIdx = Math.min(targetIndex, targetCards.length);\n const precedingCard = targetCards[clampedIdx - 1];\n if (!precedingCard) {\n // Fallback: after column header\n const adjColLine = adjustLine(targetColumn.lineNumber);\n insertIdx = adjColLine;\n } else {\n const adjEndLine = adjustLine(precedingCard.endLineNumber);\n insertIdx = adjEndLine; // 0-based: insert after this line\n }\n }\n\n // Splice the card lines into the new position\n const result = [\n ...withoutCard.slice(0, insertIdx),\n ...cardLines,\n ...withoutCard.slice(insertIdx),\n ];\n\n return result.join('\\n');\n}\n\n// ============================================================\n// computeCardArchive — move card to an Archive section\n// ============================================================\n\n/**\n * Move a card to the Archive section at the end of the file.\n * Creates `== Archive ==` if it doesn't exist.\n *\n * @returns new content string, or null if the card is not found\n */\nexport function computeCardArchive(\n content: string,\n parsed: ParsedKanban,\n cardId: string\n): string | null {\n let sourceCard: KanbanCard | null = null;\n for (const col of parsed.columns) {\n for (const card of col.cards) {\n if (card.id === cardId) {\n sourceCard = card;\n break;\n }\n }\n if (sourceCard) break;\n }\n if (!sourceCard) return null;\n\n const lines = content.split('\\n');\n\n // Extract card lines\n const startIdx = sourceCard.lineNumber - 1;\n const endIdx = sourceCard.endLineNumber - 1;\n const cardLines = lines.slice(startIdx, endIdx + 1);\n\n // Remove card from its current position\n const withoutCard = [\n ...lines.slice(0, startIdx),\n ...lines.slice(endIdx + 1),\n ];\n\n // Check if an Archive column already exists\n const archiveCol = parsed.columns.find(\n (c) => c.name.toLowerCase() === ARCHIVE_COLUMN_NAME\n );\n\n if (archiveCol) {\n // Append to existing archive column\n // Find the last line of the archive column (after removal adjustment)\n const removedCount = endIdx - startIdx + 1;\n let archiveEndLine = archiveCol.lineNumber;\n if (archiveCol.cards.length > 0) {\n const lastCard = archiveCol.cards[archiveCol.cards.length - 1];\n archiveEndLine = lastCard.endLineNumber;\n }\n // Adjust for removed lines\n if (archiveEndLine > endIdx + 1) {\n archiveEndLine -= removedCount;\n }\n // Insert after the archive end (0-based in withoutCard)\n const insertIdx = archiveEndLine; // archiveEndLine is 1-based, so this is after that line\n return [\n ...withoutCard.slice(0, insertIdx),\n ...cardLines,\n ...withoutCard.slice(insertIdx),\n ].join('\\n');\n } else {\n // Create archive section at end of file\n // Ensure trailing newline before the new section\n const trimmedEnd = withoutCard.length > 0 && withoutCard[withoutCard.length - 1].trim() === ''\n ? withoutCard\n : [...withoutCard, ''];\n return [\n ...trimmedEnd,\n '[Archive]',\n ...cardLines,\n ].join('\\n');\n }\n}\n\n/** Check if a column name is the archive column (case-insensitive). */\nexport function isArchiveColumn(name: string): boolean {\n return name.toLowerCase() === ARCHIVE_COLUMN_NAME;\n}\n","// ============================================================\n// Sequence Diagram SVG Renderer\n// ============================================================\n\nimport * as d3Selection from 'd3-selection';\nimport type { PaletteColors } from '../palettes';\nimport { mix } from '../palettes/color-utils';\nimport {\n parseInlineMarkdown,\n truncateBareUrl,\n renderInlineText,\n} from '../utils/inline-markdown';\nexport { parseInlineMarkdown, truncateBareUrl };\nimport { FONT_FAMILY } from '../fonts';\nimport type {\n ParsedSequenceDgmo,\n SequenceElement,\n SequenceGroup,\n SequenceMessage,\n SequenceNote,\n SequenceParticipant,\n} from './parser';\nimport { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';\nimport { applyCollapseProjection } from './collapse';\nimport type { CollapsedView } from './collapse';\nimport { resolveSequenceTags } from './tag-resolution';\nimport type { ResolvedTagMap } from './tag-resolution';\nimport { resolveActiveTagGroup } from '../utils/tag-groups';\nimport { LEGEND_HEIGHT } from '../utils/legend-constants';\nimport { renderLegendD3 } from '../utils/legend-d3';\nimport type {\n LegendCallbacks,\n LegendConfig,\n LegendState,\n} from '../utils/legend-types';\nimport { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';\n\n// ============================================================\n// Layout Constants\n// ============================================================\n\nconst PARTICIPANT_GAP = 160;\nconst PARTICIPANT_BOX_WIDTH = 120;\nconst PARTICIPANT_BOX_HEIGHT = 50;\nconst TOP_MARGIN = 20;\nconst TITLE_HEIGHT = 30;\nconst PARTICIPANT_Y_OFFSET = 10;\nconst SERVICE_BORDER_RADIUS = 10;\nconst MESSAGE_START_OFFSET = 30;\nconst LIFELINE_TAIL = 30;\nconst ARROWHEAD_SIZE = 8;\n\n// Note rendering constants\nconst NOTE_MAX_W = 200;\nconst NOTE_FOLD = 10;\nconst NOTE_PAD_H = 8;\nconst NOTE_PAD_V = 6;\nconst NOTE_FONT_SIZE = 10;\nconst NOTE_LINE_H = 14;\nconst NOTE_GAP = 15;\nconst NOTE_CHAR_W = 6;\nconst NOTE_CHARS_PER_LINE = Math.floor(\n (NOTE_MAX_W - NOTE_PAD_H * 2 - NOTE_FOLD) / NOTE_CHAR_W\n);\nconst COLLAPSED_NOTE_H = 20;\nconst COLLAPSED_NOTE_W = 40;\nconst ACTIVATION_WIDTH = 10;\nconst SELF_CALL_HEIGHT = 25;\nconst SELF_CALL_WIDTH = 30;\n// Max note width that keeps a note within one participant lane\nconst NOTE_LANE_MAX = PARTICIPANT_GAP - ACTIVATION_WIDTH - NOTE_GAP; // 135px\n\nfunction wrapTextLines(text: string, maxChars: number): string[] {\n const rawLines = text.split('\\n');\n const wrapped: string[] = [];\n for (const line of rawLines) {\n if (line.length <= maxChars) {\n wrapped.push(line);\n } else {\n // Preserve bullet prefix: keep \"- \" glued to the first content word\n // so wrapping never produces a bare \"-\" line.\n const bulletPrefix = line.startsWith('- ') ? '- ' : '';\n const content = bulletPrefix ? line.slice(2) : line;\n const words = content.split(' ');\n let current = bulletPrefix;\n for (const word of words) {\n const candidate = current ? current + ' ' + word : word;\n if (\n current &&\n current !== bulletPrefix &&\n candidate.length > maxChars\n ) {\n wrapped.push(current);\n current = word;\n } else {\n current =\n current && current !== bulletPrefix\n ? current + ' ' + word\n : current + word;\n }\n }\n if (current) wrapped.push(current);\n }\n }\n return wrapped;\n}\n\n/**\n * Split a participant label into multiple lines if it exceeds the box width.\n * Splits on spaces first, then dashes, then camelCase boundaries.\n * Approximate max chars based on font-size 13 (~7.5px per char average).\n */\nconst LABEL_CHAR_WIDTH = 7.5;\nconst LABEL_MAX_CHARS = Math.floor(\n (PARTICIPANT_BOX_WIDTH - 10) / LABEL_CHAR_WIDTH\n); // ~14 chars\n\nfunction splitParticipantLabel(label: string): string[] {\n if (label.length <= LABEL_MAX_CHARS) return [label];\n\n // Split on spaces\n if (label.includes(' ')) {\n return wrapLabelWords(label.split(' '));\n }\n\n // Split on dashes/underscores\n if (/[-_]/.test(label)) {\n const parts = label.split(/[-_]/);\n return wrapLabelWords(parts);\n }\n\n // Split on camelCase boundaries: \"UserLookupCloudFx\" → [\"User\", \"Lookup\", \"Cloud\", \"Fx\"]\n const camelParts = label\n .replace(/([a-z])([A-Z])/g, '$1\\x00$2')\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1\\x00$2')\n .split('\\x00');\n if (camelParts.length > 1) {\n return wrapLabelWords(camelParts);\n }\n\n return [label];\n}\n\n/** Greedily join word parts into lines that fit within LABEL_MAX_CHARS. */\nfunction wrapLabelWords(words: string[]): string[] {\n const lines: string[] = [];\n let current = '';\n for (const word of words) {\n const test = current ? current + word : word;\n if (test.length > LABEL_MAX_CHARS && current) {\n lines.push(current);\n current = word;\n } else {\n current = test;\n }\n }\n if (current) lines.push(current);\n return lines;\n}\n\n// Shared fill/stroke helpers — accept optional color override for per-participant coloring\nconst fill = (\n palette: PaletteColors,\n isDark: boolean,\n color?: string\n): string =>\n color\n ? mix(color, isDark ? palette.surface : palette.bg, isDark ? 30 : 40)\n : isDark\n ? mix(palette.overlay, palette.surface, 50)\n : mix(palette.bg, palette.surface, 50);\nconst stroke = (palette: PaletteColors, color?: string): string =>\n color || palette.border;\nconst SW = 1.5;\nconst W = PARTICIPANT_BOX_WIDTH;\nconst H = PARTICIPANT_BOX_HEIGHT;\n\n// ============================================================\n// Participant Shape Renderers\n// ============================================================\n\nfunction renderRectParticipant(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n palette: PaletteColors,\n isDark: boolean,\n color?: string\n): void {\n g.append('rect')\n .attr('x', -W / 2)\n .attr('y', 0)\n .attr('width', W)\n .attr('height', H)\n .attr('rx', 2)\n .attr('ry', 2)\n .attr('fill', fill(palette, isDark, color))\n .attr('stroke', stroke(palette, color))\n .attr('stroke-width', SW);\n}\n\nfunction renderServiceParticipant(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n palette: PaletteColors,\n isDark: boolean,\n color?: string\n): void {\n g.append('rect')\n .attr('x', -W / 2)\n .attr('y', 0)\n .attr('width', W)\n .attr('height', H)\n .attr('rx', SERVICE_BORDER_RADIUS)\n .attr('ry', SERVICE_BORDER_RADIUS)\n .attr('fill', fill(palette, isDark, color))\n .attr('stroke', stroke(palette, color))\n .attr('stroke-width', SW);\n}\n\nfunction renderActorParticipant(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n palette: PaletteColors,\n color?: string\n): void {\n // Stick figure — no background, natural proportions\n const headR = 8;\n const cx = 0;\n const headY = headR + 2;\n const bodyTopY = headY + headR + 1;\n const bodyBottomY = H * 0.65;\n const legY = H - 2;\n const armSpan = 16;\n const legSpan = 12;\n const s = stroke(palette, color);\n const actorSW = 2.5;\n\n g.append('circle')\n .attr('cx', cx)\n .attr('cy', headY)\n .attr('r', headR)\n .attr('fill', 'none')\n .attr('stroke', s)\n .attr('stroke-width', actorSW);\n\n g.append('line')\n .attr('x1', cx)\n .attr('y1', bodyTopY)\n .attr('x2', cx)\n .attr('y2', bodyBottomY)\n .attr('stroke', s)\n .attr('stroke-width', actorSW);\n\n g.append('line')\n .attr('x1', cx - armSpan)\n .attr('y1', bodyTopY + 5)\n .attr('x2', cx + armSpan)\n .attr('y2', bodyTopY + 5)\n .attr('stroke', s)\n .attr('stroke-width', actorSW);\n\n g.append('line')\n .attr('x1', cx)\n .attr('y1', bodyBottomY)\n .attr('x2', cx - legSpan)\n .attr('y2', legY)\n .attr('stroke', s)\n .attr('stroke-width', actorSW);\n\n g.append('line')\n .attr('x1', cx)\n .attr('y1', bodyBottomY)\n .attr('x2', cx + legSpan)\n .attr('y2', legY)\n .attr('stroke', s)\n .attr('stroke-width', actorSW);\n}\n\nfunction renderDatabaseParticipant(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n palette: PaletteColors,\n isDark: boolean,\n color?: string\n): void {\n // Cylinder fitting within W x H\n const ry = 7;\n const topY = ry;\n const bodyH = H - ry * 2;\n const f = fill(palette, isDark, color);\n const s = stroke(palette, color);\n\n // Bottom ellipse (drawn first — rect will cover its top arc)\n g.append('ellipse')\n .attr('cx', 0)\n .attr('cy', topY + bodyH)\n .attr('rx', W / 2)\n .attr('ry', ry)\n .attr('fill', f)\n .attr('stroke', s)\n .attr('stroke-width', SW);\n\n // Filled body (no stroke) to hide the top arc of the bottom ellipse\n g.append('rect')\n .attr('class', 'participant-body')\n .attr('x', -W / 2)\n .attr('y', topY)\n .attr('width', W)\n .attr('height', bodyH)\n .attr('fill', f)\n .attr('stroke', 'none');\n\n // Side lines\n g.append('line')\n .attr('x1', -W / 2)\n .attr('y1', topY)\n .attr('x2', -W / 2)\n .attr('y2', topY + bodyH)\n .attr('stroke', s)\n .attr('stroke-width', SW);\n g.append('line')\n .attr('x1', W / 2)\n .attr('y1', topY)\n .attr('x2', W / 2)\n .attr('y2', topY + bodyH)\n .attr('stroke', s)\n .attr('stroke-width', SW);\n\n // Top ellipse cap (drawn last, on top)\n g.append('ellipse')\n .attr('cx', 0)\n .attr('cy', topY)\n .attr('rx', W / 2)\n .attr('ry', ry)\n .attr('fill', f)\n .attr('stroke', s)\n .attr('stroke-width', SW);\n}\n\nfunction renderQueueParticipant(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n palette: PaletteColors,\n isDark: boolean,\n color?: string\n): void {\n // Horizontal cylinder (pipe) — like database rotated 90 degrees\n const rx = 10;\n const leftX = -W / 2 + rx;\n const bodyW = W - rx * 2;\n const f = fill(palette, isDark, color);\n const s = stroke(palette, color);\n\n // Right ellipse (back face, drawn first — rect will cover its left arc)\n g.append('ellipse')\n .attr('cx', leftX + bodyW)\n .attr('cy', H / 2)\n .attr('rx', rx)\n .attr('ry', H / 2)\n .attr('fill', f)\n .attr('stroke', s)\n .attr('stroke-width', SW);\n\n // Body rect (no stroke) to hide left arc of right ellipse\n g.append('rect')\n .attr('class', 'participant-body')\n .attr('x', leftX)\n .attr('y', 0)\n .attr('width', bodyW)\n .attr('height', H)\n .attr('fill', f)\n .attr('stroke', 'none');\n\n // Top and bottom lines\n g.append('line')\n .attr('x1', leftX)\n .attr('y1', 0)\n .attr('x2', leftX + bodyW)\n .attr('y2', 0)\n .attr('stroke', s)\n .attr('stroke-width', SW);\n g.append('line')\n .attr('x1', leftX)\n .attr('y1', H)\n .attr('x2', leftX + bodyW)\n .attr('y2', H)\n .attr('stroke', s)\n .attr('stroke-width', SW);\n\n // Left ellipse (front face, drawn last)\n g.append('ellipse')\n .attr('cx', leftX)\n .attr('cy', H / 2)\n .attr('rx', rx)\n .attr('ry', H / 2)\n .attr('fill', f)\n .attr('stroke', s)\n .attr('stroke-width', SW);\n}\n\nfunction renderCacheParticipant(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n palette: PaletteColors,\n isDark: boolean,\n color?: string\n): void {\n // Dashed cylinder — variation of database to convey ephemeral storage\n const ry = 7;\n const topY = ry;\n const bodyH = H - ry * 2;\n const f = fill(palette, isDark, color);\n const s = stroke(palette, color);\n const dash = '4 3';\n\n // Bottom ellipse (back face)\n g.append('ellipse')\n .attr('cx', 0)\n .attr('cy', topY + bodyH)\n .attr('rx', W / 2)\n .attr('ry', ry)\n .attr('fill', f)\n .attr('stroke', s)\n .attr('stroke-width', SW)\n .attr('stroke-dasharray', dash);\n\n g.append('rect')\n .attr('class', 'participant-body')\n .attr('x', -W / 2)\n .attr('y', topY)\n .attr('width', W)\n .attr('height', bodyH)\n .attr('fill', f)\n .attr('stroke', 'none');\n\n g.append('line')\n .attr('x1', -W / 2)\n .attr('y1', topY)\n .attr('x2', -W / 2)\n .attr('y2', topY + bodyH)\n .attr('stroke', s)\n .attr('stroke-width', SW)\n .attr('stroke-dasharray', dash);\n g.append('line')\n .attr('x1', W / 2)\n .attr('y1', topY)\n .attr('x2', W / 2)\n .attr('y2', topY + bodyH)\n .attr('stroke', s)\n .attr('stroke-width', SW)\n .attr('stroke-dasharray', dash);\n\n // Top ellipse cap\n g.append('ellipse')\n .attr('cx', 0)\n .attr('cy', topY)\n .attr('rx', W / 2)\n .attr('ry', ry)\n .attr('fill', f)\n .attr('stroke', s)\n .attr('stroke-width', SW)\n .attr('stroke-dasharray', dash);\n}\n\nfunction renderNetworkingParticipant(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n palette: PaletteColors,\n isDark: boolean,\n color?: string\n): void {\n // Hexagon fitting within W x H\n const inset = 16;\n const points = [\n `${-W / 2 + inset},0`,\n `${W / 2 - inset},0`,\n `${W / 2},${H / 2}`,\n `${W / 2 - inset},${H}`,\n `${-W / 2 + inset},${H}`,\n `${-W / 2},${H / 2}`,\n ].join(' ');\n g.append('polygon')\n .attr('points', points)\n .attr('fill', fill(palette, isDark, color))\n .attr('stroke', stroke(palette, color))\n .attr('stroke-width', SW);\n}\n\nfunction renderFrontendParticipant(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n palette: PaletteColors,\n isDark: boolean,\n color?: string\n): void {\n // Monitor shape fitting within W x H\n const screenH = H - 10;\n const s = stroke(palette, color);\n g.append('rect')\n .attr('x', -W / 2)\n .attr('y', 0)\n .attr('width', W)\n .attr('height', screenH)\n .attr('rx', 3)\n .attr('ry', 3)\n .attr('fill', fill(palette, isDark, color))\n .attr('stroke', s)\n .attr('stroke-width', SW);\n // Stand\n g.append('line')\n .attr('x1', 0)\n .attr('y1', screenH)\n .attr('x2', 0)\n .attr('y2', H - 2)\n .attr('stroke', s)\n .attr('stroke-width', SW);\n // Base\n g.append('line')\n .attr('x1', -14)\n .attr('y1', H - 2)\n .attr('x2', 14)\n .attr('y2', H - 2)\n .attr('stroke', s)\n .attr('stroke-width', SW);\n}\n\nfunction renderExternalParticipant(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n palette: PaletteColors,\n isDark: boolean,\n color?: string\n): void {\n // Dashed border rectangle\n g.append('rect')\n .attr('x', -W / 2)\n .attr('y', 0)\n .attr('width', W)\n .attr('height', H)\n .attr('rx', 2)\n .attr('ry', 2)\n .attr('fill', fill(palette, isDark, color))\n .attr('stroke', stroke(palette, color))\n .attr('stroke-width', SW)\n .attr('stroke-dasharray', '6 3');\n}\n\nfunction renderGatewayParticipant(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n palette: PaletteColors,\n isDark: boolean,\n color?: string\n): void {\n renderRectParticipant(g, palette, isDark, color);\n}\n\n// ============================================================\n// Collapsible Section Support\n// ============================================================\n\nexport interface SectionMessageGroup {\n section: import('./parser').SequenceSection;\n messageIndices: number[]; // indices into messages[]\n}\n\nexport interface SequenceRenderOptions {\n collapsedSections?: Set<number>; // keyed by section lineNumber\n collapsedGroups?: Set<number>; // keyed by group lineNumber\n expandedNoteLines?: Set<number>; // keyed by note lineNumber; undefined = all expanded (CLI default)\n exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)\n activeTagGroup?: string | null; // Active tag group name for tag-driven recoloring; null = explicitly none\n expandAllNotes?: boolean; // Whether the \"Expand Notes\" toggle is active\n onExpandAllNotes?: (expand: boolean) => void; // Toggle all notes expanded/collapsed\n controlsExpanded?: boolean; // Controls group expanded state (managed by React)\n onToggleControlsExpand?: () => void; // Callback to toggle controls group\n}\n\n/**\n * Group messages by the top-level section that precedes them.\n * Messages before the first section are ungrouped (always visible).\n * Only top-level sections are collapsible — sections inside blocks are excluded.\n */\nexport function groupMessagesBySection(\n elements: SequenceElement[],\n messages: SequenceMessage[]\n): SectionMessageGroup[] {\n const groups: SectionMessageGroup[] = [];\n let currentGroup: SectionMessageGroup | null = null;\n\n // Recursively collect all message indices from an element subtree\n const collectIndices = (els: SequenceElement[]): number[] => {\n const indices: number[] = [];\n for (const el of els) {\n if (isSequenceBlock(el)) {\n indices.push(\n ...collectIndices(el.children),\n ...collectIndices(el.elseChildren)\n );\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n indices.push(...collectIndices(branch.children));\n }\n }\n } else if (isSequenceSection(el) || isSequenceNote(el)) {\n // Sections and notes inside blocks are not messages — skip\n continue;\n } else {\n const idx = messages.indexOf(el as SequenceMessage);\n if (idx >= 0) indices.push(idx);\n }\n }\n return indices;\n };\n\n for (const el of elements) {\n if (isSequenceSection(el)) {\n // Start a new group for this top-level section\n currentGroup = { section: el, messageIndices: [] };\n groups.push(currentGroup);\n } else if (currentGroup) {\n // Collect messages from this element into the current group\n if (isSequenceBlock(el)) {\n currentGroup.messageIndices.push(...collectIndices([el]));\n } else if (!isSequenceNote(el)) {\n const idx = messages.indexOf(el as SequenceMessage);\n if (idx >= 0) currentGroup.messageIndices.push(idx);\n }\n }\n // Messages before the first section are ungrouped — skip\n }\n\n return groups;\n}\n\n// ============================================================\n// Render Sequence Builder (stack-based return placement)\n// ============================================================\n\nexport interface RenderStep {\n type: 'call' | 'return';\n from: string;\n to: string;\n label: string;\n messageIndex: number;\n async?: boolean;\n}\n\n/**\n * Build an ordered render sequence from flat messages.\n * Uses a call stack to infer where returns should be placed:\n * returns appear after all nested sub-calls complete.\n */\nexport function buildRenderSequence(messages: SequenceMessage[]): RenderStep[] {\n const steps: RenderStep[] = [];\n const stack: {\n from: string;\n to: string;\n messageIndex: number;\n }[] = [];\n\n for (let mi = 0; mi < messages.length; mi++) {\n const msg = messages[mi];\n // Pop returns for callees that are no longer the sender\n while (stack.length > 0) {\n const top = stack[stack.length - 1];\n if (top.to === msg.from) break; // callee is still working\n stack.pop();\n steps.push({\n type: 'return',\n from: top.to,\n to: top.from,\n label: '',\n messageIndex: top.messageIndex,\n });\n }\n\n // Emit call\n steps.push({\n type: 'call',\n from: msg.from,\n to: msg.to,\n label: msg.label,\n messageIndex: mi,\n ...(msg.async ? { async: true } : {}),\n });\n\n // Async messages: no return arrow, no activation on target\n if (msg.async) {\n continue;\n }\n\n if (msg.from === msg.to) {\n // Self-call: immediately emit return (completes instantly)\n steps.push({\n type: 'return',\n from: msg.to,\n to: msg.from,\n label: '',\n messageIndex: mi,\n });\n } else {\n // Push onto stack for pending return\n stack.push({\n from: msg.from,\n to: msg.to,\n messageIndex: mi,\n });\n }\n }\n\n // Flush remaining returns\n while (stack.length > 0) {\n const top = stack.pop()!;\n steps.push({\n type: 'return',\n from: top.to,\n to: top.from,\n label: '',\n messageIndex: top.messageIndex,\n });\n }\n\n return steps;\n}\n\n// ============================================================\n// Activation Computation\n// ============================================================\n\nexport interface Activation {\n participantId: string;\n startStep: number;\n endStep: number;\n depth: number;\n}\n\n/**\n * Compute activation rectangles from render steps.\n * Each call pushes onto the callee's stack; each return pops it.\n */\nexport function computeActivations(steps: RenderStep[]): Activation[] {\n const activations: Activation[] = [];\n // Per-participant stack of open activations (step index)\n const stacks = new Map<string, number[]>();\n\n const getStack = (id: string): number[] => {\n if (!stacks.has(id)) stacks.set(id, []);\n return stacks.get(id)!;\n };\n\n for (let i = 0; i < steps.length; i++) {\n const step = steps[i];\n if (step.type === 'call') {\n const s = getStack(step.to);\n s.push(i);\n } else {\n // return: step.from is the callee returning\n const s = getStack(step.from);\n if (s.length > 0) {\n const startIdx = s.pop()!;\n activations.push({\n participantId: step.from,\n startStep: startIdx,\n endStep: i,\n depth: s.length,\n });\n }\n }\n }\n\n return activations;\n}\n\n// ============================================================\n// Position Override Sorting\n// ============================================================\n\n/**\n * Reorder participants based on explicit `position` overrides.\n * Positive positions are 0-based from the left; negative positions count from the right (-1 = last).\n * Unpositioned participants maintain their relative order, filling remaining slots.\n */\nexport function applyPositionOverrides(\n participants: SequenceParticipant[]\n): SequenceParticipant[] {\n if (!participants.some((p) => p.position !== undefined)) return participants;\n\n const total = participants.length;\n const positioned: { participant: SequenceParticipant; index: number }[] = [];\n const unpositioned: SequenceParticipant[] = [];\n\n for (const p of participants) {\n if (p.position !== undefined) {\n // Resolve negative: -1 → last, -2 → second-to-last\n let idx = p.position < 0 ? total + p.position : p.position;\n // Clamp to valid range\n idx = Math.max(0, Math.min(total - 1, idx));\n positioned.push({ participant: p, index: idx });\n } else {\n unpositioned.push(p);\n }\n }\n\n // Sort positioned by target index for deterministic placement\n positioned.sort((a, b) => a.index - b.index);\n\n // Place positioned participants, resolving conflicts by finding nearest free slot\n const result: (SequenceParticipant | null)[] = new Array(total).fill(null);\n const usedIndices = new Set<number>();\n\n for (const { participant, index } of positioned) {\n let idx = index;\n if (usedIndices.has(idx)) {\n // Find nearest free slot\n for (let offset = 1; offset < total; offset++) {\n if (idx + offset < total && !usedIndices.has(idx + offset)) {\n idx = idx + offset;\n break;\n }\n if (idx - offset >= 0 && !usedIndices.has(idx - offset)) {\n idx = idx - offset;\n break;\n }\n }\n }\n result[idx] = participant;\n usedIndices.add(idx);\n }\n\n // Fill remaining slots with unpositioned participants in order\n let uIdx = 0;\n for (let i = 0; i < total; i++) {\n if (result[i] === null) {\n result[i] = unpositioned[uIdx++];\n }\n }\n\n return result as SequenceParticipant[];\n}\n\n// Group Ordering\n// ============================================================\n\n/**\n * Reorder participants so that members of the same group are adjacent.\n * Groups are positioned at the point where their first member would naturally\n * appear based on message order (first-occurrence positioning). This prevents\n * groups declared at the top of the file from being placed before participants\n * that appear in messages earlier.\n *\n * Explicit `position` overrides are handled separately by `applyPositionOverrides`.\n */\nexport function applyGroupOrdering(\n participants: SequenceParticipant[],\n groups: SequenceGroup[],\n messages: SequenceMessage[] = []\n): SequenceParticipant[] {\n if (groups.length === 0) return participants;\n\n // Build a map: participantId → group\n const idToGroup = new Map<string, SequenceGroup>();\n for (const group of groups) {\n for (const id of group.participantIds) {\n idToGroup.set(id, group);\n }\n }\n\n // Build first-appearance index from messages (order in which participants\n // are first referenced). Participants not in any message keep their\n // declaration order from the participants array.\n const appearanceOrder: string[] = [];\n const seen = new Set<string>();\n for (const msg of messages) {\n for (const id of [msg.from, msg.to]) {\n if (!seen.has(id)) {\n seen.add(id);\n appearanceOrder.push(id);\n }\n }\n }\n // Append any participants not referenced in messages (declaration-only)\n for (const p of participants) {\n if (!seen.has(p.id)) {\n seen.add(p.id);\n appearanceOrder.push(p.id);\n }\n }\n\n // Walk appearance order; when we encounter a grouped participant,\n // insert the entire group at that position (if not already placed).\n const result: SequenceParticipant[] = [];\n const placed = new Set<string>();\n const placedGroups = new Set<SequenceGroup>();\n\n for (const id of appearanceOrder) {\n if (placed.has(id)) continue;\n\n const group = idToGroup.get(id);\n if (group && !placedGroups.has(group)) {\n // Place entire group here\n placedGroups.add(group);\n for (const gid of group.participantIds) {\n const p = participants.find((pp) => pp.id === gid);\n if (p && !placed.has(gid)) {\n result.push(p);\n placed.add(gid);\n }\n }\n } else if (!group) {\n // Ungrouped participant\n const p = participants.find((pp) => pp.id === id);\n if (p) {\n result.push(p);\n placed.add(id);\n }\n }\n // If group already placed, skip (member already included)\n }\n\n return result;\n}\n\n// Main Renderer\n// ============================================================\n\n/**\n * Render a sequence diagram into the given container element.\n */\nexport function renderSequenceDiagram(\n container: HTMLDivElement,\n parsed: ParsedSequenceDgmo,\n palette: PaletteColors,\n isDark: boolean,\n _onNavigateToLine?: (line: number) => void,\n options?: SequenceRenderOptions\n): void {\n // Clear previous content\n d3Selection.select(container).selectAll('*').remove();\n\n const { title, options: parsedOptions } = parsed;\n\n // Compute effective collapsed groups: union of syntax-declared and runtime-toggled\n const effectiveCollapsedGroups = new Set<number>();\n for (const group of parsed.groups) {\n if (group.collapsed) effectiveCollapsedGroups.add(group.lineNumber);\n }\n if (options?.collapsedGroups) {\n for (const ln of options.collapsedGroups) {\n // Toggle: if already in the set (from syntax), remove it (user expanded);\n // if not in the set, add it (user collapsed)\n if (effectiveCollapsedGroups.has(ln)) {\n effectiveCollapsedGroups.delete(ln);\n } else {\n effectiveCollapsedGroups.add(ln);\n }\n }\n }\n\n // Apply collapse projection before participant ordering\n const collapsed: CollapsedView | null =\n effectiveCollapsedGroups.size > 0\n ? applyCollapseProjection(parsed, effectiveCollapsedGroups)\n : null;\n\n const messages = collapsed ? collapsed.messages : parsed.messages;\n const elements = collapsed ? collapsed.elements : parsed.elements;\n const groups = collapsed ? collapsed.groups : parsed.groups;\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n const collapsedGroupIds = collapsed?.collapsedGroupIds ?? new Map();\n\n const collapsedSections = options?.collapsedSections;\n const expandedNoteLines = options?.expandedNoteLines;\n const collapseNotesDisabled =\n parsedOptions['collapse-notes']?.toLowerCase() === 'no';\n // A note is expanded if: expandedNoteLines is undefined (CLI/export),\n // collapse-notes: no is set, or the note's lineNumber is in the set.\n const isNoteExpanded = (note: SequenceNote): boolean =>\n expandedNoteLines === undefined ||\n collapseNotesDisabled ||\n expandedNoteLines.has(note.lineNumber);\n\n const sourceParticipants = collapsed\n ? collapsed.participants\n : parsed.participants;\n const participants = applyPositionOverrides(\n applyGroupOrdering(sourceParticipants, groups, messages)\n );\n if (participants.length === 0) return;\n\n // Participant index lookup — used to clamp note width within one lane\n const participantIndexMap = new Map<string, number>();\n participants.forEach((p, i) => participantIndexMap.set(p.id, i));\n\n // Extra X shift for notes after self-calls\n const SELF_CALL_NOTE_X_SHIFT =\n ACTIVATION_WIDTH / 2 +\n SELF_CALL_WIDTH +\n NOTE_GAP -\n (ACTIVATION_WIDTH + NOTE_GAP); // 25px\n\n const noteEffectiveMaxW = (\n participantId: string,\n position: 'right' | 'left',\n afterSelfCall = false\n ): number => {\n const idx = participantIndexMap.get(participantId);\n if (idx === undefined) return NOTE_MAX_W;\n const hasNeighbor =\n position === 'right' ? idx < participants.length - 1 : idx > 0;\n if (!hasNeighbor) return NOTE_MAX_W;\n const laneMax =\n afterSelfCall && position === 'right'\n ? NOTE_LANE_MAX - SELF_CALL_NOTE_X_SHIFT\n : NOTE_LANE_MAX;\n return Math.min(NOTE_MAX_W, laneMax);\n };\n\n const charsForWidth = (maxW: number): number =>\n Math.floor((maxW - NOTE_PAD_H * 2 - NOTE_FOLD) / NOTE_CHAR_W);\n\n const activationsOff = parsedOptions.activations?.toLowerCase() === 'off';\n\n // Tag resolution — shared utility handles priority chain:\n // programmatic override → diagram-level active-tag → auto-activate first group\n const activeTagGroup =\n resolveActiveTagGroup(\n parsed.tagGroups,\n parsedOptions['active-tag'],\n options?.activeTagGroup\n ) ?? undefined;\n let tagMap: ResolvedTagMap | undefined;\n const tagValueToColor = new Map<string, string>();\n if (activeTagGroup) {\n tagMap = resolveSequenceTags(parsed, activeTagGroup);\n const tg = parsed.tagGroups.find(\n (g) => g.name.toLowerCase() === activeTagGroup.toLowerCase()\n );\n if (tg) {\n for (const entry of tg.entries) {\n tagValueToColor.set(entry.value.toLowerCase(), entry.color);\n }\n }\n }\n const getTagColor = (value: string | undefined): string | undefined =>\n value ? tagValueToColor.get(value.toLowerCase()) : undefined;\n const tagKey = activeTagGroup?.toLowerCase();\n\n // Build hidden message set for collapse support\n const hiddenMsgIndices = new Set<number>();\n if (collapsedSections && collapsedSections.size > 0) {\n const sectionGroups = groupMessagesBySection(elements, messages);\n for (const grp of sectionGroups) {\n if (collapsedSections.has(grp.section.lineNumber)) {\n for (const idx of grp.messageIndices) {\n hiddenMsgIndices.add(idx);\n }\n }\n }\n }\n\n // Build render sequence with stack-based return placement\n // Run on ALL messages first (preserves call stack correctness), then filter\n const allRenderSteps = buildRenderSequence(messages);\n let renderSteps =\n hiddenMsgIndices.size > 0\n ? allRenderSteps.filter((s) => !hiddenMsgIndices.has(s.messageIndex))\n : allRenderSteps;\n // Drop unlabeled returns — they add visual noise without conveying information.\n // Labeled returns (explicit <- value) are kept.\n renderSteps = renderSteps.filter((s) => s.type === 'call' || s.label);\n const activations = activationsOff ? [] : computeActivations(renderSteps);\n const stepSpacing = 35;\n\n // --- Block-aware Y spacing ---\n // Extra spacing constants for block boundaries\n const BLOCK_HEADER_SPACE = 30; // Extra space for frame label above first message in a block\n const BLOCK_AFTER_SPACE = 15; // Extra space after a block ends (before next sibling)\n const FRAME_PADDING_TOP = 42; // Vertical padding from frame top to first message\n\n // Build maps from messageIndex to render step indices (needed early for spacing)\n const msgToFirstStep = new Map<number, number>();\n const msgToLastStep = new Map<number, number>();\n renderSteps.forEach((step, si) => {\n if (!msgToFirstStep.has(step.messageIndex)) {\n msgToFirstStep.set(step.messageIndex, si);\n }\n msgToLastStep.set(step.messageIndex, si);\n });\n\n // Map a note to the last render-step index of its preceding message\n // (the return arrow if present, otherwise the call arrow).\n // This ensures notes are positioned below the return arrow so they\n // don't overlap it.\n // If the note's closest preceding message is hidden (collapsed section), return -1\n // so the note is hidden along with its section.\n const findAssociatedLastStep = (note: SequenceNote): number => {\n // First find the closest preceding message (ignoring hidden filter)\n let closestMsgIndex = -1;\n let closestLine = -1;\n for (let mi = 0; mi < messages.length; mi++) {\n if (\n messages[mi].lineNumber < note.lineNumber &&\n messages[mi].lineNumber > closestLine\n ) {\n closestLine = messages[mi].lineNumber;\n closestMsgIndex = mi;\n }\n }\n // If the closest preceding message is hidden, hide the note too\n if (closestMsgIndex >= 0 && hiddenMsgIndices.has(closestMsgIndex)) {\n return -1;\n }\n if (closestMsgIndex < 0) return -1;\n return msgToLastStep.get(closestMsgIndex) ?? -1;\n };\n\n // Check whether a note's preceding message is a self-call.\n // Self-call loopback arrows extend SELF_CALL_HEIGHT below the step Y,\n // so notes after self-calls need a larger vertical offset.\n const isNoteAfterSelfCall = (note: SequenceNote): boolean => {\n let closestMsgIndex = -1;\n let closestLine = -1;\n for (let mi = 0; mi < messages.length; mi++) {\n if (\n messages[mi].lineNumber < note.lineNumber &&\n messages[mi].lineNumber > closestLine\n ) {\n closestLine = messages[mi].lineNumber;\n closestMsgIndex = mi;\n }\n }\n if (closestMsgIndex < 0) return false;\n const msg = messages[closestMsgIndex];\n return msg.from === msg.to;\n };\n\n // Extra gap below self-call loop before note starts\n const SELF_CALL_NOTE_GAP = 8;\n const noteOffsetBelow = (note: SequenceNote): number =>\n isNoteAfterSelfCall(note)\n ? SELF_CALL_HEIGHT + NOTE_OFFSET_BELOW + SELF_CALL_NOTE_GAP\n : NOTE_OFFSET_BELOW;\n\n // Find the first visible message index in an element subtree.\n // Use lineNumber lookup instead of indexOf — collapse projection creates\n // separate spread copies for messages[] and elements[], breaking reference equality.\n const msgLineToIdx = new Map<number, number>();\n messages.forEach((m, i) => msgLineToIdx.set(m.lineNumber, i));\n\n const findFirstMsgIndex = (els: SequenceElement[]): number => {\n for (const el of els) {\n if (isSequenceBlock(el)) {\n const idx = findFirstMsgIndex(el.children);\n if (idx >= 0) return idx;\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n const branchIdx = findFirstMsgIndex(branch.children);\n if (branchIdx >= 0) return branchIdx;\n }\n }\n const elseIdx = findFirstMsgIndex(el.elseChildren);\n if (elseIdx >= 0) return elseIdx;\n } else if (!isSequenceSection(el) && !isSequenceNote(el)) {\n const idx = msgLineToIdx.get(el.lineNumber) ?? -1;\n if (idx >= 0 && !hiddenMsgIndices.has(idx)) return idx;\n }\n }\n return -1;\n };\n\n // Section layout constants\n const SECTION_TOP_PAD = 35; // space above section divider line (matches stepSpacing)\n const SECTION_BOTTOM_PAD = 45; // space below section divider line before next content\n\n // Block spacing via extraBeforeMsg (sections handled separately below)\n const extraBeforeMsg = new Map<number, number>();\n const addExtra = (msgIdx: number, amount: number) => {\n extraBeforeMsg.set(msgIdx, (extraBeforeMsg.get(msgIdx) || 0) + amount);\n };\n\n const markBlockSpacing = (els: SequenceElement[]): void => {\n for (let i = 0; i < els.length; i++) {\n const el = els[i];\n if (isSequenceSection(el)) continue; // sections handled separately\n if (!isSequenceBlock(el)) continue;\n\n const firstIdx = findFirstMsgIndex(el.children);\n if (firstIdx >= 0) addExtra(firstIdx, BLOCK_HEADER_SPACE);\n\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n const firstBranchIdx = findFirstMsgIndex(branch.children);\n if (firstBranchIdx >= 0) addExtra(firstBranchIdx, BLOCK_HEADER_SPACE);\n markBlockSpacing(branch.children);\n }\n }\n\n const firstElseIdx = findFirstMsgIndex(el.elseChildren);\n if (firstElseIdx >= 0) addExtra(firstElseIdx, BLOCK_HEADER_SPACE);\n\n markBlockSpacing(el.children);\n markBlockSpacing(el.elseChildren);\n\n if (i + 1 < els.length) {\n const nextIdx = findFirstMsgIndex([els[i + 1]]);\n if (nextIdx >= 0) addExtra(nextIdx, BLOCK_AFTER_SPACE);\n }\n }\n };\n\n if (elements && elements.length > 0) {\n markBlockSpacing(elements);\n }\n\n // Note spacing — add vertical room after messages that have notes attached\n const NOTE_OFFSET_BELOW = 14; // gap between message arrow and top of note box\n // The next message label extends ~17px above its arrow line (8px offset + 9px cap height).\n // When notes share horizontal space with subsequent arrows, generous vertical clearance\n // is needed so note boxes don't visually cover message labels.\n const NOTE_TRAILING_GAP = 35;\n const computeNoteHeight = (\n text: string,\n maxChars: number = NOTE_CHARS_PER_LINE\n ): number => {\n const lines = wrapTextLines(text, maxChars);\n return lines.length * NOTE_LINE_H + NOTE_PAD_V * 2;\n };\n let trailingNoteSpace = 0; // extra space for notes at the end with no following message\n const markNoteSpacing = (els: SequenceElement[]): void => {\n for (let i = 0; i < els.length; i++) {\n const el = els[i];\n if (isSequenceNote(el)) {\n // Total vertical extent of notes from the message arrow:\n // offset (gap above first note — larger after self-calls)\n // + each note's height + NOTE_OFFSET_BELOW (inter-note gap)\n // + NOTE_TRAILING_GAP (gap below last note — clears next message label)\n const firstOffset = noteOffsetBelow(el as SequenceNote);\n let totalExtent = firstOffset;\n let j = i;\n while (j < els.length && isSequenceNote(els[j])) {\n const note = els[j] as SequenceNote;\n const sc = isNoteAfterSelfCall(note);\n const maxW = noteEffectiveMaxW(note.participantId, note.position, sc);\n const noteH = isNoteExpanded(note)\n ? computeNoteHeight(note.text, charsForWidth(maxW))\n : COLLAPSED_NOTE_H;\n totalExtent += noteH + NOTE_OFFSET_BELOW;\n j++;\n }\n // Replace the final inter-note gap with the larger trailing gap\n totalExtent += NOTE_TRAILING_GAP - NOTE_OFFSET_BELOW;\n // Only reserve space beyond the existing stepSpacing gap\n let extraNeeded = Math.max(0, totalExtent - stepSpacing);\n // Scan forward past sections, blocks, and other non-message elements to find next message\n let nextMsgIdx = -1;\n for (let k = j; k < els.length; k++) {\n nextMsgIdx = findFirstMsgIndex([els[k]]);\n if (nextMsgIdx >= 0) break;\n }\n // If a block follows, its frame extends FRAME_PADDING_TOP above the first\n // message but only BLOCK_HEADER_SPACE is reserved. Add the difference so\n // the note doesn't overlap the frame.\n if (j < els.length && isSequenceBlock(els[j])) {\n extraNeeded += FRAME_PADDING_TOP - BLOCK_HEADER_SPACE;\n }\n if (nextMsgIdx >= 0) {\n addExtra(nextMsgIdx, extraNeeded);\n } else {\n // Notes at the end — reserve only the excess beyond stepSpacing\n trailingNoteSpace = Math.max(trailingNoteSpace, extraNeeded);\n }\n // Skip over the consecutive notes we just processed\n i = j - 1;\n } else if (isSequenceBlock(el)) {\n markNoteSpacing(el.children);\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n markNoteSpacing(branch.children);\n }\n }\n markNoteSpacing(el.elseChildren);\n }\n }\n };\n if (elements && elements.length > 0) {\n markNoteSpacing(elements);\n }\n\n // --- Section-aware Y layout ---\n // Sections get their own Y positions computed from content above them (not anchored\n // to messages below). This ensures toggling collapse/expand doesn't move the divider.\n\n // Walk top-level elements to build section regions\n interface SectionRegion {\n section: import('./parser').SequenceSection;\n msgIndices: number[]; // message indices belonging to this section\n }\n const preSectionMsgIndices: number[] = [];\n const sectionRegions: SectionRegion[] = [];\n {\n // Build lineNumber → message index lookup. This is used instead of\n // messages.indexOf() because collapse projection creates spread copies\n // of messages, breaking reference equality.\n const msgLineToIndex = new Map<number, number>();\n messages.forEach((m, i) => msgLineToIndex.set(m.lineNumber, i));\n\n const findMsgIndex = (child: SequenceElement): number =>\n msgLineToIndex.get(child.lineNumber) ?? -1;\n\n const collectMsgIndicesFromBlock = (\n block: import('./parser').SequenceBlock\n ): number[] => {\n const indices: number[] = [];\n for (const child of block.children) {\n if (isSequenceBlock(child)) {\n indices.push(...collectMsgIndicesFromBlock(child));\n } else if (!isSequenceSection(child) && !isSequenceNote(child)) {\n const idx = findMsgIndex(child);\n if (idx >= 0) indices.push(idx);\n }\n }\n if (block.elseIfBranches) {\n for (const branch of block.elseIfBranches) {\n for (const child of branch.children) {\n if (isSequenceBlock(child)) {\n indices.push(...collectMsgIndicesFromBlock(child));\n } else if (!isSequenceSection(child) && !isSequenceNote(child)) {\n const idx = findMsgIndex(child);\n if (idx >= 0) indices.push(idx);\n }\n }\n }\n }\n for (const child of block.elseChildren) {\n if (isSequenceBlock(child)) {\n indices.push(...collectMsgIndicesFromBlock(child));\n } else if (!isSequenceSection(child) && !isSequenceNote(child)) {\n const idx = findMsgIndex(child);\n if (idx >= 0) indices.push(idx);\n }\n }\n return indices;\n };\n\n let currentTarget = preSectionMsgIndices;\n for (const el of elements) {\n if (isSequenceSection(el)) {\n const region: SectionRegion = { section: el, msgIndices: [] };\n sectionRegions.push(region);\n currentTarget = region.msgIndices;\n } else if (isSequenceBlock(el)) {\n currentTarget.push(...collectMsgIndicesFromBlock(el));\n } else {\n const idx = findMsgIndex(el);\n if (idx >= 0) currentTarget.push(idx);\n }\n }\n }\n\n // Build mapping from original (all) render step index → filtered step index\n const allMsgToFirstStep = new Map<number, number>();\n allRenderSteps.forEach((step, si) => {\n if (!allMsgToFirstStep.has(step.messageIndex)) {\n allMsgToFirstStep.set(step.messageIndex, si);\n }\n });\n\n const originalToFiltered = new Map<number, number>();\n {\n let fi = 0;\n for (let oi = 0; oi < allRenderSteps.length; oi++) {\n const step = allRenderSteps[oi];\n if (\n !hiddenMsgIndices.has(step.messageIndex) &&\n (step.type === 'call' || step.label)\n ) {\n originalToFiltered.set(oi, fi);\n fi++;\n }\n }\n }\n\n // For each section, find the filtered step index where its padding should be inserted\n const findFilteredInsertionPoint = (origStep: number): number | null => {\n for (let i = origStep; i < allRenderSteps.length; i++) {\n const fi = originalToFiltered.get(i);\n if (fi !== undefined) return fi;\n }\n return null;\n };\n\n // Map: filtered step index → sections to insert before it (in document order)\n const sectionsBeforeStep = new Map<\n number,\n import('./parser').SequenceSection[]\n >();\n const trailingSections: import('./parser').SequenceSection[] = [];\n\n for (const region of sectionRegions) {\n if (region.msgIndices.length === 0) {\n trailingSections.push(region.section);\n continue;\n }\n const firstMsgIdx = region.msgIndices[0];\n const origStep = allMsgToFirstStep.get(firstMsgIdx);\n if (origStep === undefined) {\n trailingSections.push(region.section);\n continue;\n }\n const filteredStep = findFilteredInsertionPoint(origStep);\n if (filteredStep === null) {\n trailingSections.push(region.section);\n continue;\n }\n const existing = sectionsBeforeStep.get(filteredStep) || [];\n existing.push(region.section);\n sectionsBeforeStep.set(filteredStep, existing);\n }\n\n // Section message counts for collapsed labels\n const sectionMsgCounts = new Map<number, number>();\n for (const region of sectionRegions) {\n sectionMsgCounts.set(region.section.lineNumber, region.msgIndices.length);\n }\n\n // Group box layout constants (needed early for Y offset)\n const GROUP_PADDING_X = 15;\n const GROUP_PADDING_TOP = 22;\n const GROUP_PADDING_BOTTOM = 8;\n const GROUP_LABEL_SIZE = 11;\n\n // Compute cumulative Y positions for each step, with section dividers as stable anchors\n const titleOffset = title ? TITLE_HEIGHT : 0;\n const LEGEND_FIXED_GAP = 8;\n const legendTopSpace =\n parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;\n // Use parsed.groups (not projected groups) to keep vertical space consistent\n // even when all groups are collapsed into virtual participants\n const groupOffset =\n parsed.groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;\n const participantStartY =\n TOP_MARGIN +\n titleOffset +\n legendTopSpace +\n PARTICIPANT_Y_OFFSET +\n groupOffset;\n const lifelineStartY0 = participantStartY + PARTICIPANT_BOX_HEIGHT;\n const hasActors = participants.some((p) => p.type === 'actor');\n const messageStartOffset = MESSAGE_START_OFFSET + (hasActors ? 20 : 0);\n const stepYPositions: number[] = [];\n const sectionYPositions = new Map<number, number>(); // section lineNumber → Y\n let layoutEndY: number; // final Y after all steps and trailing sections\n {\n let curY = lifelineStartY0 + messageStartOffset;\n for (let i = 0; i < renderSteps.length; i++) {\n // Insert section padding before this step if needed\n const sections = sectionsBeforeStep.get(i);\n if (sections) {\n for (const sec of sections) {\n curY += SECTION_TOP_PAD;\n sectionYPositions.set(sec.lineNumber, curY);\n curY += SECTION_BOTTOM_PAD;\n }\n }\n\n const step = renderSteps[i];\n // Add extra spacing before the first render step of a flagged message (block spacing)\n if (msgToFirstStep.get(step.messageIndex) === i) {\n const extra = extraBeforeMsg.get(step.messageIndex) || 0;\n curY += extra;\n }\n stepYPositions.push(curY);\n curY += stepSpacing;\n }\n // Handle trailing sections (after all steps)\n for (const sec of trailingSections) {\n curY += SECTION_TOP_PAD;\n sectionYPositions.set(sec.lineNumber, curY);\n curY += SECTION_BOTTOM_PAD;\n }\n // Extend for trailing notes that have no following message\n curY += trailingNoteSpace;\n layoutEndY = curY;\n }\n\n // Helper: compute Y for a step index\n const stepY = (i: number) => stepYPositions[i];\n\n // Compute absolute Y positions for each note element\n const noteYMap = new Map<SequenceNote, number>();\n {\n const computeNotePositions = (els: SequenceElement[]): void => {\n for (let i = 0; i < els.length; i++) {\n const el = els[i];\n if (isSequenceNote(el)) {\n const si = findAssociatedLastStep(el);\n if (si < 0) continue;\n // Check if there's a preceding note that we should stack below\n const prevNote =\n i > 0 && isSequenceNote(els[i - 1])\n ? (els[i - 1] as SequenceNote)\n : null;\n const prevNoteY = prevNote ? noteYMap.get(prevNote) : undefined;\n let noteTopY: number;\n if (prevNoteY !== undefined && prevNote) {\n // Stack below previous note\n const prevMaxW = noteEffectiveMaxW(\n prevNote.participantId,\n prevNote.position,\n isNoteAfterSelfCall(prevNote)\n );\n const prevNoteH = isNoteExpanded(prevNote)\n ? computeNoteHeight(prevNote.text, charsForWidth(prevMaxW))\n : COLLAPSED_NOTE_H;\n noteTopY = prevNoteY + prevNoteH + NOTE_OFFSET_BELOW;\n } else {\n // First note after a message — use larger offset after self-calls\n noteTopY = stepY(si) + noteOffsetBelow(el);\n }\n noteYMap.set(el, noteTopY);\n } else if (isSequenceBlock(el)) {\n computeNotePositions(el.children);\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n computeNotePositions(branch.children);\n }\n }\n computeNotePositions(el.elseChildren);\n }\n }\n };\n if (elements && elements.length > 0) {\n computeNotePositions(elements);\n }\n }\n\n // Ensure contentBottomY accounts for all note extents\n let contentBottomY =\n renderSteps.length > 0\n ? Math.max(\n stepYPositions[stepYPositions.length - 1] + stepSpacing,\n layoutEndY\n )\n : layoutEndY;\n for (const [note, noteTopY] of noteYMap) {\n const maxW = noteEffectiveMaxW(\n note.participantId,\n note.position,\n isNoteAfterSelfCall(note)\n );\n const noteH = isNoteExpanded(note)\n ? computeNoteHeight(note.text, charsForWidth(maxW))\n : COLLAPSED_NOTE_H;\n contentBottomY = Math.max(\n contentBottomY,\n noteTopY + noteH + NOTE_TRAILING_GAP\n );\n }\n const messageAreaHeight = contentBottomY - lifelineStartY0;\n const lifelineLength = messageAreaHeight + LIFELINE_TAIL;\n const totalWidth = Math.max(\n participants.length * PARTICIPANT_GAP,\n PARTICIPANT_BOX_WIDTH + 40\n );\n const contentHeight =\n participantStartY +\n PARTICIPANT_BOX_HEIGHT +\n Math.max(lifelineLength, 40) +\n 40;\n const totalHeight = contentHeight;\n\n const containerWidth =\n options?.exportWidth ?? container.getBoundingClientRect().width;\n const svgWidth = Math.max(totalWidth, containerWidth);\n\n // Center the diagram horizontally\n const diagramWidth = participants.length * PARTICIPANT_GAP;\n const offsetX =\n Math.max(0, (svgWidth - diagramWidth) / 2) + PARTICIPANT_GAP / 2;\n\n // Build participant x-position lookup\n const participantX = new Map<string, number>();\n participants.forEach((p, i) => {\n participantX.set(p.id, offsetX + i * PARTICIPANT_GAP);\n });\n\n const svg = d3Selection\n .select(container)\n .append('svg')\n .attr('width', '100%')\n .attr('height', totalHeight)\n .attr('viewBox', `0 0 ${svgWidth} ${totalHeight}`)\n .attr('preserveAspectRatio', 'xMidYMin meet')\n .attr('class', 'sequence-diagram')\n .style('font-family', FONT_FAMILY);\n\n // Define arrowhead markers\n const defs = svg.append('defs');\n\n // Filled arrowhead for call arrows\n defs\n .append('marker')\n .attr('id', 'seq-arrowhead')\n .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)\n .attr('refX', ARROWHEAD_SIZE)\n .attr('refY', ARROWHEAD_SIZE / 2)\n .attr('markerWidth', ARROWHEAD_SIZE)\n .attr('markerHeight', ARROWHEAD_SIZE)\n .attr('orient', 'auto')\n .append('polygon')\n .attr(\n 'points',\n `0,0 ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE / 2} 0,${ARROWHEAD_SIZE}`\n )\n .attr('fill', palette.text);\n\n // Open arrowhead for return arrows\n defs\n .append('marker')\n .attr('id', 'seq-arrowhead-open')\n .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)\n .attr('refX', ARROWHEAD_SIZE)\n .attr('refY', ARROWHEAD_SIZE / 2)\n .attr('markerWidth', ARROWHEAD_SIZE)\n .attr('markerHeight', ARROWHEAD_SIZE)\n .attr('orient', 'auto')\n .append('polyline')\n .attr(\n 'points',\n `0,0 ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE / 2} 0,${ARROWHEAD_SIZE}`\n )\n .attr('fill', 'none')\n .attr('stroke', palette.textMuted)\n .attr('stroke-width', 1.2);\n\n // Open arrowhead for async (fire-and-forget) arrows — same as return but text color\n defs\n .append('marker')\n .attr('id', 'seq-arrowhead-async')\n .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)\n .attr('refX', ARROWHEAD_SIZE)\n .attr('refY', ARROWHEAD_SIZE / 2)\n .attr('markerWidth', ARROWHEAD_SIZE)\n .attr('markerHeight', ARROWHEAD_SIZE)\n .attr('orient', 'auto')\n .append('polyline')\n .attr(\n 'points',\n `0,0 ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE / 2} 0,${ARROWHEAD_SIZE}`\n )\n .attr('fill', 'none')\n .attr('stroke', palette.text)\n .attr('stroke-width', 1.2);\n\n // Per-color arrowhead markers for tag-driven coloring\n const arrowPoints = `0,0 ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE / 2} 0,${ARROWHEAD_SIZE}`;\n for (const [, color] of tagValueToColor) {\n const hex = color.replace('#', '');\n // Filled arrowhead (call arrows)\n defs\n .append('marker')\n .attr('id', `seq-arrowhead-c${hex}`)\n .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)\n .attr('refX', ARROWHEAD_SIZE)\n .attr('refY', ARROWHEAD_SIZE / 2)\n .attr('markerWidth', ARROWHEAD_SIZE)\n .attr('markerHeight', ARROWHEAD_SIZE)\n .attr('orient', 'auto')\n .append('polygon')\n .attr('points', arrowPoints)\n .attr('fill', color);\n // Open arrowhead (async arrows)\n defs\n .append('marker')\n .attr('id', `seq-arrowhead-async-c${hex}`)\n .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)\n .attr('refX', ARROWHEAD_SIZE)\n .attr('refY', ARROWHEAD_SIZE / 2)\n .attr('markerWidth', ARROWHEAD_SIZE)\n .attr('markerHeight', ARROWHEAD_SIZE)\n .attr('orient', 'auto')\n .append('polyline')\n .attr('points', arrowPoints)\n .attr('fill', 'none')\n .attr('stroke', color)\n .attr('stroke-width', 1.2);\n // Open arrowhead (return arrows)\n defs\n .append('marker')\n .attr('id', `seq-arrowhead-open-c${hex}`)\n .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)\n .attr('refX', ARROWHEAD_SIZE)\n .attr('refY', ARROWHEAD_SIZE / 2)\n .attr('markerWidth', ARROWHEAD_SIZE)\n .attr('markerHeight', ARROWHEAD_SIZE)\n .attr('orient', 'auto')\n .append('polyline')\n .attr('points', arrowPoints)\n .attr('fill', 'none')\n .attr('stroke', color)\n .attr('stroke-width', 1.2);\n }\n\n // Helper: resolve marker ref for tag-colored arrows\n const coloredMarker = (\n type: 'call' | 'async' | 'return',\n tagColor?: string\n ): string => {\n if (tagColor) {\n const hex = tagColor.replace('#', '');\n switch (type) {\n case 'call':\n return `url(#seq-arrowhead-c${hex})`;\n case 'async':\n return `url(#seq-arrowhead-async-c${hex})`;\n case 'return':\n return `url(#seq-arrowhead-open-c${hex})`;\n }\n }\n switch (type) {\n case 'call':\n return 'url(#seq-arrowhead)';\n case 'async':\n return 'url(#seq-arrowhead-async)';\n case 'return':\n return 'url(#seq-arrowhead-open)';\n }\n };\n\n // Render title\n if (title) {\n const titleEl = svg\n .append('text')\n .attr('class', 'chart-title')\n .attr('x', svgWidth / 2)\n .attr('y', 30)\n .attr('text-anchor', 'middle')\n .attr('fill', palette.text)\n .attr('font-size', TITLE_FONT_SIZE)\n .attr('font-weight', TITLE_FONT_WEIGHT)\n .text(title);\n\n if (parsed.titleLineNumber) {\n titleEl.attr('data-line-number', parsed.titleLineNumber);\n }\n }\n\n // Collect all note line numbers (for controls group visibility + \"all expanded\" check)\n const allNoteLineNumbers: number[] = [];\n const collectNoteLines = (els: SequenceElement[]): void => {\n for (const el of els) {\n if (isSequenceNote(el)) {\n allNoteLineNumbers.push(el.lineNumber);\n } else if (isSequenceBlock(el)) {\n collectNoteLines(el.children);\n if ('elseChildren' in el) collectNoteLines(el.elseChildren);\n if ('branches' in el && Array.isArray(el.branches)) {\n for (const branch of el.branches) {\n collectNoteLines(branch.children);\n }\n }\n }\n }\n };\n collectNoteLines(elements);\n\n // Show controls group only in interactive mode (expandedNoteLines defined)\n // when notes exist and collapse-notes is not disabled\n const showNotesControl =\n allNoteLineNumbers.length > 0 &&\n !collapseNotesDisabled &&\n expandedNoteLines !== undefined;\n\n const hasTagGroups = parsed.tagGroups.length > 0;\n\n // Build set of collapsed group names for drill-bar rendering\n const collapsedGroupNames = new Set<string>();\n const collapsedGroupMeta = new Map<\n string,\n { lineNumber: number; metadata?: Record<string, string> }\n >();\n for (const group of parsed.groups) {\n if (effectiveCollapsedGroups.has(group.lineNumber)) {\n collapsedGroupNames.add(group.name);\n collapsedGroupMeta.set(group.name, {\n lineNumber: group.lineNumber,\n metadata: group.metadata,\n });\n }\n }\n\n // Render group boxes (behind participant shapes) — skip collapsed groups\n for (const group of groups) {\n if (group.participantIds.length === 0) continue;\n\n // Find X bounds from member participant positions\n const memberXs = group.participantIds\n .map((id) => participantX.get(id))\n .filter((x): x is number => x !== undefined);\n if (memberXs.length === 0) continue;\n\n const minX =\n Math.min(...memberXs) - PARTICIPANT_BOX_WIDTH / 2 - GROUP_PADDING_X;\n const maxX =\n Math.max(...memberXs) + PARTICIPANT_BOX_WIDTH / 2 + GROUP_PADDING_X;\n const boxY = participantStartY - GROUP_PADDING_TOP;\n const boxH =\n PARTICIPANT_BOX_HEIGHT + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;\n\n // Group box background — use tag color if group has metadata for the active tag group\n const groupTagValue = tagKey && group.metadata?.[tagKey];\n const groupTagColor = getTagColor(groupTagValue || undefined);\n const fillColor = groupTagColor\n ? mix(\n groupTagColor,\n isDark ? palette.surface : palette.bg,\n isDark ? 15 : 20\n )\n : isDark\n ? palette.surface\n : palette.bg;\n const strokeColor = groupTagColor || palette.textMuted;\n\n const groupG = svg\n .append('g')\n .attr('class', 'group-box-wrapper')\n .attr('data-group-toggle', '')\n .attr('data-group-line', String(group.lineNumber))\n .attr('cursor', 'pointer');\n groupG.append('title').text('Click to collapse');\n\n groupG\n .append('rect')\n .attr('x', minX)\n .attr('y', boxY)\n .attr('width', maxX - minX)\n .attr('height', boxH)\n .attr('rx', 6)\n .attr('fill', fillColor)\n .attr('stroke', strokeColor)\n .attr('stroke-width', 1)\n .attr('stroke-opacity', 0.5)\n .attr('class', 'group-box');\n\n // Group label\n groupG\n .append('text')\n .attr('x', minX + 8)\n .attr('y', boxY + GROUP_LABEL_SIZE + 4)\n .attr('fill', strokeColor)\n .attr('font-size', GROUP_LABEL_SIZE)\n .attr('font-weight', 'bold')\n .attr('opacity', 0.7)\n .attr('class', 'group-label')\n .text(group.name);\n }\n\n // Render each participant\n const lifelineStartY = lifelineStartY0;\n participants.forEach((participant, index) => {\n const cx = offsetX + index * PARTICIPANT_GAP;\n const cy = participantStartY;\n\n const pTagValue = tagMap?.participants.get(participant.id);\n const pTagColor = getTagColor(pTagValue);\n const pTagAttr =\n tagKey && pTagValue\n ? { key: tagKey, value: pTagValue.toLowerCase() }\n : undefined;\n // For collapsed group participants, resolve tag color from group metadata\n const isCollapsedGroup = collapsedGroupNames.has(participant.id);\n let effectiveTagColor = pTagColor;\n if (isCollapsedGroup && !effectiveTagColor) {\n const meta = collapsedGroupMeta.get(participant.id);\n if (meta?.metadata && tagKey) {\n effectiveTagColor = getTagColor(meta.metadata[tagKey]);\n }\n }\n\n renderParticipant(\n svg,\n participant,\n cx,\n cy,\n palette,\n isDark,\n effectiveTagColor,\n pTagAttr\n );\n\n // Collapsed group: re-render participant box at full group height + drill-bar\n if (isCollapsedGroup) {\n const meta = collapsedGroupMeta.get(participant.id)!;\n const drillColor = effectiveTagColor || palette.textMuted;\n const drillBarH = 6;\n const boxW = PARTICIPANT_BOX_WIDTH;\n // Match the group box dimensions\n const fullH =\n PARTICIPANT_BOX_HEIGHT + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;\n const clipId = `clip-drill-group-${participant.id.replace(/[^a-zA-Z0-9-]/g, '-')}`;\n\n // Add toggle attributes to the participant <g> so any click on it\n // (overlay rect, label, drill-bar) walks up and triggers the toggle\n const participantG = svg.select<SVGGElement>(\n `.participant[data-participant-id=\"${participant.id}\"]`\n );\n participantG\n .attr('data-group-toggle', '')\n .attr('data-group-line', String(meta.lineNumber))\n .attr('cursor', 'pointer');\n participantG.append('title').text('Click to expand');\n\n // Overlay a taller rect to replace the standard participant box\n const pFill = effectiveTagColor\n ? mix(\n effectiveTagColor,\n isDark ? palette.surface : palette.bg,\n isDark ? 30 : 40\n )\n : isDark\n ? mix(palette.overlay, palette.surface, 50)\n : mix(palette.bg, palette.surface, 50);\n const pStroke = effectiveTagColor || palette.border;\n\n // Taller box inside the participant <g> (local coords, y=0 is participant cy)\n participantG\n .append('rect')\n .attr('x', -boxW / 2)\n .attr('y', -GROUP_PADDING_TOP)\n .attr('width', boxW)\n .attr('height', fullH)\n .attr('rx', 6)\n .attr('fill', pFill)\n .attr('stroke', pStroke)\n .attr('stroke-width', 1.5);\n\n // Re-render label centered in the taller box (local coords)\n participantG\n .append('text')\n .attr('x', 0)\n .attr('y', -GROUP_PADDING_TOP + fullH / 2)\n .attr('text-anchor', 'middle')\n .attr('dominant-baseline', 'central')\n .attr('fill', palette.text)\n .attr('font-size', 13)\n .attr('font-weight', 500)\n .text(participant.label);\n\n // Drill-bar at bottom (local coords)\n participantG\n .append('clipPath')\n .attr('id', clipId)\n .append('rect')\n .attr('x', -boxW / 2)\n .attr('y', -GROUP_PADDING_TOP)\n .attr('width', boxW)\n .attr('height', fullH)\n .attr('rx', 6);\n\n participantG\n .append('rect')\n .attr('class', 'sequence-drill-bar')\n .attr('x', -boxW / 2)\n .attr('y', -GROUP_PADDING_TOP + fullH - drillBarH)\n .attr('width', boxW)\n .attr('height', drillBarH)\n .attr('fill', drillColor)\n .attr('clip-path', `url(#${clipId})`);\n }\n\n // Render lifeline — collapsed groups start below the taller box\n const llY = isCollapsedGroup\n ? lifelineStartY + GROUP_PADDING_BOTTOM\n : lifelineStartY;\n const llColor = isCollapsedGroup\n ? effectiveTagColor || palette.textMuted\n : pTagColor || palette.textMuted;\n const lifelineEl = svg\n .append('line')\n .attr('x1', cx)\n .attr('y1', llY)\n .attr('x2', cx)\n .attr('y2', lifelineStartY + lifelineLength)\n .attr('stroke', llColor)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '6 4')\n .attr('class', 'lifeline')\n .attr('data-participant-id', participant.id);\n if (tagKey && pTagValue) {\n lifelineEl.attr(`data-tag-${tagKey}`, pTagValue.toLowerCase());\n }\n });\n\n // Render block frames (behind everything else)\n const FRAME_PADDING_X = 30;\n // FRAME_PADDING_TOP declared earlier (near BLOCK_HEADER_SPACE)\n const FRAME_PADDING_BOTTOM = 15;\n const FRAME_LABEL_HEIGHT = 18;\n\n // Collect message indices from an element subtree\n const collectMsgIndices = (els: SequenceElement[]): number[] => {\n const indices: number[] = [];\n for (const el of els) {\n if (isSequenceBlock(el)) {\n indices.push(\n ...collectMsgIndices(el.children),\n ...collectMsgIndices(el.elseChildren)\n );\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n indices.push(...collectMsgIndices(branch.children));\n }\n }\n } else if (!isSequenceSection(el) && !isSequenceNote(el)) {\n const idx = messages.indexOf(el as SequenceMessage);\n if (idx >= 0) indices.push(idx);\n }\n }\n return indices;\n };\n\n // Collect deferred draws (rendered after activations so they appear on top)\n const deferredLabels: Array<{\n x: number;\n y: number;\n text: string;\n bold: boolean;\n italic: boolean;\n blockLine?: number;\n }> = [];\n const deferredLines: Array<{\n x1: number;\n y1: number;\n x2: number;\n y2: number;\n blockLine?: number;\n }> = [];\n\n // Recursive block renderer — draws borders/dividers now, defers label text\n const renderBlockFrames = (els: SequenceElement[], depth: number): void => {\n for (const el of els) {\n if (!isSequenceBlock(el)) continue;\n\n const ifIndices = collectMsgIndices(el.children);\n const elseIfBranchData: {\n label: string;\n indices: number[];\n lineNumber: number;\n }[] = [];\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n elseIfBranchData.push({\n label: branch.label,\n indices: collectMsgIndices(branch.children),\n lineNumber: branch.lineNumber,\n });\n }\n }\n const elseIndices = collectMsgIndices(el.elseChildren);\n const allIndices = [\n ...ifIndices,\n ...elseIfBranchData.flatMap((b) => b.indices),\n ...elseIndices,\n ];\n if (allIndices.length === 0) continue;\n\n // Find render step range\n let minStep = Infinity;\n let maxStep = -Infinity;\n for (const mi of allIndices) {\n const first = msgToFirstStep.get(mi);\n const last = msgToLastStep.get(mi);\n if (first !== undefined) minStep = Math.min(minStep, first);\n if (last !== undefined) maxStep = Math.max(maxStep, last);\n }\n if (minStep === Infinity) continue;\n\n // Find participant X range\n const involved = new Set<string>();\n for (const mi of allIndices) {\n involved.add(messages[mi].from);\n involved.add(messages[mi].to);\n }\n let minPX = Infinity;\n let maxPX = -Infinity;\n for (const pid of involved) {\n const px = participantX.get(pid);\n if (px !== undefined) {\n minPX = Math.min(minPX, px);\n maxPX = Math.max(maxPX, px);\n }\n }\n\n const frameX = minPX - FRAME_PADDING_X;\n const frameY = stepY(minStep) - FRAME_PADDING_TOP;\n const frameW = maxPX - minPX + FRAME_PADDING_X * 2;\n const frameH =\n stepY(maxStep) -\n stepY(minStep) +\n FRAME_PADDING_TOP +\n FRAME_PADDING_BOTTOM;\n\n // Frame border\n svg\n .append('rect')\n .attr('x', frameX)\n .attr('y', frameY)\n .attr('width', frameW)\n .attr('height', frameH)\n .attr('fill', 'none')\n .attr('stroke', palette.textMuted)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '2 3')\n .attr('rx', 3)\n .attr('ry', 3)\n .attr('class', 'block-frame')\n .attr('data-block-line', String(el.lineNumber));\n\n // Defer label text (rendered on top of activations later)\n deferredLabels.push({\n x: frameX + 6,\n y: frameY + FRAME_LABEL_HEIGHT - 4,\n text: `${el.type} ${el.label}`,\n bold: true,\n italic: false,\n blockLine: el.lineNumber,\n });\n\n // Else-if dividers\n for (const branchData of elseIfBranchData) {\n if (branchData.indices.length > 0) {\n let firstBranchStep = Infinity;\n for (const mi of branchData.indices) {\n const first = msgToFirstStep.get(mi);\n if (first !== undefined)\n firstBranchStep = Math.min(firstBranchStep, first);\n }\n if (firstBranchStep < Infinity) {\n const dividerY = stepY(firstBranchStep) - BLOCK_HEADER_SPACE;\n deferredLines.push({\n x1: frameX,\n y1: dividerY,\n x2: frameX + frameW,\n y2: dividerY,\n blockLine: branchData.lineNumber,\n });\n deferredLabels.push({\n x: frameX + 6,\n y: dividerY + 14,\n text: `else if ${branchData.label}`,\n bold: false,\n italic: true,\n blockLine: branchData.lineNumber,\n });\n }\n }\n }\n\n // Else divider\n if (elseIndices.length > 0) {\n let firstElseStep = Infinity;\n for (const mi of elseIndices) {\n const first = msgToFirstStep.get(mi);\n if (first !== undefined)\n firstElseStep = Math.min(firstElseStep, first);\n }\n if (firstElseStep < Infinity) {\n const dividerY = stepY(firstElseStep) - BLOCK_HEADER_SPACE;\n deferredLines.push({\n x1: frameX,\n y1: dividerY,\n x2: frameX + frameW,\n y2: dividerY,\n blockLine: el.elseLineNumber,\n });\n deferredLabels.push({\n x: frameX + 6,\n y: dividerY + 14,\n text: 'else',\n bold: false,\n italic: true,\n blockLine: el.elseLineNumber,\n });\n }\n }\n\n // Recurse into nested blocks\n renderBlockFrames(el.children, depth + 1);\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n renderBlockFrames(branch.children, depth + 1);\n }\n }\n renderBlockFrames(el.elseChildren, depth + 1);\n }\n };\n\n if (elements && elements.length > 0) {\n renderBlockFrames(elements, 0);\n }\n\n // Render activation rectangles (behind arrows)\n const ACTIVATION_NEST_OFFSET = 6;\n activations.forEach((act) => {\n const px = participantX.get(act.participantId);\n if (px === undefined) return;\n\n const x = px - ACTIVATION_WIDTH / 2 + act.depth * ACTIVATION_NEST_OFFSET;\n const y1 = stepY(act.startStep);\n const y2 = stepY(act.endStep);\n\n // Collect message line numbers covered by this activation\n const coveredLines: number[] = [];\n for (let si = act.startStep; si <= act.endStep; si++) {\n const step = renderSteps[si];\n const msg = messages[step.messageIndex];\n if (msg) coveredLines.push(msg.lineNumber);\n }\n\n // Determine activation color from triggering message's tag\n const triggerMsg = messages[renderSteps[act.startStep]?.messageIndex];\n const actTagValue = triggerMsg\n ? tagMap?.messages.get(triggerMsg.lineNumber)\n : undefined;\n const actTagColor = getTagColor(actTagValue);\n const actBaseColor = actTagColor || palette.primary;\n\n // Opaque background to mask the lifeline\n svg\n .append('rect')\n .attr('x', x)\n .attr('y', y1)\n .attr('width', ACTIVATION_WIDTH)\n .attr('height', y2 - y1)\n .attr('fill', isDark ? palette.surface : palette.bg);\n\n const actFill = mix(\n actBaseColor,\n isDark ? palette.surface : palette.bg,\n isDark ? 15 : 30\n );\n const actRect = svg\n .append('rect')\n .attr('x', x)\n .attr('y', y1)\n .attr('width', ACTIVATION_WIDTH)\n .attr('height', y2 - y1)\n .attr('fill', actFill)\n .attr('stroke', actBaseColor)\n .attr('stroke-width', 1)\n .attr('stroke-opacity', 0.5)\n .attr('data-participant-id', act.participantId)\n .attr('data-msg-lines', coveredLines.join(','))\n .attr('data-line-number', coveredLines[0] ?? '')\n .attr('class', 'activation');\n if (tagKey && actTagValue) {\n actRect.attr(`data-tag-${tagKey}`, actTagValue.toLowerCase());\n }\n });\n\n // Render deferred else dividers (on top of activations)\n for (const ln of deferredLines) {\n const line = svg\n .append('line')\n .attr('x1', ln.x1)\n .attr('y1', ln.y1)\n .attr('x2', ln.x2)\n .attr('y2', ln.y2)\n .attr('stroke', palette.textMuted)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '2 3')\n .attr('class', 'block-divider');\n if (ln.blockLine !== undefined)\n line.attr('data-block-line', String(ln.blockLine));\n }\n\n // Render deferred block labels (on top of activations)\n for (const lbl of deferredLabels) {\n const t = svg\n .append('text')\n .attr('x', lbl.x)\n .attr('y', lbl.y)\n .attr('fill', palette.text)\n .attr('font-size', 11)\n .attr('class', 'block-label')\n .text(lbl.text);\n if (lbl.bold) t.attr('font-weight', 'bold');\n if (lbl.italic) t.attr('font-style', 'italic');\n if (lbl.blockLine !== undefined)\n t.attr('data-block-line', String(lbl.blockLine));\n }\n\n // Helper: find max active activation depth for a participant at a step\n const activeDepthAt = (pid: string, stepIdx: number): number => {\n let maxDepth = -1;\n for (const act of activations) {\n if (\n act.participantId === pid &&\n act.startStep <= stepIdx &&\n stepIdx <= act.endStep &&\n act.depth > maxDepth\n ) {\n maxDepth = act.depth;\n }\n }\n return maxDepth;\n };\n\n // Helper: compute arrow endpoint X, snapping to activation box edge\n const arrowEdgeX = (\n pid: string,\n stepIdx: number,\n side: 'left' | 'right'\n ): number => {\n const px = participantX.get(pid)!;\n const depth = activeDepthAt(pid, stepIdx);\n if (depth < 0) return px;\n const offset = depth * ACTIVATION_NEST_OFFSET;\n return side === 'right'\n ? px + ACTIVATION_WIDTH / 2 + offset\n : px - ACTIVATION_WIDTH / 2 + offset;\n };\n\n // Render section dividers\n const leftmostX = Math.min(...Array.from(participantX.values()));\n const rightmostX = Math.max(...Array.from(participantX.values()));\n const sectionLineX1 = leftmostX - PARTICIPANT_BOX_WIDTH / 2 - 10;\n const sectionLineX2 = rightmostX + PARTICIPANT_BOX_WIDTH / 2 + 10;\n\n for (const region of sectionRegions) {\n const sec = region.section;\n const secY = sectionYPositions.get(sec.lineNumber);\n if (secY === undefined) continue;\n\n const isCollapsed = collapsedSections?.has(sec.lineNumber) ?? false;\n const lineColor = palette.textMuted;\n\n // Wrap section elements in a <g> for toggle.\n // IMPORTANT: only the <g> carries data-line-number / data-section —\n // children must NOT have them, otherwise the click walk-up resolves\n // to a line-number navigation before reaching data-section-toggle.\n const sectionG = svg\n .append('g')\n .attr('data-section-toggle', '')\n .attr('data-line-number', String(sec.lineNumber))\n .attr('data-section', '')\n .attr('tabindex', '0')\n .attr('role', 'button')\n .attr('aria-expanded', String(!isCollapsed));\n\n // Full-width tinted band\n const BAND_HEIGHT = 22;\n const bandX = sectionLineX1 - 10;\n const bandWidth = sectionLineX2 - sectionLineX1 + 20;\n const bandOpacity = isCollapsed\n ? isDark\n ? 0.35\n : 0.25\n : isDark\n ? 0.1\n : 0.08;\n sectionG\n .append('rect')\n .attr('x', bandX)\n .attr('y', secY - BAND_HEIGHT / 2)\n .attr('width', bandWidth)\n .attr('height', BAND_HEIGHT)\n .attr('fill', lineColor)\n .attr('opacity', bandOpacity)\n .attr('rx', 2)\n .attr('class', 'section-divider');\n\n // Build display label\n const msgCount = sectionMsgCounts.get(sec.lineNumber) ?? 0;\n const labelText = isCollapsed\n ? `${sec.label} (${msgCount} ${msgCount === 1 ? 'message' : 'messages'})`\n : sec.label;\n\n // Centered label text\n const labelX = (sectionLineX1 + sectionLineX2) / 2;\n sectionG\n .append('text')\n .attr('x', labelX)\n .attr('y', secY + 4)\n .attr('text-anchor', 'middle')\n .attr('fill', lineColor)\n .attr('font-size', 11)\n .attr('font-weight', 'bold')\n .attr('class', 'section-label')\n .text(labelText);\n }\n\n // Render steps (calls and returns in stack-inferred order)\n // SELF_CALL_WIDTH is now a module-level constant\n renderSteps.forEach((step, i) => {\n const fromX = participantX.get(step.from);\n const toX = participantX.get(step.to);\n if (fromX === undefined || toX === undefined) return;\n\n const y = stepY(i);\n\n const HIT_H = 20; // transparent hit area height (10px above + below arrow)\n\n // Resolve tag color for this message\n const msg = messages[step.messageIndex];\n const msgTagValue = msg ? tagMap?.messages.get(msg.lineNumber) : undefined;\n const msgTagColor = getTagColor(msgTagValue);\n\n if (step.type === 'call') {\n const arrowColor = msgTagColor || palette.text;\n\n if (step.from === step.to) {\n // Self-call: loopback arrow from right edge of activation\n const x = arrowEdgeX(step.from, i, 'right');\n\n // Hit area for self-call\n svg\n .append('rect')\n .attr('x', x)\n .attr('y', y - 5)\n .attr('width', SELF_CALL_WIDTH)\n .attr('height', SELF_CALL_HEIGHT + 10)\n .attr('fill', 'transparent')\n .attr('class', 'message-hit-area')\n .attr(\n 'data-line-number',\n String(messages[step.messageIndex].lineNumber)\n )\n .attr('data-msg-index', String(step.messageIndex))\n .attr('data-step-index', String(i));\n\n const selfCallEl = svg\n .append('path')\n .attr(\n 'd',\n `M ${x} ${y} H ${x + SELF_CALL_WIDTH} V ${y + SELF_CALL_HEIGHT} H ${x}`\n )\n .attr('fill', 'none')\n .attr('stroke', arrowColor)\n .attr('stroke-width', 1.2)\n .attr('marker-end', coloredMarker('call', msgTagColor))\n .attr('class', 'message-arrow self-call')\n .attr(\n 'data-line-number',\n String(messages[step.messageIndex].lineNumber)\n )\n .attr('data-msg-index', String(step.messageIndex))\n .attr('data-step-index', String(i))\n .attr('data-from', step.from)\n .attr('data-to', step.to);\n if (tagKey && msgTagValue) {\n selfCallEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());\n }\n\n if (step.label) {\n const labelEl = svg\n .append('text')\n .attr('x', x + SELF_CALL_WIDTH + 5)\n .attr('y', y + SELF_CALL_HEIGHT / 2 + 4)\n .attr('text-anchor', 'start')\n .attr('fill', arrowColor)\n .attr('paint-order', 'stroke fill')\n .attr('stroke', palette.bg)\n .attr('stroke-width', 4)\n .attr('stroke-linejoin', 'round')\n .attr('font-size', 12)\n .attr('class', 'message-label')\n .attr(\n 'data-line-number',\n String(messages[step.messageIndex].lineNumber)\n )\n .attr('data-msg-index', String(step.messageIndex))\n .attr('data-step-index', String(i));\n if (tagKey && msgTagValue) {\n labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());\n }\n // TD-1: in-arrow labels render as plain text (no markdown interpretation).\n // Fixes the `location[]`-style silent character drop.\n labelEl.text(step.label);\n }\n } else {\n // Normal call arrow — snap to activation box edges\n const goingRight = fromX < toX;\n const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');\n const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');\n\n // Hit area for call arrow\n svg\n .append('rect')\n .attr('x', Math.min(x1, x2))\n .attr('y', y - HIT_H / 2)\n .attr('width', Math.abs(x2 - x1))\n .attr('height', HIT_H)\n .attr('fill', 'transparent')\n .attr('class', 'message-hit-area')\n .attr(\n 'data-line-number',\n String(messages[step.messageIndex].lineNumber)\n )\n .attr('data-msg-index', String(step.messageIndex))\n .attr('data-step-index', String(i));\n\n const markerRef = step.async\n ? coloredMarker('async', msgTagColor)\n : coloredMarker('call', msgTagColor);\n const arrowEl = svg\n .append('line')\n .attr('x1', x1)\n .attr('y1', y)\n .attr('x2', x2)\n .attr('y2', y)\n .attr('stroke', arrowColor)\n .attr('stroke-width', 1.2)\n .attr('marker-end', markerRef)\n .attr('class', 'message-arrow')\n .attr(\n 'data-line-number',\n String(messages[step.messageIndex].lineNumber)\n )\n .attr('data-msg-index', String(step.messageIndex))\n .attr('data-step-index', String(i))\n .attr('data-from', step.from)\n .attr('data-to', step.to);\n if (tagKey && msgTagValue) {\n arrowEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());\n }\n\n if (step.label) {\n const midX = (x1 + x2) / 2;\n const labelEl = svg\n .append('text')\n .attr('x', midX)\n .attr('y', y - 8)\n .attr('text-anchor', 'middle')\n .attr('fill', arrowColor)\n .attr('paint-order', 'stroke fill')\n .attr('stroke', palette.bg)\n .attr('stroke-width', 4)\n .attr('stroke-linejoin', 'round')\n .attr('font-size', 12)\n .attr('class', 'message-label')\n .attr(\n 'data-line-number',\n String(messages[step.messageIndex].lineNumber)\n )\n .attr('data-msg-index', String(step.messageIndex))\n .attr('data-step-index', String(i));\n if (tagKey && msgTagValue) {\n labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());\n }\n // TD-1: in-arrow labels render as plain text (no markdown interpretation).\n // Fixes the `location[]`-style silent character drop.\n labelEl.text(step.label);\n }\n }\n } else {\n if (step.from === step.to) {\n // Self-call return — already handled by the loopback path, skip\n return;\n }\n // Return arrow — snap to activation box edges\n const goingRight = fromX < toX;\n const x1 = arrowEdgeX(step.from, i, goingRight ? 'right' : 'left');\n const x2 = arrowEdgeX(step.to, i, goingRight ? 'left' : 'right');\n const returnColor = msgTagColor || palette.textMuted;\n\n // Hit area for return arrow\n svg\n .append('rect')\n .attr('x', Math.min(x1, x2))\n .attr('y', y - HIT_H / 2)\n .attr('width', Math.abs(x2 - x1))\n .attr('height', HIT_H)\n .attr('fill', 'transparent')\n .attr('class', 'message-hit-area')\n .attr(\n 'data-line-number',\n String(messages[step.messageIndex].lineNumber)\n )\n .attr('data-msg-index', String(step.messageIndex))\n .attr('data-step-index', String(i));\n\n const returnEl = svg\n .append('line')\n .attr('x1', x1)\n .attr('y1', y)\n .attr('x2', x2)\n .attr('y2', y)\n .attr('stroke', returnColor)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '6 4')\n .attr('marker-end', coloredMarker('return', msgTagColor))\n .attr('class', 'return-arrow')\n .attr(\n 'data-line-number',\n String(messages[step.messageIndex].lineNumber)\n )\n .attr('data-msg-index', String(step.messageIndex))\n .attr('data-step-index', String(i))\n .attr('data-from', step.from)\n .attr('data-to', step.to);\n if (tagKey && msgTagValue) {\n returnEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());\n }\n\n if (step.label) {\n const midX = (x1 + x2) / 2;\n const labelEl = svg\n .append('text')\n .attr('x', midX)\n .attr('y', y - 6)\n .attr('text-anchor', 'middle')\n .attr('fill', returnColor)\n .attr('paint-order', 'stroke fill')\n .attr('stroke', palette.bg)\n .attr('stroke-width', 4)\n .attr('stroke-linejoin', 'round')\n .attr('font-size', 11)\n .attr('class', 'message-label')\n .attr(\n 'data-line-number',\n String(messages[step.messageIndex].lineNumber)\n )\n .attr('data-msg-index', String(step.messageIndex))\n .attr('data-step-index', String(i));\n if (tagKey && msgTagValue) {\n labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());\n }\n // TD-1: in-arrow labels render as plain text (no markdown\n // interpretation). Return-arrow labels are currently always empty\n // (buildRenderSequence sets them to '') but this path is kept in\n // sync with the call/self-call sites above to prevent a future\n // change resurrecting the location[] silent-drop bug.\n labelEl.text(step.label);\n }\n }\n });\n\n // Render notes — folded-corner boxes attached to participant lifelines\n const noteFill = isDark\n ? mix(palette.surface, palette.bg, 50)\n : mix(palette.bg, palette.surface, 15);\n\n const collapsedNoteFill = mix(palette.textMuted, palette.bg, 15);\n\n const renderNoteElements = (els: SequenceElement[]): void => {\n for (const el of els) {\n if (isSequenceNote(el)) {\n const px = participantX.get(el.participantId);\n if (px === undefined) continue;\n const noteTopY = noteYMap.get(el);\n if (noteTopY === undefined) continue;\n\n const expanded = isNoteExpanded(el);\n const isRight = el.position === 'right';\n\n if (expanded) {\n // --- Expanded note: full folded-corner box with wrapped text ---\n const afterSelfCall = isNoteAfterSelfCall(el);\n const maxW = noteEffectiveMaxW(\n el.participantId,\n el.position,\n afterSelfCall\n );\n const maxChars = charsForWidth(maxW);\n const wrappedLines = wrapTextLines(el.text, maxChars);\n const noteH = wrappedLines.length * NOTE_LINE_H + NOTE_PAD_V * 2;\n const maxLineLen = Math.max(...wrappedLines.map((l) => l.length));\n const noteW = Math.min(\n maxW,\n Math.max(80, maxLineLen * NOTE_CHAR_W + NOTE_PAD_H * 2 + NOTE_FOLD)\n );\n // Shift notes past self-call loopback when applicable\n const rightOffset =\n afterSelfCall && isRight\n ? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP\n : ACTIVATION_WIDTH + NOTE_GAP;\n const noteX = isRight\n ? px + rightOffset\n : px - ACTIVATION_WIDTH - NOTE_GAP - noteW;\n\n const noteG = svg\n .append('g')\n .attr('class', 'note')\n .attr('data-note-toggle', '')\n .attr('data-line-number', String(el.lineNumber))\n .attr('data-line-end', String(el.endLineNumber));\n\n // Folded-corner path\n noteG\n .append('path')\n .attr(\n 'd',\n [\n `M ${noteX} ${noteTopY}`,\n `L ${noteX + noteW - NOTE_FOLD} ${noteTopY}`,\n `L ${noteX + noteW} ${noteTopY + NOTE_FOLD}`,\n `L ${noteX + noteW} ${noteTopY + noteH}`,\n `L ${noteX} ${noteTopY + noteH}`,\n 'Z',\n ].join(' ')\n )\n .attr('fill', noteFill)\n .attr('stroke', palette.textMuted)\n .attr('stroke-width', 0.75)\n .attr('class', 'note-box');\n\n // Fold triangle\n noteG\n .append('path')\n .attr(\n 'd',\n [\n `M ${noteX + noteW - NOTE_FOLD} ${noteTopY}`,\n `L ${noteX + noteW - NOTE_FOLD} ${noteTopY + NOTE_FOLD}`,\n `L ${noteX + noteW} ${noteTopY + NOTE_FOLD}`,\n ].join(' ')\n )\n .attr('fill', 'none')\n .attr('stroke', palette.textMuted)\n .attr('stroke-width', 0.75)\n .attr('class', 'note-fold');\n\n // Render text with inline markdown\n wrappedLines.forEach((line, li) => {\n const textY = noteTopY + NOTE_PAD_V + (li + 1) * NOTE_LINE_H - 3;\n const isBullet = line.startsWith('- ');\n const bulletIndent = isBullet ? 10 : 0;\n const displayLine = isBullet ? line.slice(2) : line;\n const textEl = noteG\n .append('text')\n .attr('x', noteX + NOTE_PAD_H + bulletIndent)\n .attr('y', textY)\n .attr('fill', palette.text)\n .attr('font-size', NOTE_FONT_SIZE)\n .attr('class', 'note-text');\n\n if (isBullet) {\n noteG\n .append('text')\n .attr('x', noteX + NOTE_PAD_H)\n .attr('y', textY)\n .attr('fill', palette.text)\n .attr('font-size', NOTE_FONT_SIZE)\n .text('\\u2022');\n }\n\n renderInlineText(textEl, displayLine, palette, NOTE_FONT_SIZE);\n });\n } else {\n // --- Collapsed note: compact indicator ---\n const cFold = 6;\n const afterSelfCallC = isNoteAfterSelfCall(el);\n const rightOffsetC =\n afterSelfCallC && isRight\n ? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP\n : ACTIVATION_WIDTH + NOTE_GAP;\n const noteX = isRight\n ? px + rightOffsetC\n : px - ACTIVATION_WIDTH - NOTE_GAP - COLLAPSED_NOTE_W;\n\n const noteG = svg\n .append('g')\n .attr('class', 'note note-collapsed')\n .attr('data-note-toggle', '')\n .attr('data-line-number', String(el.lineNumber))\n .attr('data-line-end', String(el.endLineNumber))\n .style('cursor', 'pointer');\n\n // Small folded-corner rectangle\n noteG\n .append('path')\n .attr(\n 'd',\n [\n `M ${noteX} ${noteTopY}`,\n `L ${noteX + COLLAPSED_NOTE_W - cFold} ${noteTopY}`,\n `L ${noteX + COLLAPSED_NOTE_W} ${noteTopY + cFold}`,\n `L ${noteX + COLLAPSED_NOTE_W} ${noteTopY + COLLAPSED_NOTE_H}`,\n `L ${noteX} ${noteTopY + COLLAPSED_NOTE_H}`,\n 'Z',\n ].join(' ')\n )\n .attr('fill', collapsedNoteFill)\n .attr('stroke', palette.border)\n .attr('stroke-width', 0.75)\n .attr('class', 'note-box');\n\n // Fold triangle\n noteG\n .append('path')\n .attr(\n 'd',\n [\n `M ${noteX + COLLAPSED_NOTE_W - cFold} ${noteTopY}`,\n `L ${noteX + COLLAPSED_NOTE_W - cFold} ${noteTopY + cFold}`,\n `L ${noteX + COLLAPSED_NOTE_W} ${noteTopY + cFold}`,\n ].join(' ')\n )\n .attr('fill', 'none')\n .attr('stroke', palette.border)\n .attr('stroke-width', 0.75)\n .attr('class', 'note-fold');\n\n // \"...\" text\n noteG\n .append('text')\n .attr('x', noteX + COLLAPSED_NOTE_W / 2)\n .attr('y', noteTopY + COLLAPSED_NOTE_H / 2 + 3)\n .attr('text-anchor', 'middle')\n .attr('fill', palette.textMuted)\n .attr('font-size', 9)\n .attr('class', 'note-text')\n .text('\\u2026');\n }\n } else if (isSequenceBlock(el)) {\n renderNoteElements(el.children);\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n renderNoteElements(branch.children);\n }\n }\n renderNoteElements(el.elseChildren);\n }\n }\n };\n\n if (elements && elements.length > 0) {\n renderNoteElements(elements);\n }\n\n // Render legend LAST so it sits on top of all other SVG elements\n // (group boxes, lifelines, participants, etc.) and can receive clicks.\n if (hasTagGroups || showNotesControl) {\n const controlsExpanded = options?.controlsExpanded ?? false;\n\n const legendY = TOP_MARGIN + titleOffset;\n const resolvedGroups = parsed.tagGroups\n .filter((tg) => tg.entries.length > 0)\n .map((tg) => ({\n name: tg.name,\n entries: tg.entries.map((e) => ({\n value: e.value,\n color: e.color,\n })),\n }));\n\n const allExpanded = showNotesControl && (options?.expandAllNotes ?? false);\n\n const controlsGroup = showNotesControl\n ? {\n toggles: [\n {\n id: 'expand-all-notes',\n type: 'toggle' as const,\n label: 'Expand Notes',\n active: allExpanded,\n onToggle: () => {},\n },\n ],\n }\n : undefined;\n\n const legendConfig: LegendConfig = {\n groups: resolvedGroups,\n position: { placement: 'top-center', titleRelation: 'below-title' },\n mode: 'fixed',\n controlsGroup,\n };\n const legendState: LegendState = {\n activeGroup: activeTagGroup ?? null,\n controlsExpanded,\n };\n\n const legendCallbacks: LegendCallbacks = {\n onControlsExpand: () => {\n options?.onToggleControlsExpand?.();\n },\n onControlsToggle: (_toggleId: string, active: boolean) => {\n options?.onExpandAllNotes?.(active);\n },\n };\n\n const legendG = svg\n .append('g')\n .attr('class', 'sequence-legend')\n .attr('transform', `translate(0,${legendY})`);\n renderLegendD3(\n legendG,\n legendConfig,\n legendState,\n palette,\n isDark,\n legendCallbacks,\n svgWidth\n );\n }\n}\n\n/**\n * Build a mapping from each note's lineNumber to the lineNumber of its\n * associated message (the last message before the note in document order).\n * Used by the app to expand notes when cursor is on the associated message.\n */\nexport function buildNoteMessageMap(\n elements: SequenceElement[]\n): Map<number, number> {\n const map = new Map<number, number>();\n let lastMessageLine = -1;\n\n const walk = (els: SequenceElement[]): void => {\n for (const el of els) {\n if (isSequenceNote(el)) {\n if (lastMessageLine >= 0) {\n map.set(el.lineNumber, lastMessageLine);\n }\n } else if (isSequenceBlock(el)) {\n walk(el.children);\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n walk(branch.children);\n }\n }\n walk(el.elseChildren);\n } else if (!isSequenceSection(el)) {\n // It's a message\n const msg = el as SequenceMessage;\n lastMessageLine = msg.lineNumber;\n }\n }\n };\n walk(elements);\n return map;\n}\n\n/**\n * Collect all note line numbers from a sequence diagram's elements.\n * Used by the app to compute the \"expand all\" set.\n */\nexport function collectNoteLineNumbers(elements: SequenceElement[]): number[] {\n const result: number[] = [];\n const walk = (els: SequenceElement[]): void => {\n for (const el of els) {\n if (isSequenceNote(el)) {\n result.push(el.lineNumber);\n } else if (isSequenceBlock(el)) {\n walk(el.children);\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n walk(branch.children);\n }\n }\n walk(el.elseChildren);\n }\n }\n };\n walk(elements);\n return result;\n}\n\nfunction renderParticipant(\n svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,\n participant: SequenceParticipant,\n cx: number,\n cy: number,\n palette: PaletteColors,\n isDark: boolean,\n color?: string,\n tagAttr?: { key: string; value: string }\n): void {\n const g = svg\n .append('g')\n .attr('transform', `translate(${cx}, ${cy})`)\n .attr('class', 'participant')\n .attr('data-participant-id', participant.id);\n\n // Set data-tag attribute for legend hover dimming\n if (tagAttr) {\n g.attr(`data-tag-${tagAttr.key}`, tagAttr.value);\n }\n\n // Render shape based on type\n switch (participant.type) {\n case 'actor':\n renderActorParticipant(g, palette, color);\n break;\n case 'database':\n renderDatabaseParticipant(g, palette, isDark, color);\n break;\n case 'service':\n renderServiceParticipant(g, palette, isDark, color);\n break;\n case 'queue':\n renderQueueParticipant(g, palette, isDark, color);\n break;\n case 'cache':\n renderCacheParticipant(g, palette, isDark, color);\n break;\n case 'networking':\n renderNetworkingParticipant(g, palette, isDark, color);\n break;\n case 'frontend':\n renderFrontendParticipant(g, palette, isDark, color);\n break;\n case 'external':\n renderExternalParticipant(g, palette, isDark, color);\n break;\n case 'gateway':\n renderGatewayParticipant(g, palette, isDark, color);\n break;\n default:\n renderRectParticipant(g, palette, isDark, color);\n break;\n }\n\n // Render label — below the shape for actors, centered inside for others\n const isActor = participant.type === 'actor';\n const labelLines = splitParticipantLabel(participant.label);\n const fontSize = 13;\n const lineHeight = fontSize + 2;\n const textEl = g\n .append('text')\n .attr('x', 0)\n .attr('text-anchor', 'middle')\n .attr('fill', palette.text)\n .attr('font-size', fontSize)\n .attr('font-weight', 500);\n\n if (labelLines.length === 1) {\n textEl\n .attr(\n 'y',\n isActor ? PARTICIPANT_BOX_HEIGHT + 14 : PARTICIPANT_BOX_HEIGHT / 2 + 5\n )\n .text(participant.label);\n } else {\n // Multi-line: vertically center the lines within the box (or below for actors)\n const totalHeight = labelLines.length * lineHeight;\n const baseY = isActor\n ? PARTICIPANT_BOX_HEIGHT + 14 - ((labelLines.length - 1) * lineHeight) / 2\n : PARTICIPANT_BOX_HEIGHT / 2 + 5 - (totalHeight - lineHeight) / 2;\n\n labelLines.forEach((line, i) => {\n textEl\n .append('tspan')\n .attr('x', 0)\n .attr('dy', i === 0 ? `${baseY}px` : `${lineHeight}px`)\n .text(line);\n });\n }\n}\n","// ============================================================\n// Sequence Diagram Parser (.dgmo format)\n// ============================================================\n\nimport { inferParticipantType } from './participant-inference';\nimport type { DgmoError } from '../diagnostics';\nimport { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';\nimport { parseArrow, parseInArrowLabel } from '../utils/arrows';\nimport {\n measureIndent,\n extractColor,\n parsePipeMetadata,\n MULTIPLE_PIPE_ERROR,\n parseFirstLine,\n OPTION_NOCOLON_RE,\n} from '../utils/parsing';\nimport type { TagGroup } from '../utils/tag-groups';\nimport {\n matchTagBlockHeading,\n validateTagValues,\n validateTagGroupNames,\n stripDefaultModifier,\n} from '../utils/tag-groups';\n\n/** Known sequence-diagram options that take a value (space-separated). */\nconst KNOWN_SEQ_OPTIONS = new Set(['active-tag']);\n\n/** Known sequence-diagram boolean options (bare keyword or `no-` prefix). */\nconst KNOWN_SEQ_BOOLEANS = new Set(['activations', 'collapse-notes']);\n\n/**\n * Participant types that can be declared via \"Name is a type\" syntax.\n */\nexport type ParticipantType =\n | 'default'\n | 'service'\n | 'database'\n | 'actor'\n | 'queue'\n | 'cache'\n | 'gateway'\n | 'external'\n | 'networking'\n | 'frontend';\n\nconst VALID_PARTICIPANT_TYPES: ReadonlySet<string> = new Set([\n 'service',\n 'database',\n 'actor',\n 'queue',\n 'cache',\n 'gateway',\n 'external',\n 'networking',\n 'frontend',\n]);\n\n/**\n * A declared or inferred participant in the sequence diagram.\n */\nexport interface SequenceParticipant {\n /** Internal identifier (e.g. \"AuthService\") */\n id: string;\n /** Display label — uses aka alias if provided, otherwise id */\n label: string;\n /** Participant shape type */\n type: ParticipantType;\n /** Source line number (1-based) */\n lineNumber: number;\n /** Explicit layout position override (0-based from left, negative from right) */\n position?: number;\n /** Pipe-delimited tag metadata (e.g. `| role: Gateway`) */\n metadata?: Record<string, string>;\n}\n\n/**\n * A message between two participants.\n */\nexport interface SequenceMessage {\n from: string;\n to: string;\n label: string;\n lineNumber: number;\n async?: boolean;\n /** Pipe-delimited tag metadata (e.g. `| c: Caching`) */\n metadata?: Record<string, string>;\n}\n\n/**\n * A conditional or loop block in the sequence diagram.\n */\nexport interface ElseIfBranch {\n label: string;\n children: SequenceElement[];\n lineNumber: number;\n}\n\nexport interface SequenceBlock {\n kind: 'block';\n type: 'if' | 'loop' | 'parallel';\n label: string;\n children: SequenceElement[];\n elseChildren: SequenceElement[];\n elseIfBranches?: ElseIfBranch[];\n elseLineNumber?: number;\n lineNumber: number;\n}\n\n/**\n * A labeled horizontal divider between message phases.\n */\nexport interface SequenceSection {\n kind: 'section';\n label: string;\n lineNumber: number;\n}\n\n/**\n * An annotation attached to a message, rendered as a folded-corner box.\n */\nexport interface SequenceNote {\n kind: 'note';\n text: string;\n position: 'right' | 'left';\n participantId: string;\n lineNumber: number;\n endLineNumber: number;\n}\n\nexport type SequenceElement =\n | SequenceMessage\n | SequenceBlock\n | SequenceSection\n | SequenceNote;\n\nexport function isSequenceBlock(el: SequenceElement): el is SequenceBlock {\n return 'kind' in el && (el as SequenceBlock).kind === 'block';\n}\n\nexport function isSequenceSection(el: SequenceElement): el is SequenceSection {\n return 'kind' in el && (el as SequenceSection).kind === 'section';\n}\n\nexport function isSequenceNote(el: SequenceElement): el is SequenceNote {\n return 'kind' in el && (el as SequenceNote).kind === 'note';\n}\n\n/**\n * A named group of participants rendered as a labeled box.\n */\nexport interface SequenceGroup {\n name: string;\n participantIds: string[];\n lineNumber: number;\n /** Pipe-delimited tag metadata (e.g. `[Backend | t: Product]`) */\n metadata?: Record<string, string>;\n /** Whether this group is collapsed by default */\n collapsed?: boolean;\n}\n\n/**\n * Parsed result from a .dgmo sequence diagram.\n */\nexport interface ParsedSequenceDgmo {\n title: string | null;\n titleLineNumber: number | null;\n participants: SequenceParticipant[];\n messages: SequenceMessage[];\n elements: SequenceElement[];\n groups: SequenceGroup[];\n sections: SequenceSection[];\n tagGroups: TagGroup[];\n options: Record<string, string>;\n diagnostics: DgmoError[];\n error: string | null;\n}\n\n// \"Name is a type\" pattern — e.g. \"Auth Server is a service\"\n// Participant names may contain spaces; [^:]+? stops at colons so that\n// note lines like \"note right of A: this is a service\" are not falsely matched.\n// Remainder after type is parsed separately for aka/position modifiers\nconst IS_A_PATTERN = /^([^:]+?)\\s+is\\s+an?\\s+(\\w+)(?:\\s+(.+))?$/i;\n\n// Standalone \"Name position N\" pattern — e.g. \"DB position -1\"\nconst POSITION_ONLY_PATTERN = /^([^:]+?)\\s+position\\s+(-?\\d+)$/i;\n\n// Colored participant declaration — e.g. \"Tapin2(green)\", \"API(blue)\"\nconst COLORED_PARTICIPANT_PATTERN = /^(\\S+?)\\(([^)]+)\\)\\s*$/;\n\n// Group heading pattern — \"[Backend]\", \"[Backend] | t: Product\"\n// Group 1: name (no ] or | inside brackets), Group 2: color in parens, Group 3: after-bracket text\nconst GROUP_HEADING_PATTERN = /^\\[([^\\]|]+?)(?:\\(([^)]+)\\))?\\]\\s*(.*)$/;\n// Fallback: allows anything inside brackets (used to detect pipe-inside-brackets error)\nconst GROUP_HEADING_FALLBACK = /^\\[([^\\]]+)\\]\\s*(.*)$/;\n// Legacy ## syntax — detect and emit migration error\nconst LEGACY_GROUP_PATTERN = /^##\\s+(.+?)(?:\\(([^)]+)\\))?\\s*$/;\n\n// Section divider pattern — \"== Label ==\", \"== Label(color) ==\", or \"== Label\" (trailing == optional)\nconst SECTION_PATTERN = /^==\\s+(.+?)(?:\\s*==)?\\s*$/;\n\n// Arrow pattern for sequence inference — detects any arrow form\nconst ARROW_PATTERN = /\\S+\\s*(?:<-\\S+-|<~\\S+~|-\\S+->|~\\S+~>|->|~>|<-|<~)\\s*\\S+/;\n\n// Note patterns — colon-free syntax only\n// Single-line: \"note text\", \"note left text\", \"note right of X text\", \"note left X text\"\n// Multi-line: \"note\", \"note right\", \"note right of X\", \"note left X\" (body indented below)\n//\n// The colon-free positioned form requires participant resolution — the parser\n// already has participant collection infrastructure, so we match the general\n// structure here and resolve participant vs text in the parsing logic.\nconst NOTE_BARE = /^note\\s+(.+)$/i;\nconst NOTE_MULTI = /^note(?:\\s+(right|left)(?:\\s+(?:of\\s+)?(.+?))?)?\\s*$/i;\n\n/** Result of parseNoteLine — indicates what the parser should do. */\ntype NoteParseResult =\n | {\n kind: 'single';\n position: 'right' | 'left';\n participantId: string;\n text: string;\n }\n | { kind: 'multi-head'; position: 'right' | 'left'; participantId: string }\n | { kind: 'skip' }\n | null; // not a note line at all\n\n/**\n * Parse a note line, resolving participant names from the known participants list.\n *\n * Supports:\n * - `note text` — default position (right), last msg sender\n * - `note left of X text` / `note left X text`\n * - `note right` — multi-line head\n * - `note right of X` / `note left X` — multi-line head\n * - Quoted participant: `note left \"Auth Service\" text`\n */\nfunction parseNoteLine(\n trimmed: string,\n participants: SequenceParticipant[],\n participantIds: Set<string>,\n sortedParticipantsCache: SequenceParticipant[],\n lastMsgFrom: string | null\n): NoteParseResult {\n const lower = trimmed.toLowerCase();\n if (!lower.startsWith('note')) return null;\n // Must be exactly \"note\" or \"note \" — not \"notebook\" etc.\n if (trimmed.length > 4 && trimmed[4] !== ' ') return null;\n\n // 1. Try multi-line head (no text after note): `note`, `note right`, `note right of X`, `note left X`\n // NOTE: NOTE_MULTI's (.+?) can greedily capture \"participant text\" as one group.\n // Only trust this match if the captured participant actually exists. Otherwise,\n // fall through to the bare-note handler which does proper participant-aware splitting.\n const multiMatch = trimmed.match(NOTE_MULTI);\n if (multiMatch) {\n const position =\n (multiMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';\n let participantId = multiMatch[2] || null;\n if (!participantId) {\n if (!lastMsgFrom) return { kind: 'skip' };\n participantId = lastMsgFrom;\n }\n if (participantIds.has(participantId)) {\n return { kind: 'multi-head', position, participantId };\n }\n // Participant not found — fall through to bare-note handler for proper resolution\n }\n\n // 2. Bare note: `note text` or `note left [of] X text`\n const bareMatch = trimmed.match(NOTE_BARE);\n if (bareMatch) {\n const rest = bareMatch[1].trim();\n const restLower = rest.toLowerCase();\n\n // Check for positioned note: `note left/right ...`\n if (restLower.startsWith('left') || restLower.startsWith('right')) {\n const posWord = restLower.startsWith('left') ? 'left' : 'right';\n const position = posWord as 'right' | 'left';\n let afterPos = rest.substring(posWord.length).trim();\n\n // Strip optional `of` keyword — track whether it was present\n let hadOf = false;\n if (afterPos.toLowerCase().startsWith('of ')) {\n afterPos = afterPos.substring(3).trim();\n hadOf = true;\n }\n\n if (!afterPos) {\n // Just `note left` or `note right` — multi-line head\n if (!lastMsgFrom) return { kind: 'skip' };\n if (!participantIds.has(lastMsgFrom)) return { kind: 'skip' };\n return { kind: 'multi-head', position, participantId: lastMsgFrom };\n }\n\n // Try to match a known participant at the start of afterPos\n const resolved = resolveParticipantAndText(\n afterPos,\n participants,\n participantIds,\n sortedParticipantsCache\n );\n if (resolved) {\n if (resolved.text) {\n return {\n kind: 'single',\n position,\n participantId: resolved.participantId,\n text: resolved.text,\n };\n } else {\n // No text after participant — multi-line head\n return {\n kind: 'multi-head',\n position,\n participantId: resolved.participantId,\n };\n }\n }\n\n // No known participant matched.\n // If `of` was explicit (`note right of Z ...`), the user intended a specific\n // participant — skip when it doesn't exist rather than defaulting.\n if (hadOf) return { kind: 'skip' };\n\n // Without `of`, treat remaining text as note content on the last-msg sender\n if (!lastMsgFrom) return { kind: 'skip' };\n if (!participantIds.has(lastMsgFrom)) return { kind: 'skip' };\n return {\n kind: 'single',\n position,\n participantId: lastMsgFrom,\n text: afterPos,\n };\n }\n\n // Plain `note text` — default position, last msg sender\n if (!lastMsgFrom) return { kind: 'skip' };\n if (!participantIds.has(lastMsgFrom)) return { kind: 'skip' };\n return {\n kind: 'single',\n position: 'right',\n participantId: lastMsgFrom,\n text: rest,\n };\n }\n\n return null;\n}\n\n/**\n * Try to match a known participant name at the start of a string.\n * Returns the matched participant and remaining text, or null if no match.\n * Tries longest match first (multi-word participant names).\n */\nfunction resolveParticipantAndText(\n input: string,\n participants: SequenceParticipant[],\n participantIds: Set<string>,\n sortedParticipantsCache: SequenceParticipant[]\n): { participantId: string; text: string } | null {\n // Handle quoted participant: `\"Auth Service\" text`\n if (input.startsWith('\"') || input.startsWith(\"'\")) {\n const quote = input[0];\n const endQuote = input.indexOf(quote, 1);\n if (endQuote > 0) {\n const name = input.substring(1, endQuote);\n if (participantIds.has(name)) {\n const text = input.substring(endQuote + 1).trim();\n return { participantId: name, text };\n }\n }\n return null;\n }\n\n // Use pre-sorted participants (longest first) for greedy matching\n const sorted = sortedParticipantsCache;\n for (const p of sorted) {\n if (input.startsWith(p.id)) {\n const remaining = input.substring(p.id.length);\n // Must be followed by whitespace, end of string, or nothing\n if (remaining === '' || remaining[0] === ' ' || remaining[0] === '\\t') {\n return { participantId: p.id, text: remaining.trim() };\n }\n }\n }\n return null;\n}\n\n/**\n * Parse a .dgmo file with `chart: sequence` into a structured representation.\n */\nexport function parseSequenceDgmo(content: string): ParsedSequenceDgmo {\n const result: ParsedSequenceDgmo = {\n title: null,\n titleLineNumber: null,\n participants: [],\n messages: [],\n elements: [],\n groups: [],\n sections: [],\n tagGroups: [],\n options: {},\n diagnostics: [],\n error: null,\n };\n\n const fail = (line: number, message: string): ParsedSequenceDgmo => {\n const diag = makeDgmoError(line, message);\n result.diagnostics.push(diag);\n result.error = formatDgmoError(diag);\n return result;\n };\n\n /** Push a recoverable error and continue parsing. */\n const pushError = (line: number, message: string): void => {\n const diag = makeDgmoError(line, message);\n result.diagnostics.push(diag);\n if (!result.error) result.error = formatDgmoError(diag);\n };\n\n /** Push a non-fatal warning (does not set result.error). */\n const pushWarning = (line: number, message: string): void => {\n result.diagnostics.push(makeDgmoError(line, message, 'warning'));\n };\n\n if (!content || !content.trim()) {\n return fail(0, 'Empty content');\n }\n\n const lines = content.split('\\n');\n let hasExplicitChart = false;\n let contentStarted = false;\n let firstLineIndex = -1; // line index of the `sequence [Title]` first line (to skip in main loop)\n\n // Handle first non-empty, non-comment line for `sequence Title` syntax\n for (let fi = 0; fi < lines.length; fi++) {\n const fl = lines[fi].trim();\n if (!fl || fl.startsWith('//')) continue;\n const parsed = parseFirstLine(fl);\n if (parsed && parsed.chartType === 'sequence') {\n hasExplicitChart = true;\n firstLineIndex = fi;\n if (parsed.title) {\n result.title = parsed.title;\n result.titleLineNumber = fi + 1;\n }\n }\n break;\n }\n\n // Group parsing state — tracks the active [Group] heading\n let activeGroup: SequenceGroup | null = null;\n\n // Fast lookup set for participant existence checks (mirrors result.participants)\n const participantIds = new Set<string>();\n\n // Cache sorted participants (longest ID first) for greedy name matching in notes.\n // Invalidated whenever a new participant is added.\n let sortedParticipantsCache: SequenceParticipant[] = [];\n let sortedCacheDirty = true;\n\n /** Get sorted participants, rebuilding cache only when dirty. */\n const getSortedParticipants = (): SequenceParticipant[] => {\n if (sortedCacheDirty) {\n sortedParticipantsCache = [...result.participants].sort(\n (a, b) => b.id.length - a.id.length\n );\n sortedCacheDirty = false;\n }\n return sortedParticipantsCache;\n };\n\n // Track participant → group name for duplicate membership detection\n const participantGroupMap = new Map<string, string>();\n\n // Tag group parsing state\n let currentTagGroup: TagGroup | null = null;\n const aliasMap = new Map<string, string>();\n\n /** Split pipe metadata from a line: \"core | k: v\" → { core, meta } */\n const splitPipe = (\n text: string,\n ln?: number\n ): { core: string; meta?: Record<string, string> } => {\n const idx = text.indexOf('|');\n if (idx < 0) return { core: text };\n const core = text.substring(0, idx).trimEnd();\n const segments = text.substring(idx).split('|');\n const warnFn =\n ln != null ? () => pushError(ln, MULTIPLE_PIPE_ERROR) : undefined;\n const meta = parsePipeMetadata(segments, aliasMap, warnFn);\n return Object.keys(meta).length > 0 ? { core, meta } : { core };\n };\n\n // Block parsing state\n const blockStack: {\n block: SequenceBlock;\n indent: number;\n inElse: boolean;\n activeElseIfBranch?: ElseIfBranch;\n }[] = [];\n const currentContainer = (): SequenceElement[] => {\n if (blockStack.length === 0) return result.elements;\n const top = blockStack[blockStack.length - 1];\n if (top.activeElseIfBranch) return top.activeElseIfBranch.children;\n return top.inElse ? top.block.elseChildren : top.block.children;\n };\n\n // Track last message sender for default note positioning\n let lastMsgFrom: string | null = null;\n\n for (let i = 0; i < lines.length; i++) {\n const raw = lines[i];\n const trimmed = raw.trim();\n const lineNumber = i + 1;\n\n // Skip empty lines\n if (!trimmed) {\n activeGroup = null;\n currentTagGroup = null;\n continue;\n }\n\n // Skip first line already handled as `sequence [Title]`\n if (i === firstLineIndex) continue;\n\n // Parse group heading — [Group Name] or [Group Name] | k: v\n const groupMatch = trimmed.match(GROUP_HEADING_PATTERN);\n if (groupMatch) {\n const groupName = groupMatch[1].trim();\n const groupColor = groupMatch[2]?.trim();\n let groupMeta: Record<string, string> | undefined;\n\n // Parse collapse keyword and pipe metadata AFTER the closing bracket\n let afterBracket = groupMatch[3]?.trim() || '';\n let isCollapsed = false;\n\n // Extract `collapse` keyword (before any pipe metadata)\n const collapseMatch = afterBracket.match(/^collapse\\b/i);\n if (collapseMatch) {\n isCollapsed = true;\n afterBracket = afterBracket.slice(collapseMatch[0].length).trim();\n }\n\n if (afterBracket.startsWith('|')) {\n const segments = afterBracket.split('|');\n const meta = parsePipeMetadata(segments, aliasMap, () =>\n pushError(lineNumber, MULTIPLE_PIPE_ERROR)\n );\n if (Object.keys(meta).length > 0) groupMeta = meta;\n }\n\n if (groupColor) {\n pushWarning(\n lineNumber,\n `(${groupColor}) color syntax removed from sequence diagrams — use 'tag:' groups for coloring`\n );\n }\n contentStarted = true;\n activeGroup = {\n name: groupName,\n participantIds: [],\n lineNumber,\n ...(groupMeta ? { metadata: groupMeta } : {}),\n ...(isCollapsed ? { collapsed: true } : {}),\n };\n result.groups.push(activeGroup);\n continue;\n }\n\n // Detect pipe-inside-brackets error: [Name | meta] → suggest [Name] | meta\n if (trimmed.startsWith('[')) {\n const fallbackMatch = trimmed.match(GROUP_HEADING_FALLBACK);\n if (fallbackMatch && fallbackMatch[1].includes('|')) {\n const rawInside = fallbackMatch[1];\n const pipeIdx = rawInside.indexOf('|');\n const cleanName = rawInside\n .substring(0, pipeIdx)\n .trim()\n .replace(/\\([^)]*\\)$/, '')\n .trim();\n const metaPart = rawInside.substring(pipeIdx).trim();\n pushError(\n lineNumber,\n `Pipe metadata must go outside brackets — use '[${cleanName}] ${metaPart}' instead of '[${rawInside.trim()}]'`\n );\n continue;\n }\n }\n\n // Reject legacy ## group syntax with migration hint\n if (trimmed.match(LEGACY_GROUP_PATTERN)) {\n const legacyMatch = trimmed.match(LEGACY_GROUP_PATTERN)!;\n const name = legacyMatch[1].trim();\n const color = legacyMatch[2]?.trim();\n const suggestion = color ? `[${name}(${color})]` : `[${name}]`;\n pushError(\n lineNumber,\n `'## ${name}' group syntax is no longer supported. Use '${suggestion}' instead`\n );\n continue;\n }\n\n // Close active group on non-indented, non-group lines\n if (activeGroup && measureIndent(raw) === 0) {\n activeGroup = null;\n }\n\n // Skip comments — only // is supported\n if (trimmed.startsWith('//')) continue;\n\n // Reject # as comment syntax\n if (trimmed.startsWith('#')) {\n pushError(lineNumber, 'Use // for comments');\n continue;\n }\n\n // ---- Tag group handling ----\n // Tag block heading: \"tag Name [alias X]\"\n const tagBlockMatch = matchTagBlockHeading(trimmed);\n if (tagBlockMatch) {\n if (contentStarted) {\n pushError(lineNumber, 'Tag groups must appear before sequence content');\n continue;\n }\n currentTagGroup = {\n name: tagBlockMatch.name,\n alias: tagBlockMatch.alias,\n entries: [],\n lineNumber,\n };\n if (tagBlockMatch.alias) {\n aliasMap.set(\n tagBlockMatch.alias.toLowerCase(),\n tagBlockMatch.name.toLowerCase()\n );\n }\n result.tagGroups.push(currentTagGroup);\n continue;\n }\n\n // Tag group entries (indented Value(color) under tag heading)\n // First entry is the default unless another is marked `default`\n if (currentTagGroup && !contentStarted && measureIndent(raw) > 0) {\n const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);\n const { label, color } = extractColor(\n cleanEntry,\n undefined,\n result.diagnostics,\n lineNumber\n );\n if (!color) {\n pushError(\n lineNumber,\n `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`\n );\n continue;\n }\n if (isDefault) {\n currentTagGroup.defaultValue = label;\n } else if (currentTagGroup.entries.length === 0) {\n currentTagGroup.defaultValue = label;\n }\n currentTagGroup.entries.push({ value: label, color, lineNumber });\n continue;\n }\n\n // Non-indented line after tag group — close it\n if (currentTagGroup) {\n currentTagGroup = null;\n }\n\n // Parse section dividers — \"== Label ==\" or \"== Label(color) ==\"\n // Close blocks first — sections at indent 0 should not nest inside blocks\n const sectionMatch = trimmed.match(SECTION_PATTERN);\n if (sectionMatch) {\n const sectionIndent = measureIndent(raw);\n while (blockStack.length > 0) {\n const top = blockStack[blockStack.length - 1];\n if (sectionIndent > top.indent) break;\n blockStack.pop();\n }\n const labelRaw = sectionMatch[1].trim();\n const colorMatch = labelRaw.match(/^(.+?)\\(([^)]+)\\)$/);\n if (colorMatch) {\n pushWarning(\n lineNumber,\n `(${colorMatch[2].trim()}) color syntax removed from sequence diagrams — use 'tag:' groups for coloring`\n );\n }\n contentStarted = true;\n const section: SequenceSection = {\n kind: 'section',\n label: colorMatch ? colorMatch[1].trim() : labelRaw,\n lineNumber,\n };\n result.sections.push(section);\n currentContainer().push(section);\n continue;\n }\n\n // Parse header key: value lines (always top-level)\n // Skip 'note' lines — parsed in the indent-aware section below\n const colonIndex = trimmed.indexOf(':');\n if (\n colonIndex > 0 &&\n !trimmed.includes('->') &&\n !trimmed.includes('~>') &&\n !trimmed.includes('<-') &&\n !trimmed.includes('<~') &&\n !trimmed.includes('|')\n ) {\n const key = trimmed.substring(0, colonIndex).trim().toLowerCase();\n if (key === 'note' || key.startsWith('note ')) {\n // Fall through to indent-aware note parsing below\n } else {\n const value = trimmed.substring(colonIndex + 1).trim();\n\n // Enforce headers-before-content\n if (contentStarted) {\n pushError(\n lineNumber,\n `Options like '${key}: ${value}' must appear before the first message or declaration`\n );\n continue;\n }\n\n if (key === 'title') {\n result.title = value;\n result.titleLineNumber = lineNumber;\n continue;\n }\n\n // Store other options\n result.options[key] = value;\n continue;\n }\n }\n\n // Parse space-separated options (no colon): `activations off`, `no-activations`, `active-tag Priority`\n {\n const optLower = trimmed.toLowerCase();\n // Negated boolean: `no-activations` → options.activations = 'off'\n if (optLower.startsWith('no-')) {\n const base = optLower.substring(3);\n if (KNOWN_SEQ_BOOLEANS.has(base)) {\n if (contentStarted) {\n pushError(\n lineNumber,\n `Options like '${trimmed}' must appear before the first message or declaration`\n );\n continue;\n }\n result.options[base] = 'off';\n continue;\n }\n }\n // Key-value option: `active-tag Priority`\n const spaceMatch = trimmed.match(OPTION_NOCOLON_RE);\n if (spaceMatch) {\n const optKey = spaceMatch[1].toLowerCase();\n const optVal = spaceMatch[2].trim();\n if (KNOWN_SEQ_OPTIONS.has(optKey) || KNOWN_SEQ_BOOLEANS.has(optKey)) {\n if (contentStarted) {\n pushError(\n lineNumber,\n `Options like '${trimmed}' must appear before the first message or declaration`\n );\n continue;\n }\n result.options[optKey] = optVal;\n continue;\n }\n }\n }\n\n // Parse \"Name is a type [aka Alias]\" declarations (always top-level)\n // Skip lines starting with 'note' — handled by note parsing below\n const { core: isACore, meta: isAMeta } = splitPipe(trimmed, lineNumber);\n const isAMatch = !/^note(\\s|$)/i.test(trimmed)\n ? isACore.match(IS_A_PATTERN)\n : null;\n if (isAMatch) {\n contentStarted = true;\n const id = isAMatch[1];\n const typeStr = isAMatch[2].toLowerCase();\n const remainder = isAMatch[3]?.trim() || '';\n\n const participantType: ParticipantType = VALID_PARTICIPANT_TYPES.has(\n typeStr\n )\n ? (typeStr as ParticipantType)\n : 'default';\n\n // Parse modifiers from remainder: aka ALIAS, position N\n const akaMatch = remainder.match(\n /\\baka\\s+(.+?)(?:\\s+position\\s+-?\\d+\\s*$|$)/i\n );\n const posMatch = remainder.match(/\\bposition\\s+(-?\\d+)/i);\n const alias = akaMatch ? akaMatch[1].trim() : null;\n const position = posMatch ? parseInt(posMatch[1], 10) : undefined;\n\n // Avoid duplicate participant declarations\n if (!participantIds.has(id)) {\n participantIds.add(id);\n sortedCacheDirty = true;\n result.participants.push({\n id,\n label: alias || id,\n type: participantType,\n lineNumber,\n ...(position !== undefined ? { position } : {}),\n ...(isAMeta ? { metadata: isAMeta } : {}),\n });\n }\n // Track group membership\n if (activeGroup && !activeGroup.participantIds.includes(id)) {\n const existingGroup = participantGroupMap.get(id);\n if (existingGroup) {\n pushError(\n lineNumber,\n `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`\n );\n } else {\n activeGroup.participantIds.push(id);\n participantGroupMap.set(id, activeGroup.name);\n }\n }\n continue;\n }\n\n // Parse standalone \"Name position N\" (no \"is a\" type)\n const { core: posCore, meta: posMeta } = splitPipe(trimmed, lineNumber);\n const posOnlyMatch = posCore.match(POSITION_ONLY_PATTERN);\n if (posOnlyMatch) {\n contentStarted = true;\n const id = posOnlyMatch[1];\n const position = parseInt(posOnlyMatch[2], 10);\n\n if (!participantIds.has(id)) {\n participantIds.add(id);\n sortedCacheDirty = true;\n result.participants.push({\n id,\n label: id,\n type: inferParticipantType(id),\n lineNumber,\n position,\n ...(posMeta ? { metadata: posMeta } : {}),\n });\n }\n // Track group membership\n if (activeGroup && !activeGroup.participantIds.includes(id)) {\n const existingGroup = participantGroupMap.get(id);\n if (existingGroup) {\n pushError(\n lineNumber,\n `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`\n );\n } else {\n activeGroup.participantIds.push(id);\n participantGroupMap.set(id, activeGroup.name);\n }\n }\n continue;\n }\n\n // Colored participant declaration — \"Name(color)\" at any level\n // Color syntax is deprecated — emit warning and register without color\n const { core: colorCore, meta: colorMeta } = splitPipe(trimmed, lineNumber);\n const coloredMatch = colorCore.match(COLORED_PARTICIPANT_PATTERN);\n if (coloredMatch && !ARROW_PATTERN.test(colorCore)) {\n const id = coloredMatch[1];\n const color = coloredMatch[2].trim();\n pushError(\n lineNumber,\n `'${id}(${color})' syntax is no longer supported — use 'tag:' groups for coloring`\n );\n contentStarted = true;\n if (!participantIds.has(id)) {\n participantIds.add(id);\n sortedCacheDirty = true;\n result.participants.push({\n id,\n label: id,\n type: inferParticipantType(id),\n lineNumber,\n ...(colorMeta ? { metadata: colorMeta } : {}),\n });\n }\n if (activeGroup && !activeGroup.participantIds.includes(id)) {\n const existingGroup = participantGroupMap.get(id);\n if (existingGroup) {\n pushError(\n lineNumber,\n `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`\n );\n } else {\n activeGroup.participantIds.push(id);\n participantGroupMap.set(id, activeGroup.name);\n }\n }\n continue;\n }\n\n // Bare participant name — either inside an active group (indented) or top-level declaration\n // Supports pipe metadata: \" API | c: Gateway\" or \"Tapin2 | l:Park\"\n {\n const { core: bareCore, meta: bareMeta } = splitPipe(trimmed, lineNumber);\n const inGroup = activeGroup && measureIndent(raw) > 0;\n if (\n /^\\S+$/.test(bareCore) &&\n !ARROW_PATTERN.test(bareCore) &&\n (inGroup || !contentStarted || bareMeta)\n ) {\n contentStarted = true;\n const id = bareCore;\n if (!participantIds.has(id)) {\n participantIds.add(id);\n result.participants.push({\n id,\n label: id,\n type: inferParticipantType(id),\n lineNumber,\n ...(bareMeta ? { metadata: bareMeta } : {}),\n });\n }\n if (activeGroup && !activeGroup.participantIds.includes(id)) {\n const existingGroup = participantGroupMap.get(id);\n if (existingGroup) {\n pushError(\n lineNumber,\n `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`\n );\n } else {\n activeGroup.participantIds.push(id);\n participantGroupMap.set(id, activeGroup.name);\n }\n }\n continue;\n }\n }\n\n // ---- Indent-aware parsing for messages and block keywords ----\n const indent = measureIndent(raw);\n\n // Close blocks whose scope has ended (indent decreased)\n while (blockStack.length > 0) {\n const top = blockStack[blockStack.length - 1];\n if (indent > top.indent) break;\n // Keep block on stack when 'else' or 'else if' matches current indent — handled below\n if (\n indent === top.indent &&\n (top.block.type === 'if' || top.block.type === 'parallel')\n ) {\n const lower = trimmed.toLowerCase();\n if (lower === 'else' || lower.startsWith('else if ')) break;\n }\n blockStack.pop();\n }\n\n // Split pipe metadata before arrow parsing (arrows use $ anchor)\n const { core: arrowCore, meta: arrowMeta } = splitPipe(trimmed, lineNumber);\n\n // Parse message lines first — arrows take priority over keywords\n // Reject \"async\" keyword prefix — use ~> instead\n const asyncPrefixMatch = arrowCore.match(/^async\\s+(.+)$/i);\n if (asyncPrefixMatch && ARROW_PATTERN.test(asyncPrefixMatch[1])) {\n pushError(lineNumber, 'Use ~> for async messages: A ~> B: message');\n continue;\n }\n\n // ---- Labeled arrows: -label->, ~label~> ----\n // Must be checked BEFORE plain arrow patterns to avoid partial matches\n const labeledArrow = parseArrow(arrowCore);\n if (labeledArrow && 'error' in labeledArrow) {\n pushError(lineNumber, labeledArrow.error);\n continue;\n }\n if (labeledArrow) {\n contentStarted = true;\n const { from, to, label: rawLabel, async: isAsync } = labeledArrow;\n lastMsgFrom = from;\n\n // TD-13/TD-14: validate in-arrow label characters\n const labelResult = parseInArrowLabel(rawLabel, lineNumber);\n labelResult.diagnostics.forEach((d) => result.diagnostics.push(d));\n const label = labelResult.label ?? rawLabel;\n\n const msg: SequenceMessage = {\n from,\n to,\n label,\n lineNumber,\n ...(isAsync ? { async: true } : {}),\n ...(arrowMeta ? { metadata: arrowMeta } : {}),\n };\n result.messages.push(msg);\n currentContainer().push(msg);\n\n // Auto-register participants\n if (!participantIds.has(from)) {\n participantIds.add(from);\n sortedCacheDirty = true;\n result.participants.push({\n id: from,\n label: from,\n type: inferParticipantType(from),\n lineNumber,\n });\n }\n if (!participantIds.has(to)) {\n participantIds.add(to);\n sortedCacheDirty = true;\n result.participants.push({\n id: to,\n label: to,\n type: inferParticipantType(to),\n lineNumber,\n });\n }\n continue;\n }\n\n // ---- Error: old colon-postfix syntax (A -> B: msg) ----\n const colonPostfixSync = arrowCore.match(\n /^(\\S+)\\s*->\\s*([^\\s:]+)\\s*:\\s*(.+)$/\n );\n const colonPostfixAsync = arrowCore.match(\n /^(\\S+)\\s*~>\\s*([^\\s:]+)\\s*:\\s*(.+)$/\n );\n const colonPostfix = colonPostfixSync || colonPostfixAsync;\n if (colonPostfix) {\n const a = colonPostfix[1];\n const b = colonPostfix[2];\n const msg = colonPostfix[3].trim();\n const arrowChar = colonPostfixAsync ? '~' : '-';\n const arrowEnd = colonPostfixAsync ? '~>' : '->';\n pushError(\n lineNumber,\n `Colon syntax is no longer supported. Use '${a} ${arrowChar}${msg}${arrowEnd} ${b}' instead`\n );\n continue;\n }\n\n // ---- Error: plain bidirectional arrows (A <-> B, A <~> B) ----\n const bidiPlainMatch = arrowCore.match(/^(.+?)\\s*(?:<->|<~>)\\s*(.+)/);\n if (bidiPlainMatch) {\n pushError(\n lineNumber,\n \"Bidirectional arrows are no longer supported. Use two separate lines: 'A -msg-> B' and 'B -msg-> A'\"\n );\n continue;\n }\n\n // ---- Deprecated bare return arrows: A <- B, A <~ B ----\n const bareReturnSync = arrowCore.match(/^(.+?)\\s*<-\\s*(.+)$/);\n const bareReturnAsync = arrowCore.match(/^(.+?)\\s*<~\\s*(.+)$/);\n const bareReturn = bareReturnSync || bareReturnAsync;\n if (bareReturn) {\n const to = bareReturn[1];\n const from = bareReturn[2];\n pushError(\n lineNumber,\n `Left-pointing arrows are no longer supported. Write '${from} -> ${to}' instead`\n );\n continue;\n }\n\n // ---- Bare (unlabeled) call arrows: A -> B, A ~> B ----\n const bareCallSync = arrowCore.match(/^(.+?)\\s*->\\s*(.+)$/);\n const bareCallAsync = arrowCore.match(/^(.+?)\\s*~>\\s*(.+)$/);\n const bareCall = bareCallSync || bareCallAsync;\n if (bareCall) {\n contentStarted = true;\n const from = bareCall[1];\n const to = bareCall[2];\n lastMsgFrom = from;\n\n const msg: SequenceMessage = {\n from,\n to,\n label: '',\n lineNumber,\n ...(bareCallAsync ? { async: true } : {}),\n ...(arrowMeta ? { metadata: arrowMeta } : {}),\n };\n result.messages.push(msg);\n currentContainer().push(msg);\n\n if (!participantIds.has(from)) {\n participantIds.add(from);\n sortedCacheDirty = true;\n result.participants.push({\n id: from,\n label: from,\n type: inferParticipantType(from),\n lineNumber,\n });\n }\n if (!participantIds.has(to)) {\n participantIds.add(to);\n sortedCacheDirty = true;\n result.participants.push({\n id: to,\n label: to,\n type: inferParticipantType(to),\n lineNumber,\n });\n }\n continue;\n }\n\n // Parse 'if <label>' block keyword\n const ifMatch = trimmed.match(/^if\\s+(.+)$/i);\n if (ifMatch) {\n contentStarted = true;\n const block: SequenceBlock = {\n kind: 'block',\n type: 'if',\n label: ifMatch[1].trim(),\n children: [],\n elseChildren: [],\n lineNumber,\n };\n currentContainer().push(block);\n blockStack.push({ block, indent, inElse: false });\n continue;\n }\n\n // Parse 'loop <label>' block keyword\n const loopMatch = trimmed.match(/^loop\\s+(.+)$/i);\n if (loopMatch) {\n contentStarted = true;\n const block: SequenceBlock = {\n kind: 'block',\n type: 'loop',\n label: loopMatch[1].trim(),\n children: [],\n elseChildren: [],\n lineNumber,\n };\n currentContainer().push(block);\n blockStack.push({ block, indent, inElse: false });\n continue;\n }\n\n // Parse 'parallel [label]' block keyword\n const parallelMatch = trimmed.match(/^parallel(?:\\s+(.+))?$/i);\n if (parallelMatch) {\n contentStarted = true;\n const block: SequenceBlock = {\n kind: 'block',\n type: 'parallel',\n label: parallelMatch[1]?.trim() || '',\n children: [],\n elseChildren: [],\n lineNumber,\n };\n currentContainer().push(block);\n blockStack.push({ block, indent, inElse: false });\n continue;\n }\n\n // Parse 'else if <label>' keyword (must come before bare 'else')\n const elseIfMatch = trimmed.match(/^else\\s+if\\s+(.+)$/i);\n if (elseIfMatch) {\n if (\n blockStack.length > 0 &&\n blockStack[blockStack.length - 1].indent === indent\n ) {\n const top = blockStack[blockStack.length - 1];\n if (top.block.type === 'parallel') {\n pushError(\n lineNumber,\n \"parallel blocks don't support else if — list all concurrent messages directly inside the block\"\n );\n continue;\n }\n if (top.block.type === 'if') {\n const branch: ElseIfBranch = {\n label: elseIfMatch[1].trim(),\n children: [],\n lineNumber,\n };\n if (!top.block.elseIfBranches) top.block.elseIfBranches = [];\n top.block.elseIfBranches.push(branch);\n top.activeElseIfBranch = branch;\n top.inElse = false;\n }\n }\n continue;\n }\n\n // Parse 'else' keyword (only applies to 'if' blocks)\n if (trimmed.toLowerCase() === 'else') {\n if (\n blockStack.length > 0 &&\n blockStack[blockStack.length - 1].indent === indent\n ) {\n const top = blockStack[blockStack.length - 1];\n if (top.block.type === 'parallel') {\n pushError(\n lineNumber,\n \"parallel blocks don't support else — list all concurrent messages directly inside the block\"\n );\n continue;\n }\n if (top.block.type === 'if') {\n top.inElse = true;\n top.activeElseIfBranch = undefined;\n top.block.elseLineNumber = lineNumber;\n }\n }\n continue;\n }\n\n // ---- Note parsing (space-separated only) ----\n // Strategy:\n // 1. Try bare note: `note text` — position defaults, text is everything after `note`\n // 2. For positioned: `note left [of] X text` — needs participant lookup to split name vs text\n // 3. Multi-line: `note`, `note right`, `note right [of] X` (body indented below)\n {\n const noteParsed = parseNoteLine(\n trimmed,\n result.participants,\n participantIds,\n getSortedParticipants(),\n lastMsgFrom\n );\n if (noteParsed) {\n if (noteParsed.kind === 'single') {\n const note: SequenceNote = {\n kind: 'note',\n text: noteParsed.text,\n position: noteParsed.position,\n participantId: noteParsed.participantId,\n lineNumber,\n endLineNumber: lineNumber,\n };\n currentContainer().push(note);\n continue;\n }\n if (noteParsed.kind === 'multi-head') {\n // Collect indented body lines\n const noteLines: string[] = [];\n while (i + 1 < lines.length) {\n const nextRaw = lines[i + 1];\n const nextTrimmed = nextRaw.trim();\n if (!nextTrimmed) break;\n const nextIndent = measureIndent(nextRaw);\n if (nextIndent <= indent) break;\n noteLines.push(nextTrimmed);\n i++;\n }\n if (noteLines.length === 0) continue; // no body yet — skip during live typing\n const note: SequenceNote = {\n kind: 'note',\n text: noteLines.join('\\n'),\n position: noteParsed.position,\n participantId: noteParsed.participantId,\n lineNumber,\n endLineNumber: i + 1, // i has advanced past the body lines (1-based)\n };\n currentContainer().push(note);\n continue;\n }\n // 'skip' — note was incomplete (no preceding message, unknown participant)\n continue;\n }\n }\n\n // Catch-all: nothing matched this line\n pushWarning(lineNumber, `Unexpected line: '${trimmed}'.`);\n }\n\n // Validate: if no explicit chart line, check for arrow-based inference\n if (!hasExplicitChart && result.messages.length === 0) {\n // Check if raw content has arrow patterns for inference\n const hasArrows = lines.some((line) => ARROW_PATTERN.test(line.trim()));\n if (!hasArrows) {\n return fail(1, 'No \"sequence\" header and no sequence content detected');\n }\n }\n\n // Warn about unused participants (only when the diagram has messages)\n if (result.messages.length > 0) {\n const usedIds = new Set<string>();\n for (const msg of result.messages) {\n usedIds.add(msg.from);\n usedIds.add(msg.to);\n }\n // Walk elements recursively to find note participant references\n const walkElements = (elements: SequenceElement[]): void => {\n for (const el of elements) {\n if (isSequenceNote(el)) {\n usedIds.add(el.participantId);\n } else if (isSequenceBlock(el)) {\n walkElements(el.children);\n walkElements(el.elseChildren);\n if (el.elseIfBranches) {\n for (const branch of el.elseIfBranches) {\n walkElements(branch.children);\n }\n }\n }\n }\n };\n walkElements(result.elements);\n\n for (const p of result.participants) {\n if (!usedIds.has(p.id)) {\n pushWarning(\n p.lineNumber,\n `Participant \"${p.label}\" is declared but never used in any message or note`\n );\n }\n }\n }\n\n // Warn about empty groups\n for (const group of result.groups) {\n if (group.participantIds.length === 0) {\n pushWarning(\n group.lineNumber,\n `Empty group '${group.name}' — did you mean '== ${group.name} ==' for a section divider?`\n );\n }\n }\n\n // Validate tag group values on participants and messages\n if (result.tagGroups.length > 0) {\n const entities: Array<{\n metadata: Record<string, string>;\n lineNumber: number;\n }> = [];\n for (const p of result.participants) {\n if (p.metadata)\n entities.push({ metadata: p.metadata, lineNumber: p.lineNumber });\n }\n for (const m of result.messages) {\n if (m.metadata)\n entities.push({ metadata: m.metadata, lineNumber: m.lineNumber });\n }\n for (const g of result.groups) {\n if (g.metadata)\n entities.push({ metadata: g.metadata, lineNumber: g.lineNumber });\n }\n validateTagValues(entities, result.tagGroups, pushWarning, suggest);\n validateTagGroupNames(result.tagGroups, pushWarning);\n }\n\n return result;\n}\n\n/**\n * Detect whether raw content looks like a sequence diagram.\n * Used by the chart type inference logic.\n */\nexport function looksLikeSequence(content: string): boolean {\n if (!content) return false;\n const lines = content.split('\\n');\n return lines.some((line) => {\n const trimmed = line.trim();\n if (trimmed.startsWith('//')) return false;\n return ARROW_PATTERN.test(trimmed);\n });\n}\n"],"mappings":"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","names":["label","num"]}