@diagrammo/dgmo 0.8.22 → 0.8.25

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 (90) hide show
  1. package/.claude/commands/dgmo.md +60 -72
  2. package/dist/cli.cjs +123 -116
  3. package/dist/editor.cjs +3 -2
  4. package/dist/editor.cjs.map +1 -1
  5. package/dist/editor.js +3 -2
  6. package/dist/editor.js.map +1 -1
  7. package/dist/highlight.cjs +3 -2
  8. package/dist/highlight.cjs.map +1 -1
  9. package/dist/highlight.js +3 -2
  10. package/dist/highlight.js.map +1 -1
  11. package/dist/index.cjs +1649 -442
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +196 -23
  14. package/dist/index.d.ts +196 -23
  15. package/dist/index.js +1631 -440
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal.cjs +677 -0
  18. package/dist/internal.cjs.map +1 -0
  19. package/dist/internal.d.cts +267 -0
  20. package/dist/internal.d.ts +267 -0
  21. package/dist/internal.js +633 -0
  22. package/dist/internal.js.map +1 -0
  23. package/docs/guide/chart-area.md +17 -17
  24. package/docs/guide/chart-bar-stacked.md +12 -12
  25. package/docs/guide/chart-cycle.md +156 -0
  26. package/docs/guide/chart-doughnut.md +10 -10
  27. package/docs/guide/chart-funnel.md +9 -9
  28. package/docs/guide/chart-heatmap.md +10 -10
  29. package/docs/guide/chart-journey-map.md +179 -0
  30. package/docs/guide/chart-kanban.md +2 -0
  31. package/docs/guide/chart-line.md +19 -19
  32. package/docs/guide/chart-multi-line.md +16 -16
  33. package/docs/guide/chart-pie.md +11 -11
  34. package/docs/guide/chart-polar-area.md +10 -10
  35. package/docs/guide/chart-pyramid.md +111 -0
  36. package/docs/guide/chart-radar.md +9 -9
  37. package/docs/guide/chart-scatter.md +24 -27
  38. package/docs/guide/index.md +3 -3
  39. package/docs/guide/registry.json +5 -0
  40. package/docs/language-reference.md +108 -26
  41. package/fonts/Inter-Bold.ttf +0 -0
  42. package/fonts/Inter-Regular.ttf +0 -0
  43. package/fonts/LICENSE-Inter.txt +92 -0
  44. package/gallery/fixtures/bar-stacked.dgmo +12 -6
  45. package/gallery/fixtures/heatmap.dgmo +12 -6
  46. package/gallery/fixtures/multi-line.dgmo +11 -7
  47. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  48. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  49. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  50. package/gallery/fixtures/quadrant.dgmo +8 -8
  51. package/gallery/fixtures/scatter.dgmo +12 -12
  52. package/package.json +14 -2
  53. package/src/boxes-and-lines/parser.ts +13 -2
  54. package/src/boxes-and-lines/renderer.ts +22 -13
  55. package/src/chart-type-scoring.ts +162 -0
  56. package/src/chart-types.ts +437 -0
  57. package/src/cli.ts +152 -101
  58. package/src/completion.ts +9 -48
  59. package/src/cycle/layout.ts +19 -28
  60. package/src/cycle/renderer.ts +59 -32
  61. package/src/cycle/types.ts +21 -0
  62. package/src/d3.ts +30 -3
  63. package/src/dgmo-router.ts +98 -73
  64. package/src/echarts.ts +1 -1
  65. package/src/editor/keywords.ts +3 -2
  66. package/src/fonts.ts +3 -2
  67. package/src/gantt/parser.ts +5 -1
  68. package/src/index.ts +37 -3
  69. package/src/infra/parser.ts +3 -3
  70. package/src/internal.ts +20 -0
  71. package/src/journey-map/layout.ts +7 -3
  72. package/src/journey-map/parser.ts +5 -1
  73. package/src/journey-map/renderer.ts +112 -47
  74. package/src/kanban/parser.ts +5 -1
  75. package/src/org/collapse.ts +82 -4
  76. package/src/org/parser.ts +1 -1
  77. package/src/org/renderer.ts +221 -4
  78. package/src/pyramid/parser.ts +172 -0
  79. package/src/pyramid/renderer.ts +684 -0
  80. package/src/pyramid/types.ts +28 -0
  81. package/src/render.ts +2 -8
  82. package/src/sequence/parser.ts +64 -22
  83. package/src/sequence/participant-inference.ts +0 -1
  84. package/src/sequence/renderer.ts +97 -265
  85. package/src/sharing.ts +0 -1
  86. package/src/sitemap/parser.ts +1 -1
  87. package/src/tech-radar/interactive.ts +54 -0
  88. package/src/utils/parsing.ts +1 -0
  89. package/src/utils/tag-groups.ts +35 -5
  90. package/src/wireframe/parser.ts +3 -1
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/utils/parsing.ts","../src/chart.ts","../src/kanban/mutations.ts","../src/sequence/parser.ts","../src/sequence/renderer.ts","../src/d3.ts","../src/internal.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 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']);\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, pushError);\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","// ============================================================\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 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 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}\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\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 = computeNoteHeight(note.text, charsForWidth(maxW));\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 = computeNoteHeight(\n prevNote.text,\n charsForWidth(prevMaxW)\n );\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 = computeNoteHeight(note.text, charsForWidth(maxW));\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 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 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 isRight = el.position === 'right';\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 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) {\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 legendConfig: LegendConfig = {\n groups: resolvedGroups,\n position: { placement: 'top-center', titleRelation: 'below-title' },\n mode: 'fixed',\n };\n const legendState: LegendState = {\n activeGroup: activeTagGroup ?? null,\n controlsExpanded: false,\n };\n\n const legendCallbacks: LegendCallbacks = {};\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 highlight the associated message when cursor is on a note.\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\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","import * as d3Scale from 'd3-scale';\nimport * as d3Selection from 'd3-selection';\nimport * as d3Shape from 'd3-shape';\nimport * as d3Array from 'd3-array';\nimport cloud from 'd3-cloud';\nimport { FONT_FAMILY } from './fonts';\nimport { computeQuadrantPointLabels, type LabelRect } from './label-layout';\nimport { MONTH_ABBR, computeTimeTicks } from './utils/time-ticks';\nimport type { D3ExportDimensions } from './utils/d3-types';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport type VisualizationType =\n | 'slope'\n | 'wordcloud'\n | 'arc'\n | 'timeline'\n | 'venn'\n | 'quadrant'\n | 'sequence'\n | 'tech-radar'\n | 'cycle'\n | 'pyramid';\n\ninterface D3DataItem {\n label: string;\n values: number[];\n color: string | null;\n lineNumber: number;\n}\n\ninterface WordCloudWord {\n text: string;\n weight: number;\n lineNumber: number;\n}\n\ntype WordCloudRotate = 'none' | 'mixed' | 'angled';\n\ninterface WordCloudOptions {\n rotate: WordCloudRotate;\n max: number;\n minSize: number;\n maxSize: number;\n}\n\nconst DEFAULT_CLOUD_OPTIONS: WordCloudOptions = {\n rotate: 'none',\n max: 0,\n minSize: 14,\n maxSize: 80,\n};\n\nexport interface ArcLink {\n source: string;\n target: string;\n value: number;\n color: string | null;\n lineNumber: number;\n}\n\ntype ArcOrder = 'appearance' | 'name' | 'group' | 'degree';\n\nexport interface ArcNodeGroup {\n name: string;\n nodes: string[];\n color: string | null;\n lineNumber: number;\n}\n\ntype TimelineSort = 'time' | 'group' | 'tag';\n\ninterface TimelineEvent {\n date: string;\n endDate: string | null;\n label: string;\n group: string | null;\n metadata: Record<string, string>;\n lineNumber: number;\n uncertain?: boolean;\n}\n\ninterface TimelineGroup {\n name: string;\n color: string | null;\n lineNumber: number;\n}\n\ninterface TimelineEra {\n startDate: string;\n endDate: string;\n label: string;\n color: string | null;\n lineNumber: number;\n}\n\ninterface TimelineMarker {\n date: string;\n label: string;\n color: string | null;\n lineNumber: number;\n}\n\ninterface VennSet {\n name: string;\n alias: string | null;\n color: string | null;\n lineNumber: number;\n}\n\ninterface VennOverlap {\n sets: string[];\n label: string | null;\n lineNumber: number;\n}\n\ninterface QuadrantLabel {\n text: string;\n color: string | null;\n lineNumber: number;\n}\n\ninterface QuadrantPoint {\n label: string;\n x: number;\n y: number;\n lineNumber: number;\n}\n\ninterface QuadrantLabels {\n topRight: QuadrantLabel | null;\n topLeft: QuadrantLabel | null;\n bottomLeft: QuadrantLabel | null;\n bottomRight: QuadrantLabel | null;\n}\n\n/** Optional explicit dimensions for CLI/export rendering (bypasses DOM layout). */\nexport type { D3ExportDimensions } from './utils/d3-types';\n\nexport interface ParsedVisualization {\n type: VisualizationType | null;\n title: string | null;\n titleLineNumber: number | null;\n orientation: 'horizontal' | 'vertical';\n periods: string[];\n data: D3DataItem[];\n words: WordCloudWord[];\n cloudOptions: WordCloudOptions;\n links: ArcLink[];\n arcOrder: ArcOrder;\n arcNodeGroups: ArcNodeGroup[];\n timelineEvents: TimelineEvent[];\n timelineGroups: TimelineGroup[];\n timelineEras: TimelineEra[];\n timelineMarkers: TimelineMarker[];\n timelineTagGroups: TagGroup[];\n timelineSort: TimelineSort;\n timelineDefaultSwimlaneTG?: string;\n timelineScale: boolean;\n timelineSwimlanes: boolean;\n vennSets: VennSet[];\n vennOverlaps: VennOverlap[];\n // Quadrant chart fields\n quadrantLabels: QuadrantLabels;\n quadrantPoints: QuadrantPoint[];\n quadrantXAxis: [string, string] | null;\n quadrantXAxisLineNumber: number | null;\n quadrantYAxis: [string, string] | null;\n quadrantYAxisLineNumber: number | null;\n quadrantTitleLineNumber: number | null;\n diagnostics: DgmoError[];\n error: string | null;\n}\n\n// ============================================================\n// Color Imports\n// ============================================================\n\nimport { resolveColorWithDiagnostic } from './colors';\nimport type { PaletteColors } from './palettes';\nimport { getSeriesColors } from './palettes';\nimport { mix } from './palettes/color-utils';\nimport type { DgmoError } from './diagnostics';\nimport { makeDgmoError, formatDgmoError, suggest } from './diagnostics';\nimport {\n collectIndentedValues,\n extractColor,\n normalizeNumericToken,\n parseFirstLine,\n parsePipeMetadata,\n MULTIPLE_PIPE_ERROR,\n} from './utils/parsing';\nimport {\n matchTagBlockHeading,\n validateTagValues,\n validateTagGroupNames,\n resolveTagColor,\n resolveActiveTagGroup,\n stripDefaultModifier,\n} from './utils/tag-groups';\nimport type { TagGroup } from './utils/tag-groups';\nimport {\n LEGEND_HEIGHT as TL_LEGEND_HEIGHT,\n LEGEND_PILL_PAD as TL_LEGEND_PILL_PAD,\n LEGEND_PILL_FONT_SIZE as TL_LEGEND_PILL_FONT_SIZE,\n LEGEND_CAPSULE_PAD as TL_LEGEND_CAPSULE_PAD,\n LEGEND_DOT_R as TL_LEGEND_DOT_R,\n LEGEND_ENTRY_FONT_SIZE as TL_LEGEND_ENTRY_FONT_SIZE,\n LEGEND_ENTRY_DOT_GAP as TL_LEGEND_ENTRY_DOT_GAP,\n LEGEND_ENTRY_TRAIL as TL_LEGEND_ENTRY_TRAIL,\n measureLegendText,\n} from './utils/legend-constants';\nimport { renderLegendD3 } from './utils/legend-d3';\nimport type {\n LegendConfig,\n LegendState,\n LegendCallbacks,\n} from './utils/legend-types';\nimport {\n TITLE_FONT_SIZE,\n TITLE_FONT_WEIGHT,\n TITLE_Y,\n} from './utils/title-constants';\n\n// ============================================================\n// Shared Rendering Helpers\n// ============================================================\n\n/**\n * Renders a chart title on the SVG with optional click interaction.\n */\nfunction renderChartTitle(\n svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,\n title: string | undefined | null,\n titleLineNumber: number | undefined | null,\n width: number,\n textColor: string,\n onClickItem?: (lineNumber: number) => void\n): void {\n if (!title) return;\n const titleEl = svg\n .append('text')\n .attr('class', 'chart-title')\n .attr('x', width / 2)\n .attr('y', TITLE_Y)\n .attr('text-anchor', 'middle')\n .attr('fill', textColor)\n .attr('font-size', TITLE_FONT_SIZE)\n .attr('font-weight', TITLE_FONT_WEIGHT)\n .style('cursor', onClickItem && titleLineNumber ? 'pointer' : 'default')\n .text(title);\n if (titleLineNumber) {\n titleEl.attr('data-line-number', titleLineNumber);\n if (onClickItem) {\n titleEl\n .on('click', () => onClickItem(titleLineNumber))\n .on('mouseenter', function () {\n d3Selection.select(this).attr('opacity', 0.7);\n })\n .on('mouseleave', function () {\n d3Selection.select(this).attr('opacity', 1);\n });\n }\n }\n}\n\n/**\n * Initializes a D3 chart: clears existing content, creates SVG, resolves palette colors.\n * Returns null if the container has zero dimensions.\n */\nfunction initD3Chart(\n container: HTMLDivElement,\n palette: PaletteColors,\n exportDims?: D3ExportDimensions\n): {\n svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;\n width: number;\n height: number;\n textColor: string;\n mutedColor: string;\n bgColor: string;\n colors: string[];\n} | null {\n d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();\n const width = exportDims?.width ?? container.clientWidth;\n const height = exportDims?.height ?? container.clientHeight;\n if (width <= 0 || height <= 0) return null;\n const textColor = palette.text;\n const mutedColor = palette.border;\n const bgColor = palette.bg;\n const colors = getSeriesColors(palette);\n const svg = d3Selection\n .select(container)\n .append('svg')\n .attr('width', width)\n .attr('height', height)\n .style('background', bgColor);\n return { svg, width, height, textColor, mutedColor, bgColor, colors };\n}\n\n// ============================================================\n// Timeline Date Helper\n// ============================================================\n\n/**\n * Converts a date string (YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM) to a fractional year number.\n */\nexport function parseTimelineDate(s: string): number {\n // Split off optional time component\n const spaceIdx = s.indexOf(' ');\n let datePart = s;\n let hour = 0;\n let minute = 0;\n\n if (spaceIdx !== -1) {\n datePart = s.slice(0, spaceIdx);\n const timePart = s.slice(spaceIdx + 1);\n const timeParts = timePart.split(':');\n if (timeParts.length === 2) {\n hour = parseInt(timeParts[0], 10);\n minute = parseInt(timeParts[1], 10);\n }\n }\n\n const parts = datePart.split('-').map((p) => parseInt(p, 10));\n const year = parts[0];\n const month = parts.length >= 2 ? parts[1] : 1;\n const day = parts.length >= 3 ? parts[2] : 1;\n return (\n year + (month - 1) / 12 + (day - 1) / 365 + hour / 8760 + minute / 525600\n );\n}\n\n/**\n * Adds a duration to a date string and returns the resulting date string.\n * Supports: d (days), w (weeks), m (months), y (years), h (hours), min (minutes)\n * Supports decimals up to 2 places (e.g., 1.25y = 1 year 3 months)\n * Preserves the precision of the input date (YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM).\n */\nexport function addDurationToDate(\n startDate: string,\n amount: number,\n unit: 'd' | 'w' | 'm' | 'y' | 'h' | 'min'\n): string {\n // Split off optional time component\n const spaceIdx = startDate.indexOf(' ');\n let datePart = startDate;\n let hour = 0;\n let minute = 0;\n\n if (spaceIdx !== -1) {\n datePart = startDate.slice(0, spaceIdx);\n const timePart = startDate.slice(spaceIdx + 1);\n const tp = timePart.split(':');\n if (tp.length === 2) {\n hour = parseInt(tp[0], 10);\n minute = parseInt(tp[1], 10);\n }\n }\n\n const parts = datePart.split('-').map((p) => parseInt(p, 10));\n const year = parts[0];\n const month = parts.length >= 2 ? parts[1] : 1;\n const day = parts.length >= 3 ? parts[2] : 1;\n\n const date = new Date(year, month - 1, day, hour, minute);\n\n switch (unit) {\n case 'd':\n date.setDate(date.getDate() + Math.round(amount));\n break;\n case 'w':\n date.setDate(date.getDate() + Math.round(amount * 7));\n break;\n case 'm': {\n const wholeMonths = Math.floor(amount);\n const fractionalDays = Math.round((amount - wholeMonths) * 30);\n date.setMonth(date.getMonth() + wholeMonths);\n if (fractionalDays > 0) {\n date.setDate(date.getDate() + fractionalDays);\n }\n break;\n }\n case 'y': {\n const wholeYears = Math.floor(amount);\n const fractionalMonths = Math.round((amount - wholeYears) * 12);\n date.setFullYear(date.getFullYear() + wholeYears);\n if (fractionalMonths > 0) {\n date.setMonth(date.getMonth() + fractionalMonths);\n }\n break;\n }\n case 'h':\n date.setTime(date.getTime() + amount * 3600000);\n break;\n case 'min':\n date.setTime(date.getTime() + amount * 60000);\n break;\n }\n\n // Preserve original precision\n const endYear = date.getFullYear();\n const endMonth = String(date.getMonth() + 1).padStart(2, '0');\n const endDay = String(date.getDate()).padStart(2, '0');\n const endHour = String(date.getHours()).padStart(2, '0');\n const endMinute = String(date.getMinutes()).padStart(2, '0');\n const hasTime = unit === 'h' || unit === 'min' || spaceIdx !== -1;\n\n if (parts.length === 1) {\n return String(endYear);\n } else if (parts.length === 2) {\n return `${endYear}-${endMonth}`;\n } else if (hasTime && (date.getHours() !== 0 || date.getMinutes() !== 0)) {\n return `${endYear}-${endMonth}-${endDay} ${endHour}:${endMinute}`;\n } else {\n return `${endYear}-${endMonth}-${endDay}`;\n }\n}\n\n// ============================================================\n// Parser\n// ============================================================\n\n/**\n * Parses D3 chart text format into structured data.\n */\nexport function parseVisualization(\n content: string,\n palette?: PaletteColors\n): ParsedVisualization {\n const result: ParsedVisualization = {\n type: null,\n title: null,\n titleLineNumber: null,\n orientation: 'horizontal',\n periods: [],\n data: [],\n words: [],\n cloudOptions: { ...DEFAULT_CLOUD_OPTIONS },\n links: [],\n arcOrder: 'appearance',\n arcNodeGroups: [],\n timelineEvents: [],\n timelineGroups: [],\n timelineEras: [],\n timelineMarkers: [],\n timelineTagGroups: [],\n timelineSort: 'time',\n timelineScale: true,\n timelineSwimlanes: false,\n vennSets: [],\n vennOverlaps: [],\n quadrantLabels: {\n topRight: null,\n topLeft: null,\n bottomLeft: null,\n bottomRight: null,\n },\n quadrantPoints: [],\n quadrantXAxis: null,\n quadrantXAxisLineNumber: null,\n quadrantYAxis: null,\n quadrantYAxisLineNumber: null,\n quadrantTitleLineNumber: null,\n diagnostics: [],\n error: null,\n };\n\n const fail = (line: number, message: string): ParsedVisualization => {\n const diag = makeDgmoError(line, message);\n result.diagnostics.push(diag);\n result.error = formatDgmoError(diag);\n return result;\n };\n\n const warn = (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 const freeformLines: string[] = [];\n let currentArcGroup: string | null = null;\n let currentTimelineGroup: string | null = null;\n let currentTimelineTagGroup: TagGroup | null = null;\n let inTimelineEraBlock = false;\n let timelineEraBlockIndent = 0;\n let inTimelineMarkerBlock = false;\n let timelineMarkerBlockIndent = 0;\n let inSlopePeriodBlock = false;\n const timelineAliasMap = new Map<string, string>();\n const VALID_D3_TYPES = new Set([\n 'slope',\n 'wordcloud',\n 'arc',\n 'timeline',\n 'venn',\n 'quadrant',\n 'sequence',\n ]);\n let firstLineParsed = false;\n\n for (let i = 0; i < lines.length; i++) {\n const rawLine = lines[i];\n const line = rawLine.trim();\n const indent = rawLine.length - rawLine.trimStart().length;\n const lineNumber = i + 1;\n\n // Skip empty lines\n if (!line) continue;\n\n // Skip comments\n if (line.startsWith('//')) continue;\n\n // First non-empty, non-comment line: chart type + optional title\n if (!firstLineParsed) {\n firstLineParsed = true;\n const firstLineResult = parseFirstLine(line);\n if (firstLineResult && VALID_D3_TYPES.has(firstLineResult.chartType)) {\n result.type = firstLineResult.chartType as ParsedVisualization['type'];\n if (firstLineResult.title) {\n result.title = firstLineResult.title;\n result.titleLineNumber = lineNumber;\n }\n continue;\n }\n // Not a bare chart type — fall through to normal parsing\n }\n\n // Timeline tag group heading: `tag Name [alias X]`\n if (result.type === 'timeline' && indent === 0) {\n const tagBlockMatch = matchTagBlockHeading(line);\n if (tagBlockMatch) {\n currentTimelineTagGroup = {\n name: tagBlockMatch.name,\n alias: tagBlockMatch.alias,\n entries: [],\n lineNumber,\n };\n if (tagBlockMatch.alias) {\n timelineAliasMap.set(\n tagBlockMatch.alias.toLowerCase(),\n tagBlockMatch.name.toLowerCase()\n );\n }\n result.timelineTagGroups.push(currentTimelineTagGroup);\n continue;\n }\n }\n\n // Timeline tag group entries (indented under tag heading)\n if (currentTimelineTagGroup && indent > 0) {\n const { text: entryText, isDefault } = stripDefaultModifier(line);\n const { label, color } = extractColor(entryText, palette);\n if (color) {\n if (isDefault) {\n currentTimelineTagGroup.defaultValue = label;\n } else if (currentTimelineTagGroup.entries.length === 0) {\n currentTimelineTagGroup.defaultValue = label;\n }\n currentTimelineTagGroup.entries.push({\n value: label,\n color,\n lineNumber,\n });\n continue;\n }\n }\n\n // End tag group on non-indented line\n if (currentTimelineTagGroup && indent === 0) {\n currentTimelineTagGroup = null;\n }\n\n // [Group] container headers for arc diagram node grouping and timeline eras\n const groupMatch = line.match(/^\\[(.+?)\\](?:\\s*\\(([^)]+)\\))?\\s*$/);\n if (groupMatch) {\n if (result.type === 'arc') {\n const name = groupMatch[1].trim();\n const color = groupMatch[2]\n ? (resolveColorWithDiagnostic(\n groupMatch[2].trim(),\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null;\n result.arcNodeGroups.push({ name, nodes: [], color, lineNumber });\n currentArcGroup = name;\n } else if (result.type === 'timeline') {\n const name = groupMatch[1].trim();\n const color = groupMatch[2]\n ? (resolveColorWithDiagnostic(\n groupMatch[2].trim(),\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null;\n result.timelineGroups.push({ name, color, lineNumber });\n currentTimelineGroup = name;\n }\n continue;\n }\n\n // Reject legacy ## group syntax\n if (\n /^#{2,}\\s+/.test(line) &&\n (result.type === 'arc' || result.type === 'timeline')\n ) {\n const name = line\n .replace(/^#{2,}\\s+/, '')\n .replace(/\\s*\\([^)]*\\)\\s*$/, '')\n .trim();\n result.diagnostics.push(\n makeDgmoError(\n lineNumber,\n `'## ${name}' is no longer supported. Use '[${name}]' instead`,\n 'warning'\n )\n );\n continue;\n }\n\n // Clear group context on un-indented lines (except [Group] already handled above)\n if (indent === 0) {\n currentArcGroup = null;\n currentTimelineGroup = null;\n }\n\n // Arc link line: source -> target(color) weight\n if (result.type === 'arc') {\n const linkMatch = line.match(\n /^(.+?)\\s*->\\s*(.+?)(?:\\(([^)]+)\\))?\\s*(?:\\s+(-?[\\d,_]+(?:\\.[\\d]+)?))?$/\n );\n if (linkMatch) {\n const source = linkMatch[1].trim();\n const target = linkMatch[2].trim();\n const linkColor = linkMatch[3]\n ? (resolveColorWithDiagnostic(\n linkMatch[3].trim(),\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null;\n result.links.push({\n source,\n target,\n value: linkMatch[4]\n ? parseFloat(normalizeNumericToken(linkMatch[4]) ?? linkMatch[4])\n : 1,\n color: linkColor,\n lineNumber,\n });\n // Assign nodes to current group (first-appearance wins)\n if (currentArcGroup !== null) {\n const group = result.arcNodeGroups.find(\n (g) => g.name === currentArcGroup\n );\n if (group) {\n const allGrouped = new Set(\n result.arcNodeGroups.flatMap((g) => g.nodes)\n );\n if (!allGrouped.has(source)) group.nodes.push(source);\n if (!allGrouped.has(target)) group.nodes.push(target);\n }\n }\n continue;\n }\n }\n\n // Timeline era block entries (indented under bare `era`)\n if (result.type === 'timeline' && inTimelineEraBlock) {\n if (indent <= timelineEraBlockIndent) {\n inTimelineEraBlock = false;\n // fall through to process this line normally\n } else {\n if (line.startsWith('//')) continue;\n const eraEntryMatch = line.match(\n /^(\\d{4}(?:-\\d{2})?(?:-\\d{2}(?: \\d{2}:\\d{2})?)?)\\s*(?:->|\\u2013>)\\s*(\\d{4}(?:-\\d{2})?(?:-\\d{2}(?: \\d{2}:\\d{2})?)?)\\s+(.+?)(?:\\s*\\(([^)]+)\\))?\\s*$/\n );\n if (eraEntryMatch) {\n const colorAnnotation = eraEntryMatch[4]?.trim() || null;\n result.timelineEras.push({\n startDate: eraEntryMatch[1],\n endDate: eraEntryMatch[2],\n label: eraEntryMatch[3].trim(),\n color: colorAnnotation\n ? (resolveColorWithDiagnostic(\n colorAnnotation,\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null,\n lineNumber,\n });\n } else {\n warn(lineNumber, `Unrecognized era entry: \"${line}\"`);\n }\n continue;\n }\n }\n\n // Timeline marker block entries (indented under bare `marker`)\n if (result.type === 'timeline' && inTimelineMarkerBlock) {\n if (indent <= timelineMarkerBlockIndent) {\n inTimelineMarkerBlock = false;\n // fall through to process this line normally\n } else {\n if (line.startsWith('//')) continue;\n const markerEntryMatch = line.match(\n /^(\\d{4}(?:-\\d{2})?(?:-\\d{2}(?: \\d{2}:\\d{2})?)?)\\s+(.+?)(?:\\s*\\(([^)]+)\\))?\\s*$/\n );\n if (markerEntryMatch) {\n const colorAnnotation = markerEntryMatch[3]?.trim() || null;\n result.timelineMarkers.push({\n date: markerEntryMatch[1],\n label: markerEntryMatch[2].trim(),\n color: colorAnnotation\n ? (resolveColorWithDiagnostic(\n colorAnnotation,\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null,\n lineNumber,\n });\n } else {\n warn(lineNumber, `Unrecognized marker entry: \"${line}\"`);\n }\n continue;\n }\n }\n\n // Timeline era/marker block starters and inline forms\n if (result.type === 'timeline') {\n // Bare `era` keyword starts a block\n if (line.toLowerCase() === 'era') {\n inTimelineEraBlock = true;\n timelineEraBlockIndent = indent;\n continue;\n }\n\n // Bare `marker` keyword starts a block\n if (line.toLowerCase() === 'marker') {\n inTimelineMarkerBlock = true;\n timelineMarkerBlockIndent = indent;\n continue;\n }\n\n // Timeline era lines (inline): era YYYY->YYYY Label (color)\n const eraMatch = line.match(\n /^era\\s+(\\d{4}(?:-\\d{2})?(?:-\\d{2}(?: \\d{2}:\\d{2})?)?)\\s*(?:->|\\u2013>)\\s*(\\d{4}(?:-\\d{2})?(?:-\\d{2}(?: \\d{2}:\\d{2})?)?)\\s+(.+?)(?:\\s*\\(([^)]+)\\))?\\s*$/\n );\n if (eraMatch) {\n const colorAnnotation = eraMatch[4]?.trim() || null;\n result.timelineEras.push({\n startDate: eraMatch[1],\n endDate: eraMatch[2],\n label: eraMatch[3].trim(),\n color: colorAnnotation\n ? (resolveColorWithDiagnostic(\n colorAnnotation,\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null,\n lineNumber,\n });\n continue;\n }\n\n // Timeline marker lines (inline): marker YYYY Label (color)\n const markerMatch = line.match(\n /^marker\\s+(\\d{4}(?:-\\d{2})?(?:-\\d{2}(?: \\d{2}:\\d{2})?)?)\\s+(.+?)(?:\\s*\\(([^)]+)\\))?\\s*$/\n );\n if (markerMatch) {\n const colorAnnotation = markerMatch[3]?.trim() || null;\n result.timelineMarkers.push({\n date: markerMatch[1],\n label: markerMatch[2].trim(),\n color: colorAnnotation\n ? (resolveColorWithDiagnostic(\n colorAnnotation,\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null,\n lineNumber,\n });\n continue;\n }\n }\n\n // Timeline event lines: duration, range, or point\n if (result.type === 'timeline') {\n // Duration event: 2026-07-15->30d: description (d=days, w=weeks, m=months, y=years, h=hours, min=minutes)\n // Supports decimals up to 2 places (e.g., 1.25y = 1 year 3 months)\n // Supports uncertain end with ? suffix (e.g., ->3m?: fades out the last 20%)\n // Accepts both -> (hyphen) and –> (en-dash U+2013)\n const durationMatch = line.match(\n /^(\\d{4}(?:-\\d{2})?(?:-\\d{2}(?: \\d{2}:\\d{2})?)?)\\s*(?:->|\\u2013>)\\s*(\\d+(?:\\.\\d{1,2})?)(min|[dwmyh])(\\?)?\\s+(.+)$/\n );\n if (durationMatch) {\n const startDate = durationMatch[1];\n const uncertain = durationMatch[4] === '?';\n const amount = parseFloat(durationMatch[2]);\n const unit = durationMatch[3] as 'd' | 'w' | 'm' | 'y' | 'h' | 'min';\n const endDate = addDurationToDate(startDate, amount, unit);\n const segments = durationMatch[5].split('|');\n const metadata =\n segments.length > 1\n ? parsePipeMetadata(\n ['', ...segments.slice(1)],\n timelineAliasMap,\n () => warn(lineNumber, MULTIPLE_PIPE_ERROR)\n )\n : {};\n result.timelineEvents.push({\n date: startDate,\n endDate,\n label: segments[0].trim(),\n group: currentTimelineGroup,\n metadata,\n lineNumber,\n uncertain,\n });\n continue;\n }\n\n // Range event: 1655->1667 description (supports uncertain end: 1655->1667?)\n // Also supports YYYY-MM-DD HH:MM in both start and end dates\n // Accepts both -> (hyphen) and –> (en-dash U+2013)\n const rangeMatch = line.match(\n /^(\\d{4}(?:-\\d{2})?(?:-\\d{2}(?: \\d{2}:\\d{2})?)?)\\s*(?:->|\\u2013>)\\s*(\\d{4}(?:-\\d{2})?(?:-\\d{2}(?: \\d{2}:\\d{2})?)?)(\\?)?\\s+(.+)$/\n );\n if (rangeMatch) {\n const segments = rangeMatch[4].split('|');\n const metadata =\n segments.length > 1\n ? parsePipeMetadata(\n ['', ...segments.slice(1)],\n timelineAliasMap,\n () => warn(lineNumber, MULTIPLE_PIPE_ERROR)\n )\n : {};\n result.timelineEvents.push({\n date: rangeMatch[1],\n endDate: rangeMatch[2],\n label: segments[0].trim(),\n group: currentTimelineGroup,\n metadata,\n lineNumber,\n uncertain: rangeMatch[3] === '?',\n });\n continue;\n }\n\n // Point event: 1718 description\n const pointMatch = line.match(/^(\\d{4}(?:-\\d{2})?(?:-\\d{2})?)\\s+(.+)$/);\n if (pointMatch) {\n const segments = pointMatch[2].split('|');\n const metadata =\n segments.length > 1\n ? parsePipeMetadata(\n ['', ...segments.slice(1)],\n timelineAliasMap,\n () => warn(lineNumber, MULTIPLE_PIPE_ERROR)\n )\n : {};\n result.timelineEvents.push({\n date: pointMatch[1],\n endDate: null,\n label: segments[0].trim(),\n group: currentTimelineGroup,\n metadata,\n lineNumber,\n });\n continue;\n }\n }\n\n // Venn diagram DSL\n if (result.type === 'venn') {\n // Intersection line: \"A + B Label\" / \"A + B\" / \"A + B + C Label\"\n // Also accepts deprecated colon syntax: \"A + B: Label\"\n if (/\\+/.test(line)) {\n // Build lookup of known set names and aliases for label extraction\n const knownSetRefs = new Set<string>();\n for (const s of result.vennSets) {\n knownSetRefs.add(s.name.toLowerCase());\n if (s.alias) knownSetRefs.add(s.alias.toLowerCase());\n }\n\n const segments = line\n .split('+')\n .map((s) => s.trim())\n .filter(Boolean);\n if (segments.length >= 2) {\n // All segments except the last are pure set references\n const rawSets = segments.slice(0, -1);\n const lastSeg = segments[segments.length - 1];\n\n // For the last segment, extract set reference and optional label.\n // Find where the set reference ends and label begins.\n // Try progressively shorter prefixes against known set names/aliases.\n const words = lastSeg.split(/\\s+/);\n let matchLen = 0;\n for (let w = words.length; w >= 1; w--) {\n const candidate = words.slice(0, w).join(' ');\n if (knownSetRefs.has(candidate.toLowerCase())) {\n matchLen = w;\n break;\n }\n }\n let lastSetRef: string;\n let label: string | null;\n if (matchLen > 0) {\n lastSetRef = words.slice(0, matchLen).join(' ');\n label =\n words.length > matchLen ? words.slice(matchLen).join(' ') : null;\n } else {\n // No known set matched — assume first word is the set ref, rest is label\n lastSetRef = words[0];\n label = words.length > 1 ? words.slice(1).join(' ') : null;\n }\n rawSets.push(lastSetRef);\n result.vennOverlaps.push({ sets: rawSets, label, lineNumber });\n continue;\n }\n }\n\n // Set declaration: \"Name(color) alias x\" / \"Name alias x\" / \"Name(color)\" / \"Name\"\n const setDeclMatch = line.match(\n /^([^(:]+?)(?:\\(([^)]+)\\))?(?:\\s+alias\\s+(\\S+))?\\s*$/i\n );\n if (setDeclMatch) {\n const name = setDeclMatch[1].trim();\n const colorName = setDeclMatch[2]?.trim() ?? null;\n let color: string | null = null;\n if (colorName) {\n color =\n resolveColorWithDiagnostic(\n colorName,\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null;\n }\n const alias = setDeclMatch[3]?.trim() ?? null;\n result.vennSets.push({ name, alias, color, lineNumber });\n continue;\n }\n }\n\n // Quadrant-specific parsing\n if (result.type === 'quadrant') {\n // x-label Low, High — or indented multi-line\n const xAxisMatch = line.match(/^x-label\\s+(.*)/i);\n if (xAxisMatch) {\n const val = xAxisMatch[1].trim();\n let parts: string[];\n if (val) {\n parts = val.split(',').map((s) => s.trim());\n } else {\n const collected = collectIndentedValues(lines, i);\n i = collected.newIndex;\n parts = collected.values;\n }\n if (parts.length >= 2) {\n result.quadrantXAxis = [parts[0], parts[1]];\n result.quadrantXAxisLineNumber = lineNumber;\n }\n continue;\n }\n\n // y-label Low, High — or indented multi-line\n const yAxisMatch = line.match(/^y-label\\s+(.*)/i);\n if (yAxisMatch) {\n const val = yAxisMatch[1].trim();\n let parts: string[];\n if (val) {\n parts = val.split(',').map((s) => s.trim());\n } else {\n const collected = collectIndentedValues(lines, i);\n i = collected.newIndex;\n parts = collected.values;\n }\n if (parts.length >= 2) {\n result.quadrantYAxis = [parts[0], parts[1]];\n result.quadrantYAxisLineNumber = lineNumber;\n }\n continue;\n }\n\n // Quadrant position labels: top-right Label (color)\n const quadrantLabelRe =\n /^(top-right|top-left|bottom-left|bottom-right)\\s+(.+)/i;\n const quadrantMatch = line.match(quadrantLabelRe);\n if (quadrantMatch) {\n const position = quadrantMatch[1].toLowerCase();\n const labelPart = quadrantMatch[2].trim();\n // Check for color annotation: \"Label (color)\" or \"Label(color)\"\n const labelColorMatch = labelPart.match(/^(.+?)\\s*\\(([^)]+)\\)\\s*$/);\n const text = labelColorMatch ? labelColorMatch[1].trim() : labelPart;\n const color = labelColorMatch\n ? (resolveColorWithDiagnostic(\n labelColorMatch[2].trim(),\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null;\n const label: QuadrantLabel = { text, color, lineNumber };\n\n if (position === 'top-right') result.quadrantLabels.topRight = label;\n else if (position === 'top-left') result.quadrantLabels.topLeft = label;\n else if (position === 'bottom-left')\n result.quadrantLabels.bottomLeft = label;\n else if (position === 'bottom-right')\n result.quadrantLabels.bottomRight = label;\n continue;\n }\n\n // Data points: Label x, y OR Label x y\n const pointMatch = line.match(\n /^(.+?)\\s+(-?[0-9][0-9,_]*(?:\\.[0-9]+)?)\\s*[,\\s]\\s*(-?[0-9][0-9,_]*(?:\\.[0-9]+)?)\\s*$/\n );\n if (pointMatch) {\n const label = pointMatch[1].trim();\n // Skip if it looks like a quadrant position keyword\n const lowerLabel = label.toLowerCase();\n if (\n lowerLabel !== 'top-right' &&\n lowerLabel !== 'top-left' &&\n lowerLabel !== 'bottom-left' &&\n lowerLabel !== 'bottom-right'\n ) {\n result.quadrantPoints.push({\n label,\n x: parseFloat(\n normalizeNumericToken(pointMatch[2]) ?? pointMatch[2]\n ),\n y: parseFloat(\n normalizeNumericToken(pointMatch[3]) ?? pointMatch[3]\n ),\n lineNumber,\n });\n }\n continue;\n }\n }\n\n // ── Space-separated options (no colon) ──────────────────\n const spaceIdx = line.indexOf(' ');\n if (spaceIdx >= 0) {\n const firstToken = line.substring(0, spaceIdx).toLowerCase();\n const restValue = line.substring(spaceIdx + 1).trim();\n\n if (\n firstToken === 'chart' &&\n VALID_D3_TYPES.has(restValue.toLowerCase())\n ) {\n result.type = restValue.toLowerCase() as ParsedVisualization['type'];\n continue;\n }\n\n if (firstToken === 'title') {\n result.title = restValue;\n result.titleLineNumber = lineNumber;\n if (result.type === 'quadrant') {\n result.quadrantTitleLineNumber = lineNumber;\n }\n continue;\n }\n\n if (firstToken === 'order') {\n const v = restValue.toLowerCase();\n if (v === 'name' || v === 'group' || v === 'degree') {\n result.arcOrder = v;\n }\n continue;\n }\n\n if (firstToken === 'rotate') {\n const v = restValue.toLowerCase();\n if (v === 'none' || v === 'mixed' || v === 'angled') {\n result.cloudOptions.rotate = v;\n }\n continue;\n }\n\n if (firstToken === 'max') {\n const v = parseInt(restValue, 10);\n if (!isNaN(v) && v > 0) {\n result.cloudOptions.max = v;\n }\n continue;\n }\n\n if (firstToken === 'size') {\n const parts = restValue.split(',').map((s) => parseInt(s.trim(), 10));\n if (\n parts.length === 2 &&\n parts.every((n) => !isNaN(n) && n > 0) &&\n parts[0] < parts[1]\n ) {\n result.cloudOptions.minSize = parts[0];\n result.cloudOptions.maxSize = parts[1];\n }\n continue;\n }\n }\n\n // ── Slope chart: period directive + right-scan data rows ──\n if (result.type === 'slope') {\n // Period block: indented lines inside `period` block\n // (blank lines are pre-filtered at loop top, so only non-indented lines close the block)\n if (inSlopePeriodBlock) {\n if (indent > 0) {\n result.periods.push(line);\n continue;\n }\n // Non-indented line → close block, fall through to process normally\n inSlopePeriodBlock = false;\n }\n\n // Period directive: `period Label1 Label2` or bare `period` (block open)\n // Only accept before data rows start (F4: prevent keyword shadowing labels)\n if (result.data.length === 0) {\n const periodMatch = line.match(/^period\\b(.*)$/i);\n if (periodMatch) {\n if (result.periods.length > 0 && !inSlopePeriodBlock) {\n // F5: warn on duplicate period directives\n warn(\n lineNumber,\n `Duplicate 'period' directive — periods are already defined`\n );\n }\n const rest = periodMatch[1].trim();\n if (rest) {\n // One-line: `period 1715 1725`\n const periodLabels = rest.split(/\\s+/);\n result.periods.push(...periodLabels);\n } else {\n // Block open: bare `period`\n inSlopePeriodBlock = true;\n }\n continue;\n }\n }\n\n // Migration error: bare period line (old syntax — comma-separated, no keyword)\n // F1: Only fire when ALL comma-separated tokens are short (≤20 chars) and non-empty\n if (\n result.periods.length === 0 &&\n line.includes(',') &&\n !line.includes(':')\n ) {\n const tokens = line\n .split(',')\n .map((t) => t.trim())\n .filter(Boolean);\n const looksLikePeriods =\n tokens.length >= 2 && tokens.every((t) => t.length <= 20);\n if (looksLikePeriods) {\n return fail(\n lineNumber,\n `Period lines require the 'period' keyword — use 'period ${tokens.join(' ')}'`\n );\n }\n }\n\n // Migration error: old colon syntax in data rows\n // F2: Only fire when content after colon is predominantly numeric (old \"Label: val1, val2\" pattern)\n if (line.includes(':')) {\n const colonPos = line.indexOf(':');\n const afterColon = line.substring(colonPos + 1).trim();\n const numericTokens = afterColon\n .split(/[,\\s]+/)\n .filter((v) => /^-?\\d/.test(v));\n // Only trigger if most tokens after the colon are numeric (old data pattern)\n if (numericTokens.length >= 1) {\n const allTokens = afterColon.split(/[,\\s]+/).filter(Boolean);\n if (numericTokens.length >= allTokens.length * 0.5) {\n const label = line.substring(0, colonPos).trim();\n return fail(\n lineNumber,\n `Colons are no longer used in slope data rows — use '${label} ${numericTokens.join(' ')}'`\n );\n }\n }\n }\n\n // Right-scan data row parsing (requires periods to be known)\n if (result.periods.length >= 2) {\n const P = result.periods.length;\n const tokens = line.split(/\\s+/);\n const values: number[] = [];\n\n // Scan from right, capped at P values\n let rightIdx = tokens.length - 1;\n while (rightIdx >= 0 && values.length < P) {\n const raw =\n normalizeNumericToken(tokens[rightIdx]) ?? tokens[rightIdx];\n const num = parseFloat(raw);\n if (!isNaN(num) && /^-?\\d/.test(raw)) {\n values.unshift(num);\n rightIdx--;\n } else {\n break;\n }\n }\n\n if (values.length < P) {\n warn(\n lineNumber,\n `Data row has ${values.length} numeric value(s) but ${P} period(s) are defined — expected ${P} values`\n );\n continue;\n }\n\n // Remaining left tokens = label\n const labelTokens = tokens.slice(0, rightIdx + 1);\n const joinedLabel = labelTokens.join(' ');\n\n if (!joinedLabel) {\n warn(\n lineNumber,\n `Data row has no label — add a label before the numeric values`\n );\n continue;\n }\n\n // Color annotation: `Label (color)` → extract color\n const colorMatch = joinedLabel.match(/^(.+?)\\(([^)]+)\\)\\s*$/);\n const labelPart = colorMatch ? colorMatch[1].trim() : joinedLabel;\n const colorPart = colorMatch\n ? (resolveColorWithDiagnostic(\n colorMatch[2].trim(),\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null;\n\n if (!labelPart) {\n warn(\n lineNumber,\n `Data row has no label — add a label before the numeric values`\n );\n continue;\n }\n\n // F3: Warn on purely numeric labels — likely a mistake\n if (/^\\d[\\d,.]*$/.test(labelPart)) {\n warn(\n lineNumber,\n `Label '${labelPart}' looks numeric — this may indicate too many values or a missing label`\n );\n }\n\n result.data.push({\n label: labelPart,\n values,\n color: colorPart,\n lineNumber,\n });\n continue;\n }\n\n // If we get here in a slope chart, it's an unrecognized line\n if (firstLineParsed) {\n warn(lineNumber, `Unexpected line: '${line}'.`);\n }\n continue;\n }\n\n // ── Colon-separated metadata / options (legacy + data lines) ──\n const colonIndex = line.indexOf(':');\n\n if (colonIndex !== -1) {\n const rawKey = line.substring(0, colonIndex).trim();\n const key = rawKey.toLowerCase();\n\n // Check for color annotation in raw key: \"Label(color)\"\n const colorMatch = rawKey.match(/^(.+?)\\(([^)]+)\\)\\s*$/);\n\n if (key === 'title') {\n result.title = line.substring(colonIndex + 1).trim();\n result.titleLineNumber = lineNumber;\n if (result.type === 'quadrant') {\n result.quadrantTitleLineNumber = lineNumber;\n }\n continue;\n }\n\n if (key === 'order') {\n const v = line\n .substring(colonIndex + 1)\n .trim()\n .toLowerCase();\n if (v === 'name' || v === 'group' || v === 'degree') {\n result.arcOrder = v;\n }\n continue;\n }\n\n if (key === 'rotate') {\n const v = line\n .substring(colonIndex + 1)\n .trim()\n .toLowerCase();\n if (v === 'none' || v === 'mixed' || v === 'angled') {\n result.cloudOptions.rotate = v;\n }\n continue;\n }\n\n if (key === 'max') {\n const v = parseInt(line.substring(colonIndex + 1).trim(), 10);\n if (!isNaN(v) && v > 0) {\n result.cloudOptions.max = v;\n }\n continue;\n }\n\n if (key === 'size') {\n const v = line.substring(colonIndex + 1).trim();\n const parts = v.split(',').map((s) => parseInt(s.trim(), 10));\n if (\n parts.length === 2 &&\n parts.every((n) => !isNaN(n) && n > 0) &&\n parts[0] < parts[1]\n ) {\n result.cloudOptions.minSize = parts[0];\n result.cloudOptions.maxSize = parts[1];\n }\n continue;\n }\n\n // Data line: \"Label: value1, value2\" or \"Label(color): value1, value2\"\n const labelPart = colorMatch ? colorMatch[1].trim() : rawKey;\n const colorPart = colorMatch\n ? (resolveColorWithDiagnostic(\n colorMatch[2].trim(),\n lineNumber,\n result.diagnostics,\n palette\n ) ?? null)\n : null;\n const valuePart = line.substring(colonIndex + 1).trim();\n const values = valuePart.split(',').map((v) => v.trim());\n\n // Check if this looks like a data line (values should be numeric)\n const numericValues: number[] = [];\n let allNumeric = true;\n for (const v of values) {\n const num = parseFloat(v);\n if (isNaN(num)) {\n allNumeric = false;\n break;\n }\n numericValues.push(num);\n }\n\n if (allNumeric && numericValues.length > 0) {\n // Wordcloud does not use colon data format — skip to freeform handling\n if (result.type !== 'wordcloud') {\n result.data.push({\n label: labelPart,\n values: numericValues,\n color: colorPart,\n lineNumber,\n });\n continue;\n }\n }\n }\n\n // For wordcloud: collect non-metadata lines for freeform fallback\n if (result.type === 'wordcloud') {\n if (colonIndex === -1 && !line.includes(' ')) {\n // Single bare word — structured mode\n result.words.push({ text: line, weight: 10, lineNumber });\n } else if (colonIndex === -1) {\n // Try \"word weight\" or \"multi-word-label weight\" space-separated format\n const lastSpace = line.lastIndexOf(' ');\n const rawWeight = lastSpace >= 0 ? line.substring(lastSpace + 1) : '';\n const maybeWeight =\n lastSpace >= 0\n ? parseFloat(normalizeNumericToken(rawWeight) ?? rawWeight)\n : NaN;\n if (lastSpace >= 0 && !isNaN(maybeWeight) && maybeWeight > 0) {\n result.words.push({\n text: line.substring(0, lastSpace).trim(),\n weight: maybeWeight,\n lineNumber,\n });\n } else {\n freeformLines.push(line);\n }\n } else {\n // Non-numeric colon line — freeform text\n freeformLines.push(line);\n }\n continue;\n }\n\n // Catch-all: nothing matched this line\n // Skip on first line — chart type suggestion is handled post-loop\n if (firstLineParsed) {\n warn(lineNumber, `Unexpected line: '${line}'.`);\n }\n }\n\n // Validation\n if (!result.type) {\n const validD3Types = [...VALID_D3_TYPES];\n const firstNonEmpty =\n lines.find((l) => l.trim() && !l.trim().startsWith('//'))?.trim() ?? '';\n const hint = suggest(\n firstNonEmpty.split(/\\s/)[0].toLowerCase(),\n validD3Types\n );\n let msg = `Unsupported chart type: \"${firstNonEmpty.split(/\\s/)[0]}\". Supported types: ${validD3Types.join(', ')}`;\n if (hint) msg += `. ${hint}`;\n return fail(1, msg);\n }\n\n // Sequence diagrams are parsed by their own dedicated parser\n if (result.type === 'sequence') {\n return result;\n }\n\n if (result.type === 'wordcloud') {\n // If no structured words were found, parse freeform text as word frequencies\n if (result.words.length === 0 && freeformLines.length > 0) {\n result.words = tokenizeFreeformText(freeformLines.join(' '));\n }\n if (result.words.length === 0) {\n warn(\n 1,\n 'No words found. Add words as \"word weight\" (space-separated), one per line, or paste freeform text'\n );\n }\n // Apply max word limit (words are already sorted by weight desc for freeform)\n if (\n result.cloudOptions.max > 0 &&\n result.words.length > result.cloudOptions.max\n ) {\n result.words = result.words\n .slice()\n .sort((a, b) => b.weight - a.weight)\n .slice(0, result.cloudOptions.max);\n }\n return result;\n }\n\n if (result.type === 'arc') {\n if (result.links.length === 0) {\n warn(\n 1,\n 'No links found. Add links as \"Source -> Target weight\" (e.g., \"Alice -> Bob 5\")'\n );\n }\n // Validate arc ordering vs groups\n if (result.arcNodeGroups.length > 0) {\n if (result.arcOrder === 'name' || result.arcOrder === 'degree') {\n warn(\n 1,\n `Cannot use \"order ${result.arcOrder}\" with [Group] headers. Use \"order group\" or remove group headers.`\n );\n result.arcOrder = 'group';\n }\n if (result.arcOrder === 'appearance') {\n result.arcOrder = 'group';\n }\n }\n return result;\n }\n\n if (result.type === 'timeline') {\n if (result.timelineEvents.length === 0) {\n warn(\n 1,\n 'No events found. Add events as \"YYYY: description\" or \"YYYY->YYYY: description\"'\n );\n }\n // Validate tag values and inject defaults\n if (result.timelineTagGroups.length > 0) {\n validateTagValues(\n result.timelineEvents,\n result.timelineTagGroups,\n (line, msg) =>\n result.diagnostics.push(makeDgmoError(line, msg, 'warning')),\n suggest\n );\n validateTagGroupNames(\n result.timelineTagGroups,\n (line, msg) =>\n result.diagnostics.push(makeDgmoError(line, msg, 'warning')),\n (line, msg) => {\n const diag = makeDgmoError(line, msg);\n result.diagnostics.push(diag);\n if (!result.error) result.error = formatDgmoError(diag);\n }\n );\n for (const group of result.timelineTagGroups) {\n if (!group.defaultValue) continue;\n const key = group.name.toLowerCase();\n for (const event of result.timelineEvents) {\n if (!event.metadata[key]) {\n event.metadata[key] = group.defaultValue;\n }\n }\n }\n }\n\n return result;\n }\n\n if (result.type === 'venn') {\n if (result.vennSets.length < 2) {\n return fail(\n 1,\n 'At least 2 sets are required. Add set names (e.g., \"Apples\", \"Oranges\")'\n );\n }\n if (result.vennSets.length > 3) {\n return fail(1, 'Venn diagrams support 2–3 sets');\n }\n // Build lookup: full name (lowercase) and alias → canonical name\n const setNameLower = new Map<string, string>(\n result.vennSets.map((s) => [s.name.toLowerCase(), s.name])\n );\n const aliasLower = new Map<string, string>();\n for (const s of result.vennSets) {\n if (s.alias) aliasLower.set(s.alias.toLowerCase(), s.name);\n }\n const resolveSetRef = (ref: string): string | null =>\n setNameLower.get(ref.toLowerCase()) ??\n aliasLower.get(ref.toLowerCase()) ??\n null;\n\n // Resolve intersection set references; drop invalid ones with a diagnostic\n const validOverlaps: VennOverlap[] = [];\n for (const ov of result.vennOverlaps) {\n const resolvedSets: string[] = [];\n let valid = true;\n for (const ref of ov.sets) {\n const resolved = resolveSetRef(ref);\n if (!resolved) {\n result.diagnostics.push(\n makeDgmoError(\n ov.lineNumber,\n `Intersection references unknown set or alias \"${ref}\"`\n )\n );\n if (!result.error)\n result.error = formatDgmoError(\n result.diagnostics[result.diagnostics.length - 1]\n );\n valid = false;\n break;\n }\n resolvedSets.push(resolved);\n }\n if (valid) validOverlaps.push({ ...ov, sets: resolvedSets.sort() });\n }\n result.vennOverlaps = validOverlaps;\n return result;\n }\n\n if (result.type === 'quadrant') {\n if (result.quadrantPoints.length === 0) {\n warn(\n 1,\n 'No data points found. Add points as \"Label x, y\" (e.g., \"Item A 0.5, 0.7\")'\n );\n }\n return result;\n }\n\n // Slope chart validation\n if (result.periods.length < 2) {\n return fail(\n 1,\n \"Missing 'period' directive. Add 'period 2020 2024' before data rows (minimum 2 periods required)\"\n );\n }\n\n if (result.data.length === 0) {\n warn(\n 1,\n \"No data lines found. Add data as 'Label value1 value2' (e.g., 'Blackbeard 40 4')\"\n );\n }\n\n // Validate value counts match period count — warn and skip mismatched items\n for (const item of result.data) {\n if (item.values.length !== result.periods.length) {\n warn(\n item.lineNumber,\n `Data item \"${item.label}\" has ${item.values.length} value(s) but ${result.periods.length} period(s) are defined`\n );\n }\n }\n result.data = result.data.filter(\n (item) => item.values.length === result.periods.length\n );\n\n return result;\n}\n\n// ============================================================\n// Freeform Text Tokenizer (for word cloud)\n// ============================================================\n\nconst STOP_WORDS = new Set([\n 'a',\n 'an',\n 'the',\n 'and',\n 'or',\n 'but',\n 'in',\n 'on',\n 'at',\n 'to',\n 'for',\n 'of',\n 'with',\n 'by',\n 'is',\n 'am',\n 'are',\n 'was',\n 'were',\n 'be',\n 'been',\n 'being',\n 'have',\n 'has',\n 'had',\n 'do',\n 'does',\n 'did',\n 'will',\n 'would',\n 'could',\n 'should',\n 'may',\n 'might',\n 'shall',\n 'can',\n 'it',\n 'its',\n 'this',\n 'that',\n 'these',\n 'those',\n 'i',\n 'me',\n 'my',\n 'we',\n 'us',\n 'our',\n 'you',\n 'your',\n 'he',\n 'him',\n 'his',\n 'she',\n 'her',\n 'they',\n 'them',\n 'their',\n 'what',\n 'which',\n 'who',\n 'whom',\n 'how',\n 'when',\n 'where',\n 'why',\n 'not',\n 'no',\n 'nor',\n 'so',\n 'if',\n 'then',\n 'than',\n 'too',\n 'very',\n 'just',\n 'about',\n 'up',\n 'out',\n 'from',\n 'into',\n 'over',\n 'after',\n 'before',\n 'between',\n 'under',\n 'again',\n 'there',\n 'here',\n 'all',\n 'each',\n 'every',\n 'both',\n 'few',\n 'more',\n 'most',\n 'other',\n 'some',\n 'such',\n 'only',\n 'own',\n 'same',\n 'also',\n 'as',\n 'because',\n 'until',\n 'while',\n 'during',\n 'through',\n]);\n\nfunction tokenizeFreeformText(text: string): WordCloudWord[] {\n const counts = new Map<string, number>();\n\n // Split on non-letter/non-apostrophe chars, lowercase everything\n const tokens = text\n .toLowerCase()\n .split(/[^a-zA-Z']+/)\n .filter(Boolean);\n\n for (const raw of tokens) {\n // Strip leading/trailing apostrophes\n const word = raw.replace(/^'+|'+$/g, '');\n if (word.length < 2 || STOP_WORDS.has(word)) continue;\n counts.set(word, (counts.get(word) ?? 0) + 1);\n }\n\n return Array.from(counts.entries())\n .map(([text, count]) => ({ text, weight: count, lineNumber: 0 }))\n .sort((a, b) => b.weight - a.weight);\n}\n\n// ============================================================\n// Slope Chart Renderer\n// ============================================================\n\n/**\n * Resolves vertical label collisions by nudging overlapping items apart.\n * Takes items with a naturalY (center) and height, returns adjusted center Y positions.\n * Optional maxY clamps the bottom edge so labels don't overflow the chart area.\n */\nexport function resolveVerticalCollisions(\n items: { naturalY: number; height: number }[],\n minGap: number,\n maxY?: number\n): number[] {\n if (items.length === 0) return [];\n const sorted = items\n .map((it, i) => ({ ...it, idx: i }))\n .sort((a, b) => a.naturalY - b.naturalY);\n const adjustedY = new Array<number>(items.length);\n let prevBottom = -Infinity;\n for (const item of sorted) {\n const halfH = item.height / 2;\n let top = Math.max(item.naturalY - halfH, prevBottom + minGap);\n // Clamp so the label bottom doesn't exceed maxY\n if (maxY !== undefined) {\n top = Math.min(top, maxY - item.height);\n }\n adjustedY[item.idx] = top + halfH;\n prevBottom = top + item.height;\n }\n return adjustedY;\n}\n\nconst SLOPE_MARGIN = { top: 80, bottom: 40, left: 80 };\nconst SLOPE_LABEL_FONT_SIZE = 14;\nconst SLOPE_CHAR_WIDTH = 8; // approximate px per character at 14px\n\n/**\n * Renders a slope chart into the given container using D3.\n */\nexport function renderSlopeChart(\n container: HTMLDivElement,\n parsed: ParsedVisualization,\n palette: PaletteColors,\n isDark: boolean,\n onClickItem?: (lineNumber: number) => void,\n exportDims?: D3ExportDimensions\n): void {\n const { periods, data, title } = parsed;\n if (data.length === 0 || periods.length < 2) return;\n\n const init = initD3Chart(container, palette, exportDims);\n if (!init) return;\n const { svg, width, height, textColor, mutedColor, bgColor, colors } = init;\n\n // Compute right margin from the longest end-of-line label\n const maxLabelText = data.reduce((longest, item) => {\n const text = `${item.values[item.values.length - 1]} — ${item.label}`;\n return text.length > longest.length ? text : longest;\n }, '');\n const estimatedLabelWidth = maxLabelText.length * SLOPE_CHAR_WIDTH;\n const maxRightMargin = Math.floor(width * 0.35);\n const rightMargin = Math.min(\n Math.max(estimatedLabelWidth + 30, 120),\n maxRightMargin\n );\n\n const innerWidth = width - SLOPE_MARGIN.left - rightMargin;\n const innerHeight = height - SLOPE_MARGIN.top - SLOPE_MARGIN.bottom;\n\n // Scales\n const allValues = data.flatMap((d) => d.values);\n const [minVal, maxVal] = d3Array.extent(allValues) as [number, number];\n const valuePadding = (maxVal - minVal) * 0.1 || 1;\n\n const yScale = d3Scale\n .scaleLinear()\n .domain([minVal - valuePadding, maxVal + valuePadding])\n .range([innerHeight, 0]);\n\n const xScale = d3Scale\n .scalePoint<string>()\n .domain(periods)\n .range([0, innerWidth])\n .padding(0);\n\n const g = svg\n .append('g')\n .attr('transform', `translate(${SLOPE_MARGIN.left},${SLOPE_MARGIN.top})`);\n\n // Tooltip\n const tooltip = createTooltip(container, palette, isDark);\n\n // Title\n renderChartTitle(\n svg,\n title,\n parsed.titleLineNumber,\n width,\n textColor,\n onClickItem\n );\n\n // Period column headers\n for (const period of periods) {\n const x = xScale(period)!;\n g.append('text')\n .attr('x', x)\n .attr('y', -15)\n .attr('text-anchor', 'middle')\n .attr('fill', textColor)\n .attr('font-size', '18px')\n .attr('font-weight', '600')\n .text(period);\n\n // Vertical guide line\n g.append('line')\n .attr('x1', x)\n .attr('y1', 0)\n .attr('x2', x)\n .attr('y2', innerHeight)\n .attr('stroke', mutedColor)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '4,4');\n }\n\n // Line generator\n const lineGen = d3Shape\n .line<number>()\n .x((_d, i) => xScale(periods[i])!)\n .y((d) => yScale(d));\n\n // Pre-compute per-series data for label collision resolution\n const seriesInfo = data.map((item, idx) => {\n const color = item.color ?? colors[idx % colors.length];\n const firstVal = item.values[0];\n const lastVal = item.values[item.values.length - 1];\n const absChange = lastVal - firstVal;\n const pctChange = firstVal !== 0 ? (absChange / firstVal) * 100 : null;\n const sign = absChange > 0 ? '+' : '';\n const tipLines = [`${sign}${parseFloat(absChange.toFixed(2))}`];\n if (pctChange !== null) tipLines.push(`${sign}${pctChange.toFixed(1)}%`);\n const tipHtml = tipLines.join('<br>');\n\n // Compute right-side label text and wrapping info\n const lastX = xScale(periods[periods.length - 1])!;\n const labelText = `${lastVal} — ${item.label}`;\n const availableWidth = rightMargin - 15;\n const maxChars = Math.floor(availableWidth / SLOPE_CHAR_WIDTH);\n\n let labelLineCount = 1;\n let wrappedLines: string[] | null = null;\n if (labelText.length > maxChars) {\n const words = labelText.split(/\\s+/);\n const lines: string[] = [];\n let current = '';\n for (const word of words) {\n const test = current ? `${current} ${word}` : word;\n if (test.length > maxChars && current) {\n lines.push(current);\n current = word;\n } else {\n current = test;\n }\n }\n if (current) lines.push(current);\n labelLineCount = lines.length;\n wrappedLines = lines;\n }\n const lineHeight = SLOPE_LABEL_FONT_SIZE * 1.2;\n const labelHeight =\n labelLineCount === 1\n ? SLOPE_LABEL_FONT_SIZE\n : labelLineCount * lineHeight;\n\n return {\n item,\n idx,\n color,\n firstVal,\n lastVal,\n tipHtml,\n lastX,\n labelText,\n maxChars,\n wrappedLines,\n labelHeight,\n };\n });\n\n // --- Resolve left-side label collisions per non-last period column ---\n const leftLabelHeight = 20; // 16px font needs ~20px to avoid glyph overlap\n const leftLabelCollisions: Map<number, number[]> = new Map();\n for (let pi = 0; pi < periods.length - 1; pi++) {\n const entries = data.map((item) => ({\n naturalY: yScale(item.values[pi]),\n height: leftLabelHeight,\n }));\n leftLabelCollisions.set(\n pi,\n resolveVerticalCollisions(entries, 4, innerHeight)\n );\n }\n\n // --- Resolve right-side label collisions ---\n const rightEntries = seriesInfo.map((si) => ({\n naturalY: yScale(si.lastVal),\n height: Math.max(si.labelHeight, SLOPE_LABEL_FONT_SIZE * 1.4),\n }));\n const rightAdjustedY = resolveVerticalCollisions(\n rightEntries,\n 4,\n innerHeight\n );\n\n // Render each data series\n data.forEach((item, idx) => {\n const si = seriesInfo[idx];\n const color = si.color;\n\n // Wrap each series in a group with data-line-number for sync adapter\n const seriesG = g\n .append('g')\n .attr('class', 'slope-series')\n .attr('data-line-number', String(item.lineNumber));\n\n // Line\n seriesG\n .append('path')\n .datum(item.values)\n .attr('fill', 'none')\n .attr('stroke', color)\n .attr('stroke-width', 2.5)\n .attr('d', lineGen);\n\n // Invisible wider path for easier hover targeting\n seriesG\n .append('path')\n .datum(item.values)\n .attr('fill', 'none')\n .attr('stroke', 'transparent')\n .attr('stroke-width', 14)\n .attr('d', lineGen)\n .style('cursor', onClickItem ? 'pointer' : 'default')\n .on('mouseenter', (event: MouseEvent) =>\n showTooltip(tooltip, si.tipHtml, event)\n )\n .on('mousemove', (event: MouseEvent) =>\n showTooltip(tooltip, si.tipHtml, event)\n )\n .on('mouseleave', () => hideTooltip(tooltip))\n .on('click', () => {\n if (onClickItem && item.lineNumber) onClickItem(item.lineNumber);\n });\n\n // Points and value labels\n item.values.forEach((val, i) => {\n const x = xScale(periods[i])!;\n const y = yScale(val);\n\n // Point circle\n seriesG\n .append('circle')\n .attr('cx', x)\n .attr('cy', y)\n .attr('r', 4)\n .attr('fill', color)\n .attr('stroke', bgColor)\n .attr('stroke-width', 1.5)\n .style('cursor', onClickItem ? 'pointer' : 'default')\n .on('mouseenter', (event: MouseEvent) =>\n showTooltip(tooltip, si.tipHtml, event)\n )\n .on('mousemove', (event: MouseEvent) =>\n showTooltip(tooltip, si.tipHtml, event)\n )\n .on('mouseleave', () => hideTooltip(tooltip))\n .on('click', () => {\n if (onClickItem && item.lineNumber) onClickItem(item.lineNumber);\n });\n\n // Value label — skip last point (shown in series label instead)\n const isFirst = i === 0;\n const isLast = i === periods.length - 1;\n if (!isLast) {\n const adjustedY = leftLabelCollisions.get(i)![idx];\n seriesG\n .append('text')\n .attr('x', isFirst ? x - 10 : x)\n .attr('y', adjustedY)\n .attr('dy', '0.35em')\n .attr('text-anchor', isFirst ? 'end' : 'middle')\n .attr('fill', color)\n .attr('font-size', '16px')\n .text(val.toString());\n }\n });\n\n // Series label with value at end of line — wraps if it exceeds available space\n const adjustedLastY = rightAdjustedY[idx];\n\n const labelEl = seriesG\n .append('text')\n .attr('x', si.lastX + 10)\n .attr('y', adjustedLastY)\n .attr('text-anchor', 'start')\n .attr('fill', color)\n .attr('font-size', `${SLOPE_LABEL_FONT_SIZE}px`)\n .attr('font-weight', '500');\n\n if (!si.wrappedLines) {\n labelEl.attr('dy', '0.35em').text(si.labelText);\n } else {\n const lineHeight = SLOPE_LABEL_FONT_SIZE * 1.2;\n const totalHeight = (si.wrappedLines.length - 1) * lineHeight;\n const startDy = -totalHeight / 2;\n\n si.wrappedLines.forEach((line, li) => {\n labelEl\n .append('tspan')\n .attr('x', si.lastX + 10)\n .attr(\n 'dy',\n li === 0\n ? `${startDy + SLOPE_LABEL_FONT_SIZE * 0.35}px`\n : `${lineHeight}px`\n )\n .text(line);\n });\n }\n });\n}\n\n// ============================================================\n// Arc Node Ordering\n// ============================================================\n\n/**\n * Orders arc diagram nodes based on the selected ordering strategy.\n */\nexport function orderArcNodes(\n links: ArcLink[],\n order: ArcOrder,\n groups: ArcNodeGroup[]\n): string[] {\n // Collect all unique nodes in first-appearance order\n const nodeSet = new Set<string>();\n for (const link of links) {\n nodeSet.add(link.source);\n nodeSet.add(link.target);\n }\n const allNodes = Array.from(nodeSet);\n\n if (order === 'name') {\n return allNodes.slice().sort((a, b) => a.localeCompare(b));\n }\n\n if (order === 'degree') {\n const degree = new Map<string, number>();\n for (const node of allNodes) degree.set(node, 0);\n for (const link of links) {\n degree.set(link.source, degree.get(link.source)! + link.value);\n degree.set(link.target, degree.get(link.target)! + link.value);\n }\n return allNodes.slice().sort((a, b) => {\n const diff = degree.get(b)! - degree.get(a)!;\n return diff !== 0 ? diff : a.localeCompare(b);\n });\n }\n\n if (order === 'group') {\n if (groups.length > 0) {\n // Explicit groups: order by ## header order, appearance within each group\n const ordered: string[] = [];\n const placed = new Set<string>();\n for (const group of groups) {\n for (const node of group.nodes) {\n if (!placed.has(node)) {\n ordered.push(node);\n placed.add(node);\n }\n }\n }\n // Orphans at end in first-appearance order\n for (const node of allNodes) {\n if (!placed.has(node)) {\n ordered.push(node);\n placed.add(node);\n }\n }\n return ordered;\n }\n // No explicit groups: connectivity clustering via BFS\n const adj = new Map<string, Set<string>>();\n for (const node of allNodes) adj.set(node, new Set());\n for (const link of links) {\n adj.get(link.source)!.add(link.target);\n adj.get(link.target)!.add(link.source);\n }\n\n const degree = new Map<string, number>();\n for (const node of allNodes) degree.set(node, 0);\n for (const link of links) {\n degree.set(link.source, degree.get(link.source)! + link.value);\n degree.set(link.target, degree.get(link.target)! + link.value);\n }\n\n const visited = new Set<string>();\n const components: string[][] = [];\n\n const remaining = new Set(allNodes);\n while (remaining.size > 0) {\n // Pick highest-degree unvisited node as BFS root\n let root = '';\n let maxDeg = -1;\n for (const node of remaining) {\n if (degree.get(node)! > maxDeg) {\n maxDeg = degree.get(node)!;\n root = node;\n }\n }\n // BFS\n const component: string[] = [];\n const queue = [root];\n visited.add(root);\n remaining.delete(root);\n while (queue.length > 0) {\n const curr = queue.shift()!;\n component.push(curr);\n for (const neighbor of adj.get(curr)!) {\n if (!visited.has(neighbor)) {\n visited.add(neighbor);\n remaining.delete(neighbor);\n queue.push(neighbor);\n }\n }\n }\n components.push(component);\n }\n // Sort components by size descending\n components.sort((a, b) => b.length - a.length);\n return components.flat();\n }\n\n // 'appearance' — first-appearance order (default)\n return allNodes;\n}\n\n// ============================================================\n// Arc Diagram Renderer\n// ============================================================\n\nconst ARC_MARGIN = { top: 60, right: 40, bottom: 60, left: 40 };\n\n/**\n * Renders an arc diagram into the given container using D3.\n */\nexport function renderArcDiagram(\n container: HTMLDivElement,\n parsed: ParsedVisualization,\n palette: PaletteColors,\n _isDark: boolean,\n onClickItem?: (lineNumber: number) => void,\n exportDims?: D3ExportDimensions\n): void {\n const { links, title, orientation, arcOrder, arcNodeGroups } = parsed;\n if (links.length === 0) return;\n\n const init = initD3Chart(container, palette, exportDims);\n if (!init) return;\n const { svg, width, height, textColor, mutedColor, bgColor, colors } = init;\n\n const isVertical = orientation === 'vertical';\n const margin = isVertical\n ? {\n top: ARC_MARGIN.top,\n right: ARC_MARGIN.right,\n bottom: ARC_MARGIN.bottom,\n left: 120,\n }\n : ARC_MARGIN;\n\n const innerWidth = width - margin.left - margin.right;\n const innerHeight = height - margin.top - margin.bottom;\n\n // Order nodes by selected strategy\n const nodes = orderArcNodes(links, arcOrder, arcNodeGroups);\n\n // Build node color map from group colors\n const nodeColorMap = new Map<string, string>();\n for (const group of arcNodeGroups) {\n if (group.color) {\n for (const node of group.nodes) {\n if (!nodeColorMap.has(node)) {\n nodeColorMap.set(node, group.color);\n }\n }\n }\n }\n\n // Build group-to-nodes lookup for group hover\n const groupNodeSets = new Map<string, Set<string>>();\n for (const group of arcNodeGroups) {\n groupNodeSets.set(group.name, new Set(group.nodes));\n }\n\n // Scales\n const values = links.map((l) => l.value);\n const [minVal, maxVal] = d3Array.extent(values) as [number, number];\n const strokeScale = d3Scale\n .scaleLinear()\n .domain([minVal, maxVal])\n .range([1.5, 6]);\n\n const g = svg\n .append('g')\n .attr('transform', `translate(${margin.left},${margin.top})`);\n\n // Title\n renderChartTitle(\n svg,\n title,\n parsed.titleLineNumber,\n width,\n textColor,\n onClickItem\n );\n\n // Build adjacency map for hover interactions\n const neighbors = new Map<string, Set<string>>();\n for (const node of nodes) neighbors.set(node, new Set());\n for (const link of links) {\n neighbors.get(link.source)!.add(link.target);\n neighbors.get(link.target)!.add(link.source);\n }\n\n const FADE_OPACITY = 0.1;\n\n function handleMouseEnter(hovered: string) {\n const connected = neighbors.get(hovered)!;\n\n g.selectAll<SVGPathElement, unknown>('.arc-link').each(function () {\n const el = d3Selection.select(this);\n const src = el.attr('data-source');\n const tgt = el.attr('data-target');\n const isRelated = src === hovered || tgt === hovered;\n el.attr('stroke-opacity', isRelated ? 0.85 : FADE_OPACITY);\n });\n\n g.selectAll<SVGGElement, unknown>('.arc-node').each(function () {\n const el = d3Selection.select(this);\n const name = el.attr('data-node');\n const isRelated = name === hovered || connected.has(name!);\n el.attr('opacity', isRelated ? 1 : FADE_OPACITY);\n });\n }\n\n function handleMouseLeave() {\n g.selectAll<SVGPathElement, unknown>('.arc-link').attr(\n 'stroke-opacity',\n 0.7\n );\n g.selectAll<SVGGElement, unknown>('.arc-node').attr('opacity', 1);\n g.selectAll<SVGRectElement, unknown>('.arc-group-band').attr(\n 'fill-opacity',\n 0.06\n );\n g.selectAll<SVGTextElement, unknown>('.arc-group-label').attr(\n 'fill-opacity',\n 0.5\n );\n }\n\n function handleGroupEnter(groupName: string) {\n const members = groupNodeSets.get(groupName);\n if (!members) return;\n\n g.selectAll<SVGPathElement, unknown>('.arc-link').each(function () {\n const el = d3Selection.select(this);\n const isRelated =\n members.has(el.attr('data-source')!) ||\n members.has(el.attr('data-target')!);\n el.attr('stroke-opacity', isRelated ? 0.85 : FADE_OPACITY);\n });\n\n g.selectAll<SVGGElement, unknown>('.arc-node').each(function () {\n const el = d3Selection.select(this);\n el.attr('opacity', members.has(el.attr('data-node')!) ? 1 : FADE_OPACITY);\n });\n\n g.selectAll<SVGRectElement, unknown>('.arc-group-band').each(function () {\n const el = d3Selection.select(this);\n el.attr(\n 'fill-opacity',\n el.attr('data-group') === groupName ? 0.18 : 0.03\n );\n });\n\n g.selectAll<SVGTextElement, unknown>('.arc-group-label').each(function () {\n const el = d3Selection.select(this);\n el.attr('fill-opacity', el.attr('data-group') === groupName ? 1 : 0.2);\n });\n }\n\n if (isVertical) {\n // Vertical layout: nodes along Y axis, arcs curve to the right\n const yScale = d3Scale\n .scalePoint<string>()\n .domain(nodes)\n .range([0, innerHeight])\n .padding(0.5);\n\n const baseX = innerWidth / 2;\n\n // Group bands (shaded regions bounding grouped nodes)\n if (arcNodeGroups.length > 0) {\n const bandPad = (yScale.step?.() ?? 20) * 0.4;\n const bandHalfW = 60;\n for (const group of arcNodeGroups) {\n const groupNodes = group.nodes.filter((n) => nodes.includes(n));\n if (groupNodes.length === 0) continue;\n const positions = groupNodes.map((n) => yScale(n)!);\n const minY = Math.min(...positions) - bandPad;\n const maxY = Math.max(...positions) + bandPad;\n\n g.append('rect')\n .attr('class', 'arc-group-band')\n .attr('data-group', group.name)\n .attr('data-line-number', String(group.lineNumber))\n .attr('x', baseX - bandHalfW)\n .attr('y', minY)\n .attr('width', bandHalfW * 2)\n .attr('height', maxY - minY)\n .attr('rx', 4)\n .attr('fill', textColor)\n .attr('fill-opacity', 0.06)\n .style('cursor', 'pointer')\n .on('mouseenter', () => handleGroupEnter(group.name))\n .on('mouseleave', handleMouseLeave)\n .on('click', () => {\n if (onClickItem) onClickItem(group.lineNumber);\n });\n\n g.append('text')\n .attr('class', 'arc-group-label')\n .attr('data-group', group.name)\n .attr('data-line-number', String(group.lineNumber))\n .attr('x', baseX - bandHalfW + 6)\n .attr('y', minY + 14)\n .attr('fill', textColor)\n .attr('font-size', '12px')\n .attr('font-weight', '600')\n .attr('fill-opacity', 0.5)\n .style('cursor', onClickItem ? 'pointer' : 'default')\n .text(group.name)\n .on('mouseenter', () => handleGroupEnter(group.name))\n .on('mouseleave', handleMouseLeave)\n .on('click', () => {\n if (onClickItem) onClickItem(group.lineNumber);\n });\n }\n }\n\n // Dashed vertical baseline\n g.append('line')\n .attr('x1', baseX)\n .attr('y1', 0)\n .attr('x2', baseX)\n .attr('y2', innerHeight)\n .attr('stroke', mutedColor)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '4,4');\n\n // Arcs\n links.forEach((link, idx) => {\n const y1 = yScale(link.source)!;\n const y2 = yScale(link.target)!;\n const midY = (y1 + y2) / 2;\n const distance = Math.abs(y2 - y1);\n const controlX = baseX + distance * 0.4;\n const color = link.color ?? colors[idx % colors.length];\n\n g.append('path')\n .attr('class', 'arc-link')\n .attr('data-source', link.source)\n .attr('data-target', link.target)\n .attr('data-line-number', String(link.lineNumber))\n .attr('d', `M ${baseX},${y1} Q ${controlX},${midY} ${baseX},${y2}`)\n .attr('fill', 'none')\n .attr('stroke', color)\n .attr('stroke-width', strokeScale(link.value))\n .attr('stroke-opacity', 0.7)\n .style('cursor', onClickItem ? 'pointer' : 'default')\n .on('click', () => {\n if (onClickItem && link.lineNumber) onClickItem(link.lineNumber);\n });\n });\n\n // Node circles and labels\n for (const node of nodes) {\n const y = yScale(node)!;\n const nodeColor = nodeColorMap.get(node) ?? textColor;\n // Find the first link involving this node (for line number and click target)\n const nodeLink = links.find(\n (l) => l.source === node || l.target === node\n );\n\n const nodeG = g\n .append('g')\n .attr('class', 'arc-node')\n .attr('data-node', node)\n .attr(\n 'data-line-number',\n nodeLink?.lineNumber ? String(nodeLink.lineNumber) : null\n )\n .style('cursor', 'pointer')\n .on('mouseenter', () => handleMouseEnter(node))\n .on('mouseleave', handleMouseLeave)\n .on('click', () => {\n if (onClickItem && nodeLink?.lineNumber)\n onClickItem(nodeLink.lineNumber);\n });\n\n nodeG\n .append('circle')\n .attr('cx', baseX)\n .attr('cy', y)\n .attr('r', 5)\n .attr('fill', nodeColor)\n .attr('stroke', bgColor)\n .attr('stroke-width', 1.5);\n\n // Label to the left of baseline\n nodeG\n .append('text')\n .attr('x', baseX - 14)\n .attr('y', y)\n .attr('dy', '0.35em')\n .attr('text-anchor', 'end')\n .attr('fill', textColor)\n .attr('font-size', '11px')\n .text(node);\n }\n } else {\n // Horizontal layout (default): nodes along X axis, arcs curve upward\n const xScale = d3Scale\n .scalePoint<string>()\n .domain(nodes)\n .range([0, innerWidth])\n .padding(0.5);\n\n const baseY = innerHeight / 2;\n\n // Group bands (shaded regions bounding grouped nodes)\n if (arcNodeGroups.length > 0) {\n const bandPad = (xScale.step?.() ?? 20) * 0.4;\n const bandHalfH = 40;\n for (const group of arcNodeGroups) {\n const groupNodes = group.nodes.filter((n) => nodes.includes(n));\n if (groupNodes.length === 0) continue;\n const positions = groupNodes.map((n) => xScale(n)!);\n const minX = Math.min(...positions) - bandPad;\n const maxX = Math.max(...positions) + bandPad;\n\n g.append('rect')\n .attr('class', 'arc-group-band')\n .attr('data-group', group.name)\n .attr('data-line-number', String(group.lineNumber))\n .attr('x', minX)\n .attr('y', baseY - bandHalfH)\n .attr('width', maxX - minX)\n .attr('height', bandHalfH * 2)\n .attr('rx', 4)\n .attr('fill', textColor)\n .attr('fill-opacity', 0.06)\n .style('cursor', 'pointer')\n .on('mouseenter', () => handleGroupEnter(group.name))\n .on('mouseleave', handleMouseLeave)\n .on('click', () => {\n if (onClickItem) onClickItem(group.lineNumber);\n });\n\n g.append('text')\n .attr('class', 'arc-group-label')\n .attr('data-group', group.name)\n .attr('data-line-number', String(group.lineNumber))\n .attr('x', (minX + maxX) / 2)\n .attr('y', baseY + bandHalfH - 4)\n .attr('text-anchor', 'middle')\n .attr('fill', textColor)\n .attr('font-size', '12px')\n .attr('font-weight', '600')\n .attr('fill-opacity', 0.5)\n .style('cursor', onClickItem ? 'pointer' : 'default')\n .text(group.name)\n .on('mouseenter', () => handleGroupEnter(group.name))\n .on('mouseleave', handleMouseLeave)\n .on('click', () => {\n if (onClickItem) onClickItem(group.lineNumber);\n });\n }\n }\n\n // Dashed horizontal baseline\n g.append('line')\n .attr('x1', 0)\n .attr('y1', baseY)\n .attr('x2', innerWidth)\n .attr('y2', baseY)\n .attr('stroke', mutedColor)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '4,4');\n\n // Arcs\n links.forEach((link, idx) => {\n const x1 = xScale(link.source)!;\n const x2 = xScale(link.target)!;\n const midX = (x1 + x2) / 2;\n const distance = Math.abs(x2 - x1);\n const controlY = baseY - distance * 0.4;\n const color = link.color ?? colors[idx % colors.length];\n\n g.append('path')\n .attr('class', 'arc-link')\n .attr('data-source', link.source)\n .attr('data-target', link.target)\n .attr('data-line-number', String(link.lineNumber))\n .attr('d', `M ${x1},${baseY} Q ${midX},${controlY} ${x2},${baseY}`)\n .attr('fill', 'none')\n .attr('stroke', color)\n .attr('stroke-width', strokeScale(link.value))\n .attr('stroke-opacity', 0.7)\n .style('cursor', onClickItem ? 'pointer' : 'default')\n .on('click', () => {\n if (onClickItem && link.lineNumber) onClickItem(link.lineNumber);\n });\n });\n\n // Node circles and labels\n for (const node of nodes) {\n const x = xScale(node)!;\n const nodeColor = nodeColorMap.get(node) ?? textColor;\n // Find the first link involving this node (for line number and click target)\n const nodeLink = links.find(\n (l) => l.source === node || l.target === node\n );\n\n const nodeG = g\n .append('g')\n .attr('class', 'arc-node')\n .attr('data-node', node)\n .attr(\n 'data-line-number',\n nodeLink?.lineNumber ? String(nodeLink.lineNumber) : null\n )\n .style('cursor', 'pointer')\n .on('mouseenter', () => handleMouseEnter(node))\n .on('mouseleave', handleMouseLeave)\n .on('click', () => {\n if (onClickItem && nodeLink?.lineNumber)\n onClickItem(nodeLink.lineNumber);\n });\n\n nodeG\n .append('circle')\n .attr('cx', x)\n .attr('cy', baseY)\n .attr('r', 5)\n .attr('fill', nodeColor)\n .attr('stroke', bgColor)\n .attr('stroke-width', 1.5);\n\n // Label below baseline\n nodeG\n .append('text')\n .attr('x', x)\n .attr('y', baseY + 20)\n .attr('text-anchor', 'middle')\n .attr('fill', textColor)\n .attr('font-size', '11px')\n .text(node);\n }\n }\n}\n\n// ============================================================\n// Timeline Era Bands\n// ============================================================\n\nfunction getEraColors(palette: PaletteColors): string[] {\n return [\n palette.colors.blue,\n palette.colors.green,\n palette.colors.yellow,\n palette.colors.orange,\n palette.colors.purple,\n ];\n}\n\n/**\n * Renders semi-transparent era background bands behind timeline events.\n */\nfunction renderEras(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n eras: TimelineEra[],\n scale: d3Scale.ScaleLinear<number, number>,\n isVertical: boolean,\n innerWidth: number,\n innerHeight: number,\n onEnter: (eraStart: number, eraEnd: number) => void,\n onLeave: () => void,\n hasScale: boolean = false,\n tooltip: HTMLDivElement | null = null,\n palette?: PaletteColors\n): void {\n const eraColors = palette\n ? getEraColors(palette)\n : ['#5e81ac', '#a3be8c', '#ebcb8b', '#d08770', '#b48ead'];\n eras.forEach((era, i) => {\n const startVal = parseTimelineDate(era.startDate);\n const endVal = parseTimelineDate(era.endDate);\n const start = scale(startVal);\n const end = scale(endVal);\n const color = era.color || eraColors[i % eraColors.length];\n\n const eraG = g\n .append('g')\n .attr('class', 'tl-era')\n .attr('data-line-number', String(era.lineNumber))\n .attr('data-era-start', String(startVal))\n .attr('data-era-end', String(endVal))\n .style('cursor', 'pointer')\n .on('mouseenter', function (event: MouseEvent) {\n onEnter(startVal, endVal);\n if (tooltip) showTooltip(tooltip, buildEraTooltipHtml(era), event);\n })\n .on('mouseleave', function () {\n onLeave();\n if (tooltip) hideTooltip(tooltip);\n })\n .on('mousemove', function (event: MouseEvent) {\n if (tooltip) showTooltip(tooltip, buildEraTooltipHtml(era), event);\n });\n\n if (isVertical) {\n const y = Math.min(start, end);\n const h = Math.abs(end - start);\n eraG\n .append('rect')\n .attr('x', 0)\n .attr('y', y)\n .attr('width', innerWidth)\n .attr('height', h)\n .attr('fill', color)\n .attr('opacity', 0.08);\n eraG\n .append('text')\n .attr('x', 6)\n .attr('y', y + 18)\n .attr('text-anchor', 'start')\n .attr('fill', color)\n .attr('font-size', '13px')\n .attr('font-weight', '600')\n .attr('opacity', 0.8)\n .text(era.label);\n } else {\n const x = Math.min(start, end);\n const w = Math.abs(end - start);\n // When scale is on, extend the shading above the chart area\n // so the label sits above the scale marks but inside the band.\n const rectTop = hasScale ? -48 : 0;\n eraG\n .append('rect')\n .attr('x', x)\n .attr('y', rectTop)\n .attr('width', w)\n .attr('height', innerHeight - rectTop)\n .attr('fill', color)\n .attr('opacity', 0.08);\n eraG\n .append('text')\n .attr('x', x + w / 2)\n .attr('y', hasScale ? -32 : 18)\n .attr('text-anchor', 'middle')\n .attr('fill', color)\n .attr('font-size', '13px')\n .attr('font-weight', '600')\n .attr('opacity', 0.8)\n .text(era.label);\n }\n });\n}\n\n/**\n * Renders timeline markers as dashed vertical lines with diamond indicators and labels.\n */\nfunction renderMarkers(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n markers: TimelineMarker[],\n scale: d3Scale.ScaleLinear<number, number>,\n isVertical: boolean,\n innerWidth: number,\n innerHeight: number,\n _hasScale: boolean = false,\n tooltip: HTMLDivElement | null = null,\n palette?: PaletteColors\n): void {\n // Default marker color - bright orange/red that \"pops\"\n const defaultColor = palette?.accent || '#d08770';\n\n markers.forEach((marker) => {\n const dateVal = parseTimelineDate(marker.date);\n const pos = scale(dateVal);\n const color = marker.color || defaultColor;\n const lineOpacity = 0.5;\n const diamondSize = 5;\n\n const markerG = g\n .append('g')\n .attr('class', 'tl-marker')\n .attr('data-marker-date', String(dateVal))\n .attr('data-line-number', String(marker.lineNumber))\n .style('cursor', 'pointer')\n .on('mouseenter', function (event: MouseEvent) {\n if (tooltip) {\n showTooltip(tooltip, formatDateLabel(marker.date), event);\n }\n })\n .on('mouseleave', function () {\n if (tooltip) hideTooltip(tooltip);\n })\n .on('mousemove', function (event: MouseEvent) {\n if (tooltip) {\n showTooltip(tooltip, formatDateLabel(marker.date), event);\n }\n });\n\n if (isVertical) {\n // Vertical orientation: horizontal dashed line across the chart\n markerG\n .append('line')\n .attr('x1', 0)\n .attr('y1', pos)\n .attr('x2', innerWidth)\n .attr('y2', pos)\n .attr('stroke', color)\n .attr('stroke-width', 1.5)\n .attr('stroke-dasharray', '6 4')\n .attr('opacity', lineOpacity);\n\n // Label above diamond\n markerG\n .append('text')\n .attr('x', -diamondSize - 8)\n .attr('y', pos - diamondSize - 4)\n .attr('text-anchor', 'middle')\n .attr('fill', color)\n .attr('font-size', '11px')\n .attr('font-weight', '600')\n .text(marker.label);\n\n // Diamond at the left edge\n markerG\n .append('path')\n .attr(\n 'd',\n `M${-diamondSize - 8},${pos} l${diamondSize},-${diamondSize} l${diamondSize},${diamondSize} l-${diamondSize},${diamondSize} Z`\n )\n .attr('fill', color)\n .attr('opacity', 0.9);\n } else {\n // Horizontal orientation: vertical dashed line down the chart\n // Label above diamond, diamond below, then dashed line to chart bottom\n const labelY = 6;\n const diamondY = labelY + 14;\n\n // Label above diamond\n markerG\n .append('text')\n .attr('x', pos)\n .attr('y', labelY)\n .attr('text-anchor', 'middle')\n .attr('fill', color)\n .attr('font-size', '11px')\n .attr('font-weight', '600')\n .text(marker.label);\n\n // Diamond below label\n markerG\n .append('path')\n .attr(\n 'd',\n `M${pos},${diamondY - diamondSize} l${diamondSize},${diamondSize} l-${diamondSize},${diamondSize} l-${diamondSize},-${diamondSize} Z`\n )\n .attr('fill', color)\n .attr('opacity', 0.9);\n\n // Line starts from bottom of diamond and goes down to chart bottom\n markerG\n .append('line')\n .attr('x1', pos)\n .attr('y1', diamondY + diamondSize)\n .attr('x2', pos)\n .attr('y2', innerHeight)\n .attr('stroke', color)\n .attr('stroke-width', 1.5)\n .attr('stroke-dasharray', '6 4')\n .attr('opacity', lineOpacity);\n }\n });\n}\n\n// ============================================================\n// Timeline Time Scale\n// ============================================================\n\n/**\n * Converts a DSL date string (YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM) to a human-readable label.\n * '1718' → '1718'\n * '1718-05' → 'May 1718'\n * '1718-05-22' → 'May 22, 1718'\n * '2024-06-15 14:30' → 'Jun 15, 2024 14:30'\n */\nexport function formatDateLabel(dateStr: string): string {\n // Split off optional time component\n const spaceIdx = dateStr.indexOf(' ');\n let datePart = dateStr;\n let timeSuffix = '';\n\n if (spaceIdx !== -1) {\n datePart = dateStr.slice(0, spaceIdx);\n timeSuffix = ' ' + dateStr.slice(spaceIdx + 1);\n }\n\n const parts = datePart.split('-');\n const year = parts[0];\n if (parts.length === 1) return year + timeSuffix;\n const month = MONTH_ABBR[parseInt(parts[1], 10) - 1];\n if (parts.length === 2) return `${month} ${year}${timeSuffix}`;\n const day = parseInt(parts[2], 10);\n return `${month} ${day}, ${year}${timeSuffix}`;\n}\n\n/**\n * Formats a boundary label for the time axis.\n * When both boundaries fall on the same calendar day and have a time component,\n * returns just the time (e.g. \"12:15\") to avoid collisions with regular ticks.\n * Otherwise falls back to the full formatDateLabel.\n */\nfunction formatBoundaryLabel(dateStr: string, otherDateStr: string): string {\n const spaceIdx = dateStr.indexOf(' ');\n const otherSpaceIdx = otherDateStr.indexOf(' ');\n // Both must have time components and share the same date portion\n if (spaceIdx !== -1 && otherSpaceIdx !== -1) {\n const datePart = dateStr.slice(0, spaceIdx);\n const otherDatePart = otherDateStr.slice(0, otherSpaceIdx);\n if (datePart === otherDatePart) {\n return dateStr.slice(spaceIdx + 1); // just \"HH:MM\"\n }\n }\n return formatDateLabel(dateStr);\n}\n\n/**\n * Renders adaptive tick marks along the time axis.\n * Optional boundary parameters add ticks at exact data start/end.\n */\nfunction renderTimeScale(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n scale: d3Scale.ScaleLinear<number, number>,\n isVertical: boolean,\n innerWidth: number,\n innerHeight: number,\n textColor: string,\n boundaryStart?: number,\n boundaryEnd?: number,\n boundaryStartLabel?: string,\n boundaryEndLabel?: string\n): void {\n const [domainMin, domainMax] = scale.domain();\n const ticks = computeTimeTicks(\n domainMin,\n domainMax,\n scale,\n boundaryStart,\n boundaryEnd,\n boundaryStartLabel,\n boundaryEndLabel\n );\n if (ticks.length < 2) return;\n\n const tickLen = 6;\n const opacity = 0.4;\n\n const guideOpacity = 0.15;\n\n for (const tick of ticks) {\n if (isVertical) {\n // Guide line spanning full width\n g.append('line')\n .attr('x1', 0)\n .attr('y1', tick.pos)\n .attr('x2', innerWidth)\n .attr('y2', tick.pos)\n .attr('stroke', textColor)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '4 4')\n .attr('opacity', guideOpacity);\n\n // Left edge\n g.append('line')\n .attr('x1', -tickLen)\n .attr('y1', tick.pos)\n .attr('x2', 0)\n .attr('y2', tick.pos)\n .attr('stroke', textColor)\n .attr('stroke-width', 1)\n .attr('opacity', opacity);\n\n g.append('text')\n .attr('x', -tickLen - 3)\n .attr('y', tick.pos)\n .attr('dy', '0.35em')\n .attr('text-anchor', 'end')\n .attr('fill', textColor)\n .attr('font-size', '10px')\n .attr('opacity', opacity)\n .text(tick.label);\n\n // Right edge\n g.append('line')\n .attr('x1', innerWidth)\n .attr('y1', tick.pos)\n .attr('x2', innerWidth + tickLen)\n .attr('y2', tick.pos)\n .attr('stroke', textColor)\n .attr('stroke-width', 1)\n .attr('opacity', opacity);\n\n g.append('text')\n .attr('x', innerWidth + tickLen + 3)\n .attr('y', tick.pos)\n .attr('dy', '0.35em')\n .attr('text-anchor', 'start')\n .attr('fill', textColor)\n .attr('font-size', '10px')\n .attr('opacity', opacity)\n .text(tick.label);\n } else {\n // Guide line spanning full height\n g.append('line')\n .attr('class', 'tl-scale-tick')\n .attr('x1', tick.pos)\n .attr('y1', 0)\n .attr('x2', tick.pos)\n .attr('y2', innerHeight)\n .attr('stroke', textColor)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '4 4')\n .attr('opacity', guideOpacity);\n\n // Bottom edge\n g.append('line')\n .attr('class', 'tl-scale-tick')\n .attr('x1', tick.pos)\n .attr('y1', innerHeight)\n .attr('x2', tick.pos)\n .attr('y2', innerHeight + tickLen)\n .attr('stroke', textColor)\n .attr('stroke-width', 1)\n .attr('opacity', opacity);\n\n g.append('text')\n .attr('class', 'tl-scale-tick')\n .attr('x', tick.pos)\n .attr('y', innerHeight + tickLen + 12)\n .attr('text-anchor', 'middle')\n .attr('fill', textColor)\n .attr('font-size', '10px')\n .attr('opacity', opacity)\n .text(tick.label);\n\n // Top edge\n g.append('line')\n .attr('class', 'tl-scale-tick')\n .attr('x1', tick.pos)\n .attr('y1', -tickLen)\n .attr('x2', tick.pos)\n .attr('y2', 0)\n .attr('stroke', textColor)\n .attr('stroke-width', 1)\n .attr('opacity', opacity);\n\n g.append('text')\n .attr('class', 'tl-scale-tick')\n .attr('x', tick.pos)\n .attr('y', -tickLen - 4)\n .attr('text-anchor', 'middle')\n .attr('fill', textColor)\n .attr('font-size', '10px')\n .attr('opacity', opacity)\n .text(tick.label);\n }\n }\n}\n\n// ============================================================\n// Timeline Event Date Scale Helpers\n// ============================================================\n\n/**\n * Shows event start/end dates on the scale, fading existing scale ticks.\n * For horizontal timelines, displays dates at the top of the scale.\n */\nfunction showEventDatesOnScale(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n scale: d3Scale.ScaleLinear<number, number>,\n startDate: string,\n endDate: string | null,\n innerHeight: number,\n accentColor: string\n): void {\n // Fade existing scale ticks\n g.selectAll('.tl-scale-tick').attr('opacity', 0.1);\n\n const tickLen = 6;\n const startPos = scale(parseTimelineDate(startDate));\n const startLabel = formatDateLabel(startDate);\n\n // Start date - top\n g.append('line')\n .attr('class', 'tl-event-date')\n .attr('x1', startPos)\n .attr('y1', -tickLen)\n .attr('x2', startPos)\n .attr('y2', innerHeight)\n .attr('stroke', accentColor)\n .attr('stroke-width', 1.5)\n .attr('stroke-dasharray', '4 4')\n .attr('opacity', 0.6);\n\n g.append('text')\n .attr('class', 'tl-event-date')\n .attr('x', startPos)\n .attr('y', -tickLen - 4)\n .attr('text-anchor', 'middle')\n .attr('fill', accentColor)\n .attr('font-size', '10px')\n .attr('font-weight', '600')\n .text(startLabel);\n\n // Start date - bottom\n g.append('text')\n .attr('class', 'tl-event-date')\n .attr('x', startPos)\n .attr('y', innerHeight + tickLen + 12)\n .attr('text-anchor', 'middle')\n .attr('fill', accentColor)\n .attr('font-size', '10px')\n .attr('font-weight', '600')\n .text(startLabel);\n\n if (endDate) {\n const endPos = scale(parseTimelineDate(endDate));\n const endLabel = formatDateLabel(endDate);\n\n // End date - top\n g.append('line')\n .attr('class', 'tl-event-date')\n .attr('x1', endPos)\n .attr('y1', -tickLen)\n .attr('x2', endPos)\n .attr('y2', innerHeight)\n .attr('stroke', accentColor)\n .attr('stroke-width', 1.5)\n .attr('stroke-dasharray', '4 4')\n .attr('opacity', 0.6);\n\n g.append('text')\n .attr('class', 'tl-event-date')\n .attr('x', endPos)\n .attr('y', -tickLen - 4)\n .attr('text-anchor', 'middle')\n .attr('fill', accentColor)\n .attr('font-size', '10px')\n .attr('font-weight', '600')\n .text(endLabel);\n\n // End date - bottom\n g.append('text')\n .attr('class', 'tl-event-date')\n .attr('x', endPos)\n .attr('y', innerHeight + tickLen + 12)\n .attr('text-anchor', 'middle')\n .attr('fill', accentColor)\n .attr('font-size', '10px')\n .attr('font-weight', '600')\n .text(endLabel);\n }\n}\n\n/**\n * Hides event dates and restores scale tick visibility.\n */\nfunction hideEventDatesOnScale(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>\n): void {\n // Remove event date elements\n g.selectAll('.tl-event-date').remove();\n\n // Restore scale tick visibility\n g.selectAll('.tl-scale-tick').each(function () {\n const el = d3Selection.select(this);\n // Restore original opacity based on element type\n const isDashed = el.attr('stroke-dasharray');\n el.attr('opacity', isDashed ? 0.15 : 0.4);\n });\n}\n\n// ============================================================\n// Timeline Tooltip Helpers\n// ============================================================\n\nfunction createTooltip(\n container: HTMLElement,\n palette: PaletteColors,\n isDark: boolean\n): HTMLDivElement {\n container.style.position = 'relative';\n\n // Reuse existing tooltip element if present (avoids DOM churn on re-renders)\n const existing = container.querySelector<HTMLDivElement>('[data-d3-tooltip]');\n if (existing) {\n existing.style.display = 'none';\n existing.style.background = palette.surface;\n existing.style.color = palette.text;\n existing.style.boxShadow = isDark\n ? '0 2px 6px rgba(0,0,0,0.3)'\n : '0 2px 6px rgba(0,0,0,0.12)';\n return existing;\n }\n\n const tip = document.createElement('div');\n tip.setAttribute('data-d3-tooltip', '');\n tip.style.position = 'absolute';\n tip.style.display = 'none';\n tip.style.pointerEvents = 'none';\n tip.style.background = palette.surface;\n tip.style.color = palette.text;\n tip.style.padding = '6px 10px';\n tip.style.borderRadius = '4px';\n tip.style.fontSize = '12px';\n tip.style.lineHeight = '1.4';\n tip.style.whiteSpace = 'nowrap';\n tip.style.zIndex = '10';\n tip.style.boxShadow = isDark\n ? '0 2px 6px rgba(0,0,0,0.3)'\n : '0 2px 6px rgba(0,0,0,0.12)';\n container.appendChild(tip);\n return tip;\n}\n\nfunction showTooltip(\n tooltip: HTMLDivElement,\n html: string,\n event: MouseEvent\n): void {\n tooltip.innerHTML = html;\n tooltip.style.display = 'block';\n const container = tooltip.parentElement!;\n const rect = container.getBoundingClientRect();\n let left = event.clientX - rect.left + 12;\n let top = event.clientY - rect.top - 28;\n // Clamp so tooltip stays inside the container\n const tipW = tooltip.offsetWidth;\n const tipH = tooltip.offsetHeight;\n if (left + tipW > rect.width) left = rect.width - tipW - 4;\n if (top < 0) top = event.clientY - rect.top + 16;\n if (top + tipH > rect.height) top = rect.height - tipH - 4;\n tooltip.style.left = `${left}px`;\n tooltip.style.top = `${top}px`;\n}\n\nfunction hideTooltip(tooltip: HTMLDivElement): void {\n tooltip.style.display = 'none';\n}\n\nfunction buildEventTooltipHtml(ev: TimelineEvent): string {\n const datePart = ev.endDate\n ? `${formatDateLabel(ev.date)} → ${formatDateLabel(ev.endDate)}`\n : formatDateLabel(ev.date);\n return `<strong>${ev.label}</strong><br>${datePart}`;\n}\n\nfunction buildEraTooltipHtml(era: TimelineEra): string {\n return `<strong>${era.label}</strong><br>${formatDateLabel(era.startDate)} → ${formatDateLabel(era.endDate)}`;\n}\n\n// ============================================================\n// Timeline Renderer\n// ============================================================\n\n/**\n * Renders timeline group legend as pills (colored dot + text in rounded rect),\n * matching the centralized legend pill style.\n */\nfunction renderTimelineGroupLegend(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n groups: TimelineGroup[],\n groupColorMap: Map<string, string>,\n textColor: string,\n palette: PaletteColors,\n isDark: boolean,\n legendY: number,\n onHover: (name: string) => void,\n onLeave: () => void\n): void {\n const PILL_H = 22;\n const DOT_R = 4;\n const DOT_GAP = 4;\n const PAD_X = 10;\n const FONT_SIZE = 11;\n const GAP = 8;\n const pillBg = isDark\n ? mix(palette.surface, palette.bg, 50)\n : mix(palette.surface, palette.bg, 30);\n\n let legendX = 0;\n for (const grp of groups) {\n const color = groupColorMap.get(grp.name) ?? textColor;\n const textW = measureLegendText(grp.name, FONT_SIZE);\n const pillW = PAD_X + DOT_R * 2 + DOT_GAP + textW + PAD_X;\n\n const itemG = g\n .append('g')\n .attr('class', 'tl-legend-item')\n .attr('data-group', grp.name)\n .style('cursor', 'pointer')\n .on('mouseenter', () => onHover(grp.name))\n .on('mouseleave', () => onLeave());\n\n // Pill background\n itemG\n .append('rect')\n .attr('x', legendX)\n .attr('y', legendY - PILL_H / 2)\n .attr('width', pillW)\n .attr('height', PILL_H)\n .attr('rx', PILL_H / 2)\n .attr('fill', pillBg);\n\n // Colored dot\n itemG\n .append('circle')\n .attr('cx', legendX + PAD_X + DOT_R)\n .attr('cy', legendY)\n .attr('r', DOT_R)\n .attr('fill', color);\n\n // Label text\n itemG\n .append('text')\n .attr('x', legendX + PAD_X + DOT_R * 2 + DOT_GAP)\n .attr('y', legendY)\n .attr('dy', '0.35em')\n .attr('fill', textColor)\n .attr('font-size', `${FONT_SIZE}px`)\n .attr('font-family', FONT_FAMILY)\n .text(grp.name);\n\n legendX += pillW + GAP;\n }\n}\n\n/**\n * Renders a timeline chart into the given container using D3.\n * Supports horizontal (default) and vertical orientation.\n */\nexport function renderTimeline(\n container: HTMLDivElement,\n parsed: ParsedVisualization,\n palette: PaletteColors,\n isDark: boolean,\n onClickItem?: (lineNumber: number) => void,\n exportDims?: D3ExportDimensions,\n activeTagGroup?: string | null,\n swimlaneTagGroup?: string | null,\n onTagStateChange?: (\n activeTagGroup: string | null,\n swimlaneTagGroup: string | null\n ) => void,\n viewMode?: boolean\n): void {\n d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();\n\n const {\n timelineEvents,\n timelineGroups,\n timelineEras,\n timelineMarkers,\n timelineSort,\n timelineScale,\n timelineSwimlanes,\n title,\n orientation,\n } = parsed;\n if (timelineEvents.length === 0) return;\n\n // When sort: tag is set and no explicit swimlane param, use the default\n if (\n swimlaneTagGroup == null &&\n timelineSort === 'tag' &&\n parsed.timelineDefaultSwimlaneTG\n ) {\n swimlaneTagGroup = parsed.timelineDefaultSwimlaneTG;\n }\n\n const tooltip = createTooltip(container, palette, isDark);\n\n const width = exportDims?.width ?? container.clientWidth;\n const height = exportDims?.height ?? container.clientHeight;\n if (width <= 0 || height <= 0) return;\n\n const isVertical = orientation === 'vertical';\n\n // Theme colors\n const textColor = palette.text;\n const mutedColor = palette.border;\n const bgColor = palette.bg;\n const bg = isDark ? palette.surface : palette.bg;\n const colors = getSeriesColors(palette);\n\n // Assign colors to groups\n const groupColorMap = new Map<string, string>();\n timelineGroups.forEach((grp, i) => {\n groupColorMap.set(grp.name, grp.color ?? colors[i % colors.length]);\n });\n\n // When tag-based swimlanes are active, compute lanes from tag values\n // and populate groupColorMap with tag entry colors for lane headers.\n type Lane = { name: string; events: TimelineEvent[] };\n let tagLanes: Lane[] | null = null;\n\n if (swimlaneTagGroup) {\n const tagKey = swimlaneTagGroup.toLowerCase();\n const tagGroup = parsed.timelineTagGroups.find(\n (g) => g.name.toLowerCase() === tagKey\n );\n if (tagGroup) {\n // Collect events per tag value\n const buckets = new Map<string, TimelineEvent[]>();\n const otherEvents: TimelineEvent[] = [];\n for (const ev of timelineEvents) {\n const val = ev.metadata[tagKey];\n if (val) {\n const list = buckets.get(val) ?? [];\n list.push(ev);\n buckets.set(val, list);\n } else {\n otherEvents.push(ev);\n }\n }\n\n // Order lanes by earliest event date\n const laneEntries = [...buckets.entries()].sort((a, b) => {\n const aMin = Math.min(...a[1].map((e) => parseTimelineDate(e.date)));\n const bMin = Math.min(...b[1].map((e) => parseTimelineDate(e.date)));\n return aMin - bMin;\n });\n\n tagLanes = laneEntries.map(([name, events]) => ({ name, events }));\n if (otherEvents.length > 0) {\n tagLanes.push({ name: '(Other)', events: otherEvents });\n }\n\n // Populate groupColorMap from tag entry colors\n for (const entry of tagGroup.entries) {\n groupColorMap.set(entry.value, entry.color);\n }\n }\n }\n\n // Determine effective color source: explicit colorTG > swimlaneTG > group\n const effectiveColorTG = activeTagGroup ?? swimlaneTagGroup ?? null;\n\n function eventColor(ev: TimelineEvent): string {\n // Tag color takes priority when a tag group is active\n if (effectiveColorTG) {\n const tagColor = resolveTagColor(\n ev.metadata,\n parsed.timelineTagGroups,\n effectiveColorTG\n );\n if (tagColor) return tagColor;\n }\n if (ev.group && groupColorMap.has(ev.group)) {\n return groupColorMap.get(ev.group)!;\n }\n return textColor;\n }\n\n // Convert dates to numeric values and find boundary dates\n let minDate = Infinity;\n let maxDate = -Infinity;\n let earliestStartDateStr = '';\n let latestEndDateStr = '';\n\n for (const ev of timelineEvents) {\n const startNum = parseTimelineDate(ev.date);\n const endNum = ev.endDate ? parseTimelineDate(ev.endDate) : startNum;\n\n if (startNum < minDate) {\n minDate = startNum;\n earliestStartDateStr = ev.date;\n }\n if (endNum > maxDate) {\n maxDate = endNum;\n latestEndDateStr = ev.endDate ?? ev.date;\n }\n }\n const datePadding = (maxDate - minDate) * 0.05 || 0.5;\n\n const FADE_OPACITY = 0.1;\n\n // ------------------------------------------------------------------\n // Shared hover helpers (operate on CSS classes, orientation-agnostic)\n // ------------------------------------------------------------------\n\n function fadeToGroup(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n groupName: string\n ) {\n g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {\n const el = d3Selection.select(this);\n const evGroup = el.attr('data-group');\n el.attr('opacity', evGroup === groupName ? 1 : FADE_OPACITY);\n });\n g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').each(\n function () {\n const el = d3Selection.select(this);\n const name = el.attr('data-group');\n el.attr('opacity', name === groupName ? 1 : FADE_OPACITY);\n }\n );\n g.selectAll<SVGGElement, unknown>('.tl-marker').attr(\n 'opacity',\n FADE_OPACITY\n );\n }\n\n function fadeToEra(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n eraStart: number,\n eraEnd: number\n ) {\n g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {\n const el = d3Selection.select(this);\n const date = parseFloat(el.attr('data-date')!);\n const endDate = el.attr('data-end-date');\n const evEnd = endDate ? parseFloat(endDate) : date;\n const inside = evEnd >= eraStart && date <= eraEnd;\n el.attr('opacity', inside ? 1 : FADE_OPACITY);\n });\n g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').attr(\n 'opacity',\n FADE_OPACITY\n );\n g.selectAll<SVGGElement, unknown>('.tl-era').each(function () {\n const el = d3Selection.select(this);\n const s = parseFloat(el.attr('data-era-start')!);\n const e = parseFloat(el.attr('data-era-end')!);\n const isSelf = s === eraStart && e === eraEnd;\n el.attr('opacity', isSelf ? 1 : FADE_OPACITY);\n });\n g.selectAll<SVGGElement, unknown>('.tl-marker').each(function () {\n const el = d3Selection.select(this);\n const date = parseFloat(el.attr('data-marker-date')!);\n const inside = date >= eraStart && date <= eraEnd;\n el.attr('opacity', inside ? 1 : FADE_OPACITY);\n });\n }\n\n function fadeReset(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>\n ) {\n g.selectAll<SVGGElement, unknown>(\n '.tl-event, .tl-legend-item, .tl-lane-header, .tl-marker, .tl-tag-legend-entry'\n ).attr('opacity', 1);\n g.selectAll<SVGGElement, unknown>('.tl-era').attr('opacity', 1);\n }\n\n function fadeToTagValue(\n g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n tagKey: string,\n tagValue: string\n ) {\n const attrName = `data-tag-${tagKey}`;\n g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {\n const el = d3Selection.select(this);\n const val = el.attr(attrName);\n el.attr('opacity', val === tagValue ? 1 : FADE_OPACITY);\n });\n g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').attr(\n 'opacity',\n FADE_OPACITY\n );\n g.selectAll<SVGGElement, unknown>('.tl-marker').attr(\n 'opacity',\n FADE_OPACITY\n );\n // Fade legend entry dots/labels that don't match (keep group pill visible)\n g.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {\n const el = d3Selection.select(this);\n const entryValue = el.attr('data-legend-entry');\n if (entryValue === '__group__') return; // keep group pill at full opacity\n const entryGroup = el.attr('data-tag-group');\n el.attr(\n 'opacity',\n entryGroup === tagKey && entryValue === tagValue ? 1 : FADE_OPACITY\n );\n });\n }\n\n /** Attach data-tag-* attributes on an event group element */\n function setTagAttrs(\n evG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n ev: TimelineEvent\n ) {\n for (const [key, value] of Object.entries(ev.metadata)) {\n evG.attr(`data-tag-${key}`, value.toLowerCase());\n }\n }\n\n // Reserve space for tag legend at the top of chart content (below title/headers)\n const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;\n\n // ================================================================\n // VERTICAL orientation (time flows top→bottom)\n // ================================================================\n if (isVertical) {\n const useGroupedVertical =\n tagLanes != null ||\n (timelineSort === 'group' && timelineGroups.length > 0);\n if (useGroupedVertical) {\n // === GROUPED: one column/lane per group, vertical ===\n let laneNames: string[];\n let laneEventsByName: Map<string, TimelineEvent[]>;\n\n if (tagLanes) {\n laneNames = tagLanes.map((l) => l.name);\n laneEventsByName = new Map(tagLanes.map((l) => [l.name, l.events]));\n } else {\n const groupNames = timelineGroups.map((gr) => gr.name);\n const ungroupedEvents = timelineEvents.filter(\n (ev) => ev.group === null || !groupNames.includes(ev.group)\n );\n laneNames =\n ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;\n laneEventsByName = new Map(\n laneNames.map((name) => [\n name,\n timelineEvents.filter((ev) =>\n name === '(Other)'\n ? ev.group === null || !groupNames.includes(ev.group)\n : ev.group === name\n ),\n ])\n );\n }\n\n const laneCount = laneNames.length;\n const scaleMargin = timelineScale ? 40 : 0;\n const markerMargin = timelineMarkers.length > 0 ? 30 : 0;\n const margin = {\n top: 104 + markerMargin + tagLegendReserve,\n right: 40 + scaleMargin,\n bottom: 40,\n left: 60 + scaleMargin,\n };\n const innerWidth = width - margin.left - margin.right;\n const innerHeight = height - margin.top - margin.bottom;\n const laneWidth = innerWidth / laneCount;\n\n const yScale = d3Scale\n .scaleLinear()\n .domain([minDate - datePadding, maxDate + datePadding])\n .range([0, innerHeight]);\n\n const svg = d3Selection\n .select(container)\n .append('svg')\n .attr('viewBox', `0 0 ${width} ${height}`)\n .attr('width', exportDims ? width : '100%')\n .attr('preserveAspectRatio', 'xMidYMin meet')\n .style('background', bgColor);\n\n const g = svg\n .append('g')\n .attr('transform', `translate(${margin.left},${margin.top})`);\n\n renderChartTitle(\n svg,\n title,\n parsed.titleLineNumber,\n width,\n textColor,\n onClickItem\n );\n\n renderEras(\n g,\n timelineEras,\n yScale,\n true,\n innerWidth,\n innerHeight,\n (s, e) => fadeToEra(g, s, e),\n () => fadeReset(g),\n timelineScale,\n tooltip,\n palette\n );\n\n renderMarkers(\n g,\n timelineMarkers,\n yScale,\n true,\n innerWidth,\n innerHeight,\n timelineScale,\n tooltip,\n palette\n );\n\n if (timelineScale) {\n renderTimeScale(\n g,\n yScale,\n true,\n innerWidth,\n innerHeight,\n textColor,\n minDate,\n maxDate,\n formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),\n formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)\n );\n }\n\n // Render swimlane backgrounds for vertical lanes\n if (timelineSwimlanes || tagLanes) {\n laneNames.forEach((laneName, laneIdx) => {\n const laneX = laneIdx * laneWidth;\n const fillColor = laneIdx % 2 === 0 ? textColor : 'transparent';\n g.append('rect')\n .attr('class', 'tl-swimlane')\n .attr('data-group', laneName)\n .attr('x', laneX)\n .attr('y', 0)\n .attr('width', laneWidth)\n .attr('height', innerHeight)\n .attr('fill', fillColor)\n .attr('opacity', 0.06);\n });\n }\n\n laneNames.forEach((laneName, laneIdx) => {\n const laneX = laneIdx * laneWidth;\n const laneColor = groupColorMap.get(laneName) ?? textColor;\n const laneCenter = laneX + laneWidth / 2;\n\n const headerG = g\n .append('g')\n .attr('class', 'tl-lane-header')\n .attr('data-group', laneName)\n .style('cursor', 'pointer')\n .on('mouseenter', () => fadeToGroup(g, laneName))\n .on('mouseleave', () => fadeReset(g));\n\n headerG\n .append('text')\n .attr('x', laneCenter)\n .attr('y', -15)\n .attr('text-anchor', 'middle')\n .attr('fill', laneColor)\n .attr('font-size', '12px')\n .attr('font-weight', '600')\n .text(laneName);\n\n g.append('line')\n .attr('x1', laneCenter)\n .attr('y1', 0)\n .attr('x2', laneCenter)\n .attr('y2', innerHeight)\n .attr('stroke', mutedColor)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '4,4');\n\n const laneEvents = laneEventsByName.get(laneName) ?? [];\n\n for (const ev of laneEvents) {\n const y = yScale(parseTimelineDate(ev.date));\n const evG = g\n .append('g')\n .attr('class', 'tl-event')\n .attr('data-group', laneName)\n .attr('data-line-number', String(ev.lineNumber))\n .attr('data-date', String(parseTimelineDate(ev.date)))\n .attr(\n 'data-end-date',\n ev.endDate ? String(parseTimelineDate(ev.endDate)) : null\n )\n .style('cursor', 'pointer')\n .on('mouseenter', function (event: MouseEvent) {\n fadeToGroup(g, laneName);\n showTooltip(tooltip, buildEventTooltipHtml(ev), event);\n })\n .on('mouseleave', function () {\n fadeReset(g);\n hideTooltip(tooltip);\n })\n .on('mousemove', function (event: MouseEvent) {\n showTooltip(tooltip, buildEventTooltipHtml(ev), event);\n })\n .on('click', () => {\n if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);\n });\n setTagAttrs(evG, ev);\n\n const evColor = eventColor(ev);\n\n if (ev.endDate) {\n const y2 = yScale(parseTimelineDate(ev.endDate));\n const rectH = Math.max(y2 - y, 4);\n\n let fill: string = mix(evColor, bg, 30);\n let stroke: string = evColor;\n if (ev.uncertain) {\n const gradientId = `uncertain-vg-${ev.lineNumber}`;\n const strokeGradientId = `uncertain-vg-s-${ev.lineNumber}`;\n const defs =\n svg.select('defs').node() || svg.append('defs').node();\n const defsEl = d3Selection.select(defs as Element);\n defsEl\n .append('linearGradient')\n .attr('id', gradientId)\n .attr('x1', '0%')\n .attr('y1', '0%')\n .attr('x2', '0%')\n .attr('y2', '100%')\n .selectAll('stop')\n .data([\n { offset: '0%', opacity: 1 },\n { offset: '80%', opacity: 1 },\n { offset: '100%', opacity: 0 },\n ])\n .enter()\n .append('stop')\n .attr('offset', (d) => d.offset)\n .attr('stop-color', mix(laneColor, bg, 30))\n .attr('stop-opacity', (d) => d.opacity);\n defsEl\n .append('linearGradient')\n .attr('id', strokeGradientId)\n .attr('x1', '0%')\n .attr('y1', '0%')\n .attr('x2', '0%')\n .attr('y2', '100%')\n .selectAll('stop')\n .data([\n { offset: '0%', opacity: 1 },\n { offset: '80%', opacity: 1 },\n { offset: '100%', opacity: 0 },\n ])\n .enter()\n .append('stop')\n .attr('offset', (d) => d.offset)\n .attr('stop-color', evColor)\n .attr('stop-opacity', (d) => d.opacity);\n fill = `url(#${gradientId})`;\n stroke = `url(#${strokeGradientId})`;\n }\n\n evG\n .append('rect')\n .attr('x', laneCenter - 6)\n .attr('y', y)\n .attr('width', 12)\n .attr('height', rectH)\n .attr('rx', 4)\n .attr('fill', fill)\n .attr('stroke', stroke)\n .attr('stroke-width', 2);\n evG\n .append('text')\n .attr('x', laneCenter + 14)\n .attr('y', y + rectH / 2)\n .attr('dy', '0.35em')\n .attr('fill', textColor)\n .attr('font-size', '10px')\n .text(ev.label);\n } else {\n evG\n .append('circle')\n .attr('cx', laneCenter)\n .attr('cy', y)\n .attr('r', 4)\n .attr('fill', mix(evColor, bg, 30))\n .attr('stroke', evColor)\n .attr('stroke-width', 2);\n evG\n .append('text')\n .attr('x', laneCenter + 10)\n .attr('y', y)\n .attr('dy', '0.35em')\n .attr('fill', textColor)\n .attr('font-size', '10px')\n .text(ev.label);\n }\n }\n });\n } else {\n // === TIME SORT, vertical: single vertical axis ===\n const scaleMargin = timelineScale ? 40 : 0;\n const markerMargin = timelineMarkers.length > 0 ? 30 : 0;\n const margin = {\n top: 104 + markerMargin + tagLegendReserve,\n right: 200,\n bottom: 40,\n left: 60 + scaleMargin,\n };\n const innerWidth = width - margin.left - margin.right;\n const innerHeight = height - margin.top - margin.bottom;\n const axisX = 20;\n\n const yScale = d3Scale\n .scaleLinear()\n .domain([minDate - datePadding, maxDate + datePadding])\n .range([0, innerHeight]);\n\n const sorted = timelineEvents\n .slice()\n .sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));\n\n const svg = d3Selection\n .select(container)\n .append('svg')\n .attr('viewBox', `0 0 ${width} ${height}`)\n .attr('width', exportDims ? width : '100%')\n .attr('preserveAspectRatio', 'xMidYMin meet')\n .style('background', bgColor);\n\n const g = svg\n .append('g')\n .attr('transform', `translate(${margin.left},${margin.top})`);\n\n renderChartTitle(\n svg,\n title,\n parsed.titleLineNumber,\n width,\n textColor,\n onClickItem\n );\n\n renderEras(\n g,\n timelineEras,\n yScale,\n true,\n innerWidth,\n innerHeight,\n (s, e) => fadeToEra(g, s, e),\n () => fadeReset(g),\n timelineScale,\n tooltip,\n palette\n );\n\n renderMarkers(\n g,\n timelineMarkers,\n yScale,\n true,\n innerWidth,\n innerHeight,\n timelineScale,\n tooltip,\n palette\n );\n\n if (timelineScale) {\n renderTimeScale(\n g,\n yScale,\n true,\n innerWidth,\n innerHeight,\n textColor,\n minDate,\n maxDate,\n formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),\n formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)\n );\n }\n\n // Group legend (pill style)\n if (timelineGroups.length > 0) {\n renderTimelineGroupLegend(\n g,\n timelineGroups,\n groupColorMap,\n textColor,\n palette,\n isDark,\n -55,\n (name) => fadeToGroup(g, name),\n () => fadeReset(g)\n );\n }\n\n g.append('line')\n .attr('x1', axisX)\n .attr('y1', 0)\n .attr('x2', axisX)\n .attr('y2', innerHeight)\n .attr('stroke', mutedColor)\n .attr('stroke-width', 1)\n .attr('stroke-dasharray', '4,4');\n\n for (const ev of sorted) {\n const y = yScale(parseTimelineDate(ev.date));\n const color = eventColor(ev);\n\n const evG = g\n .append('g')\n .attr('class', 'tl-event')\n .attr('data-group', ev.group || '')\n .attr('data-line-number', String(ev.lineNumber))\n .attr('data-date', String(parseTimelineDate(ev.date)))\n .attr(\n 'data-end-date',\n ev.endDate ? String(parseTimelineDate(ev.endDate)) : null\n )\n .style('cursor', 'pointer')\n .on('mouseenter', function (event: MouseEvent) {\n if (ev.group && timelineGroups.length > 0) fadeToGroup(g, ev.group);\n showTooltip(tooltip, buildEventTooltipHtml(ev), event);\n })\n .on('mouseleave', function () {\n fadeReset(g);\n hideTooltip(tooltip);\n })\n .on('mousemove', function (event: MouseEvent) {\n showTooltip(tooltip, buildEventTooltipHtml(ev), event);\n })\n .on('click', () => {\n if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);\n });\n setTagAttrs(evG, ev);\n\n if (ev.endDate) {\n const y2 = yScale(parseTimelineDate(ev.endDate));\n const rectH = Math.max(y2 - y, 4);\n\n let fill: string = mix(color, bg, 30);\n let stroke: string = color;\n if (ev.uncertain) {\n const gradientId = `uncertain-v-${ev.lineNumber}`;\n const strokeGradientId = `uncertain-v-s-${ev.lineNumber}`;\n const defs = svg.select('defs').node() || svg.append('defs').node();\n const defsEl = d3Selection.select(defs as Element);\n defsEl\n .append('linearGradient')\n .attr('id', gradientId)\n .attr('x1', '0%')\n .attr('y1', '0%')\n .attr('x2', '0%')\n .attr('y2', '100%')\n .selectAll('stop')\n .data([\n { offset: '0%', opacity: 1 },\n { offset: '80%', opacity: 1 },\n { offset: '100%', opacity: 0 },\n ])\n .enter()\n .append('stop')\n .attr('offset', (d) => d.offset)\n .attr('stop-color', mix(color, bg, 30))\n .attr('stop-opacity', (d) => d.opacity);\n defsEl\n .append('linearGradient')\n .attr('id', strokeGradientId)\n .attr('x1', '0%')\n .attr('y1', '0%')\n .attr('x2', '0%')\n .attr('y2', '100%')\n .selectAll('stop')\n .data([\n { offset: '0%', opacity: 1 },\n { offset: '80%', opacity: 1 },\n { offset: '100%', opacity: 0 },\n ])\n .enter()\n .append('stop')\n .attr('offset', (d) => d.offset)\n .attr('stop-color', color)\n .attr('stop-opacity', (d) => d.opacity);\n fill = `url(#${gradientId})`;\n stroke = `url(#${strokeGradientId})`;\n }\n\n evG\n .append('rect')\n .attr('x', axisX - 6)\n .attr('y', y)\n .attr('width', 12)\n .attr('height', rectH)\n .attr('rx', 4)\n .attr('fill', fill)\n .attr('stroke', stroke)\n .attr('stroke-width', 2);\n evG\n .append('text')\n .attr('x', axisX + 16)\n .attr('y', y + rectH / 2)\n .attr('dy', '0.35em')\n .attr('fill', textColor)\n .attr('font-size', '11px')\n .text(ev.label);\n } else {\n evG\n .append('circle')\n .attr('cx', axisX)\n .attr('cy', y)\n .attr('r', 4)\n .attr('fill', mix(color, bg, 30))\n .attr('stroke', color)\n .attr('stroke-width', 2);\n evG\n .append('text')\n .attr('x', axisX + 16)\n .attr('y', y)\n .attr('dy', '0.35em')\n .attr('fill', textColor)\n .attr('font-size', '11px')\n .text(ev.label);\n }\n\n // Date label to the left\n evG\n .append('text')\n .attr('x', axisX - 14)\n .attr(\n 'y',\n ev.endDate\n ? yScale(parseTimelineDate(ev.date)) +\n Math.max(\n yScale(parseTimelineDate(ev.endDate)) -\n yScale(parseTimelineDate(ev.date)),\n 4\n ) /\n 2\n : y\n )\n .attr('dy', '0.35em')\n .attr('text-anchor', 'end')\n .attr('fill', mutedColor)\n .attr('font-size', '10px')\n .text(ev.date + (ev.endDate ? `→${ev.endDate}` : ''));\n }\n }\n\n return; // vertical done\n }\n\n // ================================================================\n // HORIZONTAL orientation (default — time flows left→right)\n // Each event gets its own row, stacked vertically.\n // ================================================================\n\n const BAR_H = 22; // range bar thickness (tall enough for text inside)\n const GROUP_GAP = 12; // vertical gap between group swim-lanes\n\n const useGroupedHorizontal =\n tagLanes != null || (timelineSort === 'group' && timelineGroups.length > 0);\n if (useGroupedHorizontal) {\n // === GROUPED: swim-lanes stacked vertically, events on own rows ===\n let lanes: Lane[];\n\n if (tagLanes) {\n lanes = tagLanes;\n } else {\n const groupNames = timelineGroups.map((gr) => gr.name);\n const ungroupedEvents = timelineEvents.filter(\n (ev) => ev.group === null || !groupNames.includes(ev.group)\n );\n const laneNames =\n ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;\n lanes = laneNames.map((name) => ({\n name,\n events: timelineEvents.filter((ev) =>\n name === '(Other)'\n ? ev.group === null || !groupNames.includes(ev.group)\n : ev.group === name\n ),\n }));\n }\n\n const totalEventRows = lanes.reduce((s, l) => s + l.events.length, 0);\n const scaleMargin = timelineScale ? 24 : 0;\n const markerMargin = timelineMarkers.length > 0 ? 30 : 0;\n // Calculate left margin based on longest group name (~7px per char + padding)\n const maxGroupNameLen = Math.max(...lanes.map((l) => l.name.length));\n const dynamicLeftMargin = Math.max(120, maxGroupNameLen * 7 + 30);\n // Group-sorted doesn't need legend space (group names shown on left)\n const baseTopMargin = title ? 50 : 20;\n const margin = {\n top:\n baseTopMargin +\n (timelineScale ? 40 : 0) +\n markerMargin +\n tagLegendReserve,\n right: 40,\n bottom: 40 + scaleMargin,\n left: dynamicLeftMargin,\n };\n const innerWidth = width - margin.left - margin.right;\n const innerHeight = height - margin.top - margin.bottom;\n const totalGaps = (lanes.length - 1) * GROUP_GAP;\n const rowH = Math.min(28, (innerHeight - totalGaps) / totalEventRows);\n\n const xScale = d3Scale\n .scaleLinear()\n .domain([minDate - datePadding, maxDate + datePadding])\n .range([0, innerWidth]);\n\n const svg = d3Selection\n .select(container)\n .append('svg')\n .attr('width', width)\n .attr('height', height)\n .style('background', bgColor);\n\n const g = svg\n .append('g')\n .attr('transform', `translate(${margin.left},${margin.top})`);\n\n renderChartTitle(\n svg,\n title,\n parsed.titleLineNumber,\n width,\n textColor,\n onClickItem\n );\n\n renderEras(\n g,\n timelineEras,\n xScale,\n false,\n innerWidth,\n innerHeight,\n (s, e) => fadeToEra(g, s, e),\n () => fadeReset(g),\n timelineScale,\n tooltip,\n palette\n );\n\n renderMarkers(\n g,\n timelineMarkers,\n xScale,\n false,\n innerWidth,\n innerHeight,\n timelineScale,\n tooltip,\n palette\n );\n\n if (timelineScale) {\n renderTimeScale(\n g,\n xScale,\n false,\n innerWidth,\n innerHeight,\n textColor,\n minDate,\n maxDate,\n formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),\n formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)\n );\n }\n\n // Offset events below marker area when markers are present\n let curY = markerMargin;\n\n // Render swimlane backgrounds first (so they appear behind events)\n // Extend into left margin to include group names\n if (timelineSwimlanes || tagLanes) {\n let swimY = markerMargin;\n lanes.forEach((lane, idx) => {\n const laneSpan = lane.events.length * rowH;\n // Alternate between light gray and transparent for visual separation\n const fillColor = idx % 2 === 0 ? textColor : 'transparent';\n g.append('rect')\n .attr('class', 'tl-swimlane')\n .attr('data-group', lane.name)\n .attr('x', -margin.left)\n .attr('y', swimY)\n .attr('width', innerWidth + margin.left)\n .attr('height', laneSpan + (idx < lanes.length - 1 ? GROUP_GAP : 0))\n .attr('fill', fillColor)\n .attr('opacity', 0.06);\n swimY += laneSpan + GROUP_GAP;\n });\n }\n\n for (const lane of lanes) {\n const laneColor = groupColorMap.get(lane.name) ?? textColor;\n const laneSpan = lane.events.length * rowH;\n\n // Group label — left of lane, vertically centred\n const group = timelineGroups.find((grp) => grp.name === lane.name);\n const headerG = g\n .append('g')\n .attr('class', 'tl-lane-header')\n .attr('data-group', lane.name)\n .style('cursor', 'pointer')\n .on('mouseenter', () => fadeToGroup(g, lane.name))\n .on('mouseleave', () => fadeReset(g))\n .on('click', () => {\n if (onClickItem && group?.lineNumber) onClickItem(group.lineNumber);\n });\n\n headerG\n .append('text')\n .attr('x', -margin.left + 10)\n .attr('y', curY + laneSpan / 2)\n .attr('dy', '0.35em')\n .attr('text-anchor', 'start')\n .attr('fill', laneColor)\n .attr('font-size', '12px')\n .attr('font-weight', '600')\n .text(lane.name);\n\n lane.events.forEach((ev, i) => {\n const y = curY + i * rowH + rowH / 2;\n const x = xScale(parseTimelineDate(ev.date));\n\n const evG = g\n .append('g')\n .attr('class', 'tl-event')\n .attr('data-group', lane.name)\n .attr('data-line-number', String(ev.lineNumber))\n .attr('data-date', String(parseTimelineDate(ev.date)))\n .attr(\n 'data-end-date',\n ev.endDate ? String(parseTimelineDate(ev.endDate)) : null\n )\n .style('cursor', 'pointer')\n .on('mouseenter', function (event: MouseEvent) {\n fadeToGroup(g, lane.name);\n if (timelineScale) {\n showEventDatesOnScale(\n g,\n xScale,\n ev.date,\n ev.endDate,\n innerHeight,\n laneColor\n );\n } else {\n showTooltip(tooltip, buildEventTooltipHtml(ev), event);\n }\n })\n .on('mouseleave', function () {\n fadeReset(g);\n if (timelineScale) {\n hideEventDatesOnScale(g);\n } else {\n hideTooltip(tooltip);\n }\n })\n .on('mousemove', function (event: MouseEvent) {\n if (!timelineScale) {\n showTooltip(tooltip, buildEventTooltipHtml(ev), event);\n }\n })\n .on('click', () => {\n if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);\n });\n setTagAttrs(evG, ev);\n\n const evColor = eventColor(ev);\n\n if (ev.endDate) {\n const x2 = xScale(parseTimelineDate(ev.endDate));\n const rectW = Math.max(x2 - x, 4);\n // Estimate label width (~7px per char at 13px font) + padding\n const estLabelWidth = ev.label.length * 7 + 16;\n const labelFitsInside = rectW >= estLabelWidth;\n\n let fill: string = mix(evColor, bg, 30);\n let stroke: string = evColor;\n if (ev.uncertain) {\n // Create gradient for uncertain end - fades last 20%\n const gradientId = `uncertain-${ev.lineNumber}`;\n const strokeGradientId = `uncertain-s-${ev.lineNumber}`;\n const defs = svg.select('defs').node() || svg.append('defs').node();\n const defsEl = d3Selection.select(defs as Element);\n defsEl\n .append('linearGradient')\n .attr('id', gradientId)\n .attr('x1', '0%')\n .attr('y1', '0%')\n .attr('x2', '100%')\n .attr('y2', '0%')\n .selectAll('stop')\n .data([\n { offset: '0%', opacity: 1 },\n { offset: '80%', opacity: 1 },\n { offset: '100%', opacity: 0 },\n ])\n .enter()\n .append('stop')\n .attr('offset', (d) => d.offset)\n .attr('stop-color', mix(evColor, bg, 30))\n .attr('stop-opacity', (d) => d.opacity);\n defsEl\n .append('linearGradient')\n .attr('id', strokeGradientId)\n .attr('x1', '0%')\n .attr('y1', '0%')\n .attr('x2', '100%')\n .attr('y2', '0%')\n .selectAll('stop')\n .data([\n { offset: '0%', opacity: 1 },\n { offset: '80%', opacity: 1 },\n { offset: '100%', opacity: 0 },\n ])\n .enter()\n .append('stop')\n .attr('offset', (d) => d.offset)\n .attr('stop-color', evColor)\n .attr('stop-opacity', (d) => d.opacity);\n fill = `url(#${gradientId})`;\n stroke = `url(#${strokeGradientId})`;\n }\n\n evG\n .append('rect')\n .attr('x', x)\n .attr('y', y - BAR_H / 2)\n .attr('width', rectW)\n .attr('height', BAR_H)\n .attr('rx', 4)\n .attr('fill', fill)\n .attr('stroke', stroke)\n .attr('stroke-width', 2);\n\n if (labelFitsInside) {\n // Text inside bar - use textColor for readability on muted fill\n evG\n .append('text')\n .attr('x', x + 8)\n .attr('y', y)\n .attr('dy', '0.35em')\n .attr('text-anchor', 'start')\n .attr('fill', textColor)\n .attr('font-size', '13px')\n .text(ev.label);\n } else {\n // Text outside bar - check if it fits on left or must go right\n const wouldFlipLeft = x + rectW > innerWidth * 0.6;\n const labelFitsLeft = x - 6 - estLabelWidth > 0;\n const flipLeft = wouldFlipLeft && labelFitsLeft;\n evG\n .append('text')\n .attr('x', flipLeft ? x - 6 : x + rectW + 6)\n .attr('y', y)\n .attr('dy', '0.35em')\n .attr('text-anchor', flipLeft ? 'end' : 'start')\n .attr('fill', textColor)\n .attr('font-size', '13px')\n .text(ev.label);\n }\n } else {\n // Point event (no end date) - render as circle with label\n const estLabelWidth = ev.label.length * 7;\n // Only flip left if past 60% AND label fits without colliding with group name area\n const wouldFlipLeft = x > innerWidth * 0.6;\n const labelFitsLeft = x - 10 - estLabelWidth > 0;\n const flipLeft = wouldFlipLeft && labelFitsLeft;\n evG\n .append('circle')\n .attr('cx', x)\n .attr('cy', y)\n .attr('r', 5)\n .attr('fill', mix(evColor, bg, 30))\n .attr('stroke', evColor)\n .attr('stroke-width', 2);\n evG\n .append('text')\n .attr('x', flipLeft ? x - 10 : x + 10)\n .attr('y', y)\n .attr('dy', '0.35em')\n .attr('text-anchor', flipLeft ? 'end' : 'start')\n .attr('fill', textColor)\n .attr('font-size', '12px')\n .text(ev.label);\n }\n });\n\n curY += laneSpan + GROUP_GAP;\n }\n } else {\n // === TIME SORT, horizontal: each event on its own row ===\n const sorted = timelineEvents\n .slice()\n .sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));\n\n const scaleMargin = timelineScale ? 24 : 0;\n const markerMargin = timelineMarkers.length > 0 ? 30 : 0;\n const margin = {\n top: 104 + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,\n right: 40,\n bottom: 40 + scaleMargin,\n left: 60,\n };\n const innerWidth = width - margin.left - margin.right;\n const innerHeight = height - margin.top - margin.bottom;\n const rowH = Math.min(28, innerHeight / sorted.length);\n\n const xScale = d3Scale\n .scaleLinear()\n .domain([minDate - datePadding, maxDate + datePadding])\n .range([0, innerWidth]);\n\n const svg = d3Selection\n .select(container)\n .append('svg')\n .attr('width', width)\n .attr('height', height)\n .style('background', bgColor);\n\n const g = svg\n .append('g')\n .attr('transform', `translate(${margin.left},${margin.top})`);\n\n renderChartTitle(\n svg,\n title,\n parsed.titleLineNumber,\n width,\n textColor,\n onClickItem\n );\n\n renderEras(\n g,\n timelineEras,\n xScale,\n false,\n innerWidth,\n innerHeight,\n (s, e) => fadeToEra(g, s, e),\n () => fadeReset(g),\n timelineScale,\n tooltip,\n palette\n );\n\n renderMarkers(\n g,\n timelineMarkers,\n xScale,\n false,\n innerWidth,\n innerHeight,\n timelineScale,\n tooltip,\n palette\n );\n\n if (timelineScale) {\n renderTimeScale(\n g,\n xScale,\n false,\n innerWidth,\n innerHeight,\n textColor,\n minDate,\n maxDate,\n formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),\n formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)\n );\n }\n\n // Group legend at top-left (pill style)\n if (timelineGroups.length > 0) {\n const legendY = timelineScale ? -75 : -55;\n renderTimelineGroupLegend(\n g,\n timelineGroups,\n groupColorMap,\n textColor,\n palette,\n isDark,\n legendY,\n (name) => fadeToGroup(g, name),\n () => fadeReset(g)\n );\n }\n\n sorted.forEach((ev, i) => {\n // Offset events below marker area when markers are present\n const y = markerMargin + i * rowH + rowH / 2;\n const x = xScale(parseTimelineDate(ev.date));\n const color = eventColor(ev);\n\n const evG = g\n .append('g')\n .attr('class', 'tl-event')\n .attr('data-group', ev.group || '')\n .attr('data-line-number', String(ev.lineNumber))\n .attr('data-date', String(parseTimelineDate(ev.date)))\n .attr(\n 'data-end-date',\n ev.endDate ? String(parseTimelineDate(ev.endDate)) : null\n )\n .style('cursor', 'pointer')\n .on('mouseenter', function (event: MouseEvent) {\n if (ev.group && timelineGroups.length > 0) fadeToGroup(g, ev.group);\n if (timelineScale) {\n showEventDatesOnScale(\n g,\n xScale,\n ev.date,\n ev.endDate,\n innerHeight,\n color\n );\n } else {\n showTooltip(tooltip, buildEventTooltipHtml(ev), event);\n }\n })\n .on('mouseleave', function () {\n fadeReset(g);\n if (timelineScale) {\n hideEventDatesOnScale(g);\n } else {\n hideTooltip(tooltip);\n }\n })\n .on('mousemove', function (event: MouseEvent) {\n if (!timelineScale) {\n showTooltip(tooltip, buildEventTooltipHtml(ev), event);\n }\n })\n .on('click', () => {\n if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);\n });\n setTagAttrs(evG, ev);\n\n if (ev.endDate) {\n const x2 = xScale(parseTimelineDate(ev.endDate));\n const rectW = Math.max(x2 - x, 4);\n // Estimate label width (~7px per char at 13px font) + padding\n const estLabelWidth = ev.label.length * 7 + 16;\n const labelFitsInside = rectW >= estLabelWidth;\n\n let fill: string = mix(color, bg, 30);\n let stroke: string = color;\n if (ev.uncertain) {\n // Create gradient for uncertain end - fades last 20%\n const gradientId = `uncertain-ts-${ev.lineNumber}`;\n const strokeGradientId = `uncertain-ts-s-${ev.lineNumber}`;\n const defs = svg.select('defs').node() || svg.append('defs').node();\n const defsEl = d3Selection.select(defs as Element);\n defsEl\n .append('linearGradient')\n .attr('id', gradientId)\n .attr('x1', '0%')\n .attr('y1', '0%')\n .attr('x2', '100%')\n .attr('y2', '0%')\n .selectAll('stop')\n .data([\n { offset: '0%', opacity: 1 },\n { offset: '80%', opacity: 1 },\n { offset: '100%', opacity: 0 },\n ])\n .enter()\n .append('stop')\n .attr('offset', (d) => d.offset)\n .attr('stop-color', mix(color, bg, 30))\n .attr('stop-opacity', (d) => d.opacity);\n defsEl\n .append('linearGradient')\n .attr('id', strokeGradientId)\n .attr('x1', '0%')\n .attr('y1', '0%')\n .attr('x2', '100%')\n .attr('y2', '0%')\n .selectAll('stop')\n .data([\n { offset: '0%', opacity: 1 },\n { offset: '80%', opacity: 1 },\n { offset: '100%', opacity: 0 },\n ])\n .enter()\n .append('stop')\n .attr('offset', (d) => d.offset)\n .attr('stop-color', color)\n .attr('stop-opacity', (d) => d.opacity);\n fill = `url(#${gradientId})`;\n stroke = `url(#${strokeGradientId})`;\n }\n\n evG\n .append('rect')\n .attr('x', x)\n .attr('y', y - BAR_H / 2)\n .attr('width', rectW)\n .attr('height', BAR_H)\n .attr('rx', 4)\n .attr('fill', fill)\n .attr('stroke', stroke)\n .attr('stroke-width', 2);\n\n if (labelFitsInside) {\n // Text inside bar - use textColor for readability on muted fill\n evG\n .append('text')\n .attr('x', x + 8)\n .attr('y', y)\n .attr('dy', '0.35em')\n .attr('text-anchor', 'start')\n .attr('fill', textColor)\n .attr('font-size', '13px')\n .text(ev.label);\n } else {\n // Text outside bar - check if it fits on left or must go right\n const wouldFlipLeft = x + rectW > innerWidth * 0.6;\n const labelFitsLeft = x - 6 - estLabelWidth > 0;\n const flipLeft = wouldFlipLeft && labelFitsLeft;\n evG\n .append('text')\n .attr('x', flipLeft ? x - 6 : x + rectW + 6)\n .attr('y', y)\n .attr('dy', '0.35em')\n .attr('text-anchor', flipLeft ? 'end' : 'start')\n .attr('fill', textColor)\n .attr('font-size', '13px')\n .text(ev.label);\n }\n } else {\n // Point event (no end date) - render as circle with label\n const estLabelWidth = ev.label.length * 7;\n // Only flip left if past 60% AND label fits without going off-chart\n const wouldFlipLeft = x > innerWidth * 0.6;\n const labelFitsLeft = x - 10 - estLabelWidth > 0;\n const flipLeft = wouldFlipLeft && labelFitsLeft;\n evG\n .append('circle')\n .attr('cx', x)\n .attr('cy', y)\n .attr('r', 5)\n .attr('fill', mix(color, bg, 30))\n .attr('stroke', color)\n .attr('stroke-width', 2);\n evG\n .append('text')\n .attr('x', flipLeft ? x - 10 : x + 10)\n .attr('y', y)\n .attr('dy', '0.35em')\n .attr('text-anchor', flipLeft ? 'end' : 'start')\n .attr('fill', textColor)\n .attr('font-size', '12px')\n .text(ev.label);\n }\n });\n }\n\n // ── Tag Legend (org-chart-style pills) ──\n if (parsed.timelineTagGroups.length > 0) {\n const LG_HEIGHT = TL_LEGEND_HEIGHT;\n const LG_PILL_PAD = TL_LEGEND_PILL_PAD;\n const LG_PILL_FONT_SIZE = TL_LEGEND_PILL_FONT_SIZE;\n const LG_CAPSULE_PAD = TL_LEGEND_CAPSULE_PAD;\n const LG_DOT_R = TL_LEGEND_DOT_R;\n const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;\n const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;\n const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;\n // LG_GROUP_GAP no longer needed — centralized legend handles spacing\n const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space) — local\n\n const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');\n const mainG = mainSvg.select<SVGGElement>('g');\n if (!mainSvg.empty() && !mainG.empty()) {\n // Position legend at top, below title\n const legendY = title ? 50 : 10;\n\n // Pre-compute group widths (minified and expanded)\n type LegendGroup = {\n group: TagGroup;\n minifiedWidth: number;\n expandedWidth: number;\n };\n const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {\n const pillW =\n measureLegendText(g.name, LG_PILL_FONT_SIZE) + LG_PILL_PAD;\n // Expanded: pill + icon (unless viewMode) + entries\n const iconSpace = viewMode ? 8 : LG_ICON_W + 4;\n let entryX = LG_CAPSULE_PAD + pillW + iconSpace;\n for (const entry of g.entries) {\n const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;\n entryX =\n textX +\n measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) +\n LG_ENTRY_TRAIL;\n }\n return {\n group: g,\n minifiedWidth: pillW,\n expandedWidth: entryX + LG_CAPSULE_PAD,\n };\n });\n\n // Two independent state axes: swimlane source + color source\n let currentActiveGroup: string | null = activeTagGroup ?? null;\n let currentSwimlaneGroup: string | null = swimlaneTagGroup ?? null;\n\n /** Render the swimlane icon (3 horizontal bars of varying width) */\n function drawSwimlaneIcon(\n parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,\n x: number,\n y: number,\n isSwimActive: boolean\n ) {\n const iconG = parent\n .append('g')\n .attr('class', 'tl-swimlane-icon')\n .attr('transform', `translate(${x}, ${y})`)\n .style('cursor', 'pointer');\n\n const barColor = isSwimActive ? palette.primary : palette.textMuted;\n const barOpacity = isSwimActive ? 1 : 0.35;\n const bars = [\n { y: 0, w: 8 },\n { y: 4, w: 12 },\n { y: 8, w: 6 },\n ];\n for (const bar of bars) {\n iconG\n .append('rect')\n .attr('x', 0)\n .attr('y', bar.y)\n .attr('width', bar.w)\n .attr('height', 2)\n .attr('rx', 1)\n .attr('fill', barColor)\n .attr('opacity', barOpacity);\n }\n return iconG;\n }\n\n /** Full re-render with updated swimlane state */\n function relayout() {\n renderTimeline(\n container,\n parsed,\n palette,\n isDark,\n onClickItem,\n exportDims,\n currentActiveGroup,\n currentSwimlaneGroup,\n onTagStateChange,\n viewMode\n );\n }\n\n function drawLegend() {\n // Remove previous legend\n mainSvg.selectAll('.tl-tag-legend-group').remove();\n mainSvg.selectAll('.tl-tag-legend-container').remove();\n\n // Effective color source: explicit color group > swimlane group\n const effectiveColorKey =\n (currentActiveGroup ?? currentSwimlaneGroup)?.toLowerCase() ?? null;\n\n // In view mode, only show the color-driving tag group (expanded, non-interactive).\n // Skip the swimlane group if it's separate from the color group (lane headers already label it).\n const visibleGroups = viewMode\n ? legendGroups.filter(\n (lg) =>\n effectiveColorKey != null &&\n lg.group.name.toLowerCase() === effectiveColorKey\n )\n : legendGroups;\n\n if (visibleGroups.length === 0) return;\n\n // Legend container for data-legend-active attribute\n const legendContainer = mainSvg\n .append('g')\n .attr('class', 'tl-tag-legend-container');\n if (currentActiveGroup) {\n legendContainer.attr(\n 'data-legend-active',\n currentActiveGroup.toLowerCase()\n );\n }\n\n // Render tag groups via centralized legend system\n const iconAddon = viewMode ? 0 : LG_ICON_W;\n const centralGroups = visibleGroups.map((lg) => ({\n name: lg.group.name,\n entries: lg.group.entries.map((e) => ({\n value: e.value,\n color: e.color,\n })),\n }));\n\n // Determine effective active group for centralized renderer\n const centralActive = viewMode ? effectiveColorKey : currentActiveGroup;\n\n const centralConfig: LegendConfig = {\n groups: centralGroups,\n position: { placement: 'top-center', titleRelation: 'below-title' },\n mode: 'fixed',\n capsulePillAddonWidth: iconAddon,\n };\n const centralState: LegendState = { activeGroup: centralActive };\n\n const centralCallbacks: LegendCallbacks = viewMode\n ? {}\n : {\n onGroupToggle: (groupName) => {\n currentActiveGroup =\n currentActiveGroup === groupName.toLowerCase()\n ? null\n : groupName.toLowerCase();\n drawLegend();\n recolorEvents();\n onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);\n },\n onEntryHover: (groupName, entryValue) => {\n const tagKey = groupName.toLowerCase();\n if (entryValue) {\n const tagVal = entryValue.toLowerCase();\n fadeToTagValue(mainG, tagKey, tagVal);\n mainSvg\n .selectAll<SVGGElement, unknown>('[data-legend-entry]')\n .each(function () {\n const el = d3Selection.select(this);\n const ev = el.attr('data-legend-entry');\n const eg =\n el.attr('data-tag-group') ??\n (el.node() as Element)\n ?.closest?.('[data-tag-group]')\n ?.getAttribute('data-tag-group');\n el.attr(\n 'opacity',\n eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY\n );\n });\n } else {\n fadeReset(mainG);\n mainSvg\n .selectAll<SVGGElement, unknown>('[data-legend-entry]')\n .attr('opacity', 1);\n }\n },\n onGroupRendered: (groupName, groupEl, isActive) => {\n const groupKey = groupName.toLowerCase();\n groupEl.attr('data-tag-group', groupKey);\n if (isActive && !viewMode) {\n const isSwimActive =\n currentSwimlaneGroup != null &&\n currentSwimlaneGroup.toLowerCase() === groupKey;\n const pillWidth =\n measureLegendText(groupName, LG_PILL_FONT_SIZE) +\n LG_PILL_PAD;\n const pillXOff = LG_CAPSULE_PAD;\n const iconX = pillXOff + pillWidth + 5;\n const iconY = (LG_HEIGHT - 10) / 2;\n const iconEl = drawSwimlaneIcon(\n groupEl,\n iconX,\n iconY,\n isSwimActive\n );\n iconEl\n .attr('data-swimlane-toggle', groupKey)\n .on('click', (event: MouseEvent) => {\n event.stopPropagation();\n currentSwimlaneGroup =\n currentSwimlaneGroup === groupKey ? null : groupKey;\n onTagStateChange?.(\n currentActiveGroup,\n currentSwimlaneGroup\n );\n relayout();\n });\n }\n },\n };\n\n const legendInnerG = legendContainer\n .append('g')\n .attr('transform', `translate(0, ${legendY})`);\n renderLegendD3(\n legendInnerG,\n centralConfig,\n centralState,\n palette,\n isDark,\n centralCallbacks,\n width\n );\n }\n\n // Build a quick lineNumber→event lookup\n const eventByLine = new Map<string, TimelineEvent>();\n for (const ev of timelineEvents) {\n eventByLine.set(String(ev.lineNumber), ev);\n }\n\n function recolorEvents() {\n const colorTG = currentActiveGroup ?? swimlaneTagGroup ?? null;\n mainG.selectAll<SVGGElement, unknown>('.tl-event').each(function () {\n const el = d3Selection.select(this);\n const lineNum = el.attr('data-line-number');\n const ev = lineNum ? eventByLine.get(lineNum) : undefined;\n if (!ev) return;\n\n let color: string;\n if (colorTG) {\n const tagColor = resolveTagColor(\n ev.metadata,\n parsed.timelineTagGroups,\n colorTG\n );\n color =\n tagColor ??\n (ev.group && groupColorMap.has(ev.group)\n ? groupColorMap.get(ev.group)!\n : textColor);\n } else {\n color =\n ev.group && groupColorMap.has(ev.group)\n ? groupColorMap.get(ev.group)!\n : textColor;\n }\n el.selectAll('rect')\n .attr('fill', mix(color, bg, 30))\n .attr('stroke', color);\n el.selectAll('circle:not(.tl-event-point-outline)')\n .attr('fill', mix(color, bg, 30))\n .attr('stroke', color);\n });\n }\n\n drawLegend();\n }\n }\n}\n\n// ============================================================\n// Word Cloud Helpers\n// ============================================================\n\nfunction getRotateFn(mode: WordCloudRotate): () => number {\n if (mode === 'mixed') return () => (Math.random() > 0.5 ? 0 : 90);\n if (mode === 'angled') return () => Math.round(Math.random() * 30 - 15);\n return () => 0;\n}\n\n// ============================================================\n// Word Cloud Renderer\n// ============================================================\n\n/**\n * Renders a word cloud into the given container using d3-cloud.\n */\nexport function renderWordCloud(\n container: HTMLDivElement,\n parsed: ParsedVisualization,\n palette: PaletteColors,\n _isDark: boolean,\n onClickItem?: (lineNumber: number) => void,\n exportDims?: D3ExportDimensions\n): void {\n const { words, title, cloudOptions } = parsed;\n if (words.length === 0) return;\n\n const init = initD3Chart(container, palette, exportDims);\n if (!init) return;\n const { svg, width, height, textColor, colors } = init;\n\n const titleHeight = title ? 40 : 0;\n const cloudHeight = height - titleHeight;\n\n const { minSize, maxSize } = cloudOptions;\n const weights = words.map((w) => w.weight);\n const minWeight = Math.min(...weights);\n const maxWeight = Math.max(...weights);\n const range = maxWeight - minWeight || 1;\n\n const fontSize = (weight: number): number => {\n const t = (weight - minWeight) / range;\n return minSize + Math.sqrt(t) * (maxSize - minSize);\n };\n\n const rotateFn = getRotateFn(cloudOptions.rotate);\n\n renderChartTitle(\n svg,\n title,\n parsed.titleLineNumber,\n width,\n textColor,\n onClickItem\n );\n\n const g = svg\n .append('g')\n .attr(\n 'transform',\n `translate(${width / 2},${titleHeight + cloudHeight / 2})`\n );\n\n cloud<WordCloudWord & cloud.Word>()\n .size([width, cloudHeight])\n .words(words.map((w) => ({ ...w, size: fontSize(w.weight) })))\n .padding(2)\n .rotate(rotateFn)\n .fontSize((d) => d.size!)\n .font(FONT_FAMILY)\n .on('end', (layoutWords) => {\n g.selectAll('text')\n .data(layoutWords)\n .join('text')\n .style('font-size', (d) => `${d.size}px`)\n .style('font-family', FONT_FAMILY)\n .style('font-weight', '600')\n .style('fill', (_d, i) => colors[i % colors.length])\n .style('cursor', (d) =>\n onClickItem && (d as WordCloudWord).lineNumber ? 'pointer' : 'default'\n )\n .attr('text-anchor', 'middle')\n .attr(\n 'transform',\n (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`\n )\n .attr('data-line-number', (d) => {\n const ln = (d as WordCloudWord).lineNumber;\n return ln ? String(ln) : null;\n })\n .text((d) => d.text!)\n .on('click', (_event, d) => {\n const ln = (d as WordCloudWord).lineNumber;\n if (onClickItem && ln) onClickItem(ln);\n });\n })\n .start();\n}\n\n// ============================================================\n// Word Cloud Renderer (for export — returns Promise)\n// ============================================================\n\nfunction renderWordCloudAsync(\n container: HTMLDivElement,\n parsed: ParsedVisualization,\n palette: PaletteColors,\n _isDark: boolean,\n exportDims?: D3ExportDimensions\n): Promise<void> {\n return new Promise((resolve) => {\n d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();\n\n const { words, title, cloudOptions } = parsed;\n if (words.length === 0) {\n resolve();\n return;\n }\n\n const width = exportDims?.width ?? container.clientWidth;\n const height = exportDims?.height ?? container.clientHeight;\n if (width <= 0 || height <= 0) {\n resolve();\n return;\n }\n\n const titleHeight = title ? 40 : 0;\n const cloudHeight = height - titleHeight;\n\n const textColor = palette.text;\n const bgColor = palette.bg;\n const colors = getSeriesColors(palette);\n\n const { minSize, maxSize } = cloudOptions;\n const weights = words.map((w) => w.weight);\n const minWeight = Math.min(...weights);\n const maxWeight = Math.max(...weights);\n const range = maxWeight - minWeight || 1;\n\n const fontSize = (weight: number): number => {\n const t = (weight - minWeight) / range;\n return minSize + Math.sqrt(t) * (maxSize - minSize);\n };\n\n const rotateFn = getRotateFn(cloudOptions.rotate);\n\n const svg = d3Selection\n .select(container)\n .append('svg')\n .attr('width', width)\n .attr('height', height)\n .style('background', bgColor);\n\n renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor);\n\n const g = svg\n .append('g')\n .attr(\n 'transform',\n `translate(${width / 2},${titleHeight + cloudHeight / 2})`\n );\n\n cloud<WordCloudWord & cloud.Word>()\n .size([width, cloudHeight])\n .words(words.map((w) => ({ ...w, size: fontSize(w.weight) })))\n .padding(2)\n .rotate(rotateFn)\n .fontSize((d) => d.size!)\n .font(FONT_FAMILY)\n .on('end', (layoutWords) => {\n g.selectAll('text')\n .data(layoutWords)\n .join('text')\n .style('font-size', (d) => `${d.size}px`)\n .style('font-family', FONT_FAMILY)\n .style('font-weight', '600')\n .style('fill', (_d, i) => colors[i % colors.length])\n .attr('text-anchor', 'middle')\n .attr(\n 'transform',\n (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`\n )\n .text((d) => d.text!);\n resolve();\n })\n .start();\n });\n}\n\n// ============================================================\n// Venn Diagram Math Helpers\n// ============================================================\n\ninterface Point {\n x: number;\n y: number;\n}\n\ninterface Circle {\n x: number;\n y: number;\n r: number;\n}\n\nfunction fitCirclesToContainerAsymmetric(\n circles: Circle[],\n w: number,\n h: number,\n mLeft: number,\n mRight: number,\n mTop: number,\n mBottom: number\n): Circle[] {\n if (circles.length === 0) return [];\n let minX = Infinity,\n maxX = -Infinity,\n minY = Infinity,\n maxY = -Infinity;\n for (const c of circles) {\n minX = Math.min(minX, c.x - c.r);\n maxX = Math.max(maxX, c.x + c.r);\n minY = Math.min(minY, c.y - c.r);\n maxY = Math.max(maxY, c.y + c.r);\n }\n const bw = maxX - minX;\n const bh = maxY - minY;\n const availW = w - mLeft - mRight;\n const availH = h - mTop - mBottom;\n const scale = Math.min(availW / bw, availH / bh);\n const cx = (minX + maxX) / 2;\n const cy = (minY + maxY) / 2;\n const tx = mLeft + availW / 2;\n const ty = mTop + availH / 2;\n return circles.map((c) => ({\n x: (c.x - cx) * scale + tx,\n y: (c.y - cy) * scale + ty,\n r: c.r * scale,\n }));\n}\n\nfunction pointInCircle(p: Point, c: Circle): boolean {\n const dx = p.x - c.x;\n const dy = p.y - c.y;\n return dx * dx + dy * dy <= c.r * c.r + 1e-6;\n}\n\nfunction regionCentroid(circles: Circle[], inside: boolean[]): Point {\n // Deterministic 50×50 grid scan instead of random sampling\n const GRID = 50;\n let minX = Infinity,\n maxX = -Infinity,\n minY = Infinity,\n maxY = -Infinity;\n for (const c of circles) {\n minX = Math.min(minX, c.x - c.r);\n maxX = Math.max(maxX, c.x + c.r);\n minY = Math.min(minY, c.y - c.r);\n maxY = Math.max(maxY, c.y + c.r);\n }\n const stepX = (maxX - minX) / GRID;\n const stepY = (maxY - minY) / GRID;\n let sx = 0,\n sy = 0,\n count = 0;\n for (let gi = 0; gi <= GRID; gi++) {\n const x = minX + gi * stepX;\n for (let gj = 0; gj <= GRID; gj++) {\n const y = minY + gj * stepY;\n let match = true;\n for (let j = 0; j < circles.length; j++) {\n const isIn = pointInCircle({ x, y }, circles[j]);\n if (isIn !== inside[j]) {\n match = false;\n break;\n }\n }\n if (match) {\n sx += x;\n sy += y;\n count++;\n }\n }\n }\n if (count === 0) {\n // Fallback: centroid of the circles that should be \"inside\"\n let fx = 0,\n fy = 0,\n fc = 0;\n for (let j = 0; j < circles.length; j++) {\n if (inside[j]) {\n fx += circles[j].x;\n fy += circles[j].y;\n fc++;\n }\n }\n return { x: fx / (fc || 1), y: fy / (fc || 1) };\n }\n return { x: sx / count, y: sy / count };\n}\n\n// ============================================================\n// Venn Diagram Renderer\n// ============================================================\n\nexport function renderVenn(\n container: HTMLDivElement,\n parsed: ParsedVisualization,\n palette: PaletteColors,\n isDark: boolean,\n onClickItem?: (lineNumber: number) => void,\n exportDims?: D3ExportDimensions\n): void {\n const { vennSets, vennOverlaps, title } = parsed;\n if (vennSets.length < 2 || vennSets.length > 3) return;\n\n const init = initD3Chart(container, palette, exportDims);\n if (!init) return;\n const { svg, width, height, textColor, colors } = init;\n const titleHeight = title ? 40 : 0;\n const n = vennSets.length;\n\n // ── Equal-radius layout with ~30% overlap depth ──\n // All circles share the same base radius; center distance = 1.4r gives ~30% penetration\n const BASE_R = 100;\n const OVERLAP_DISTANCE = BASE_R * 1.4;\n\n let rawCircles: Circle[];\n if (n === 2) {\n rawCircles = [\n { x: -OVERLAP_DISTANCE / 2, y: 0, r: BASE_R },\n { x: OVERLAP_DISTANCE / 2, y: 0, r: BASE_R },\n ];\n } else {\n // Equilateral triangle with side = OVERLAP_DISTANCE\n const s = OVERLAP_DISTANCE;\n const h = (Math.sqrt(3) / 2) * s;\n rawCircles = [\n { x: -s / 2, y: h / 3, r: BASE_R },\n { x: s / 2, y: h / 3, r: BASE_R },\n { x: 0, y: -(2 * h) / 3, r: BASE_R },\n ];\n }\n\n // Resolve colors for each set\n const setColors = vennSets.map(\n (s, i) => s.color ?? colors[i % colors.length]\n );\n\n // ── Layout-aware centering with label space ──\n const clusterCx = rawCircles.reduce((s, c) => s + c.x, 0) / n;\n const clusterCy = rawCircles.reduce((s, c) => s + c.y, 0) / n;\n\n let marginLeft = 30,\n marginRight = 30,\n marginTop = 30,\n marginBottom = 30;\n const stubLen = 20;\n const edgePad = 8;\n const labelTextPad = 4;\n\n for (let i = 0; i < n; i++) {\n const estimatedWidth =\n vennSets[i].name.length * 8.5 + stubLen + edgePad + labelTextPad;\n const dx = rawCircles[i].x - clusterCx;\n const dy = rawCircles[i].y - clusterCy;\n if (Math.abs(dx) >= Math.abs(dy)) {\n if (dx >= 0) marginRight = Math.max(marginRight, estimatedWidth);\n else marginLeft = Math.max(marginLeft, estimatedWidth);\n } else {\n const halfEstimate = estimatedWidth * 0.5;\n if (dy >= 0) marginBottom = Math.max(marginBottom, halfEstimate + 20);\n else marginTop = Math.max(marginTop, halfEstimate + 20);\n }\n }\n\n const drawH = height - titleHeight;\n const circles = fitCirclesToContainerAsymmetric(\n rawCircles,\n width,\n drawH,\n marginLeft,\n marginRight,\n marginTop,\n marginBottom\n ).map((c) => ({ ...c, y: c.y + titleHeight }));\n\n const scaledR = circles[0].r;\n\n // Suppress WebKit focus ring on interactive SVG elements\n svg\n .append('style')\n .text(\n 'circle:focus, circle:focus-visible { outline-solid: none !important; }'\n );\n\n // Title\n renderChartTitle(\n svg,\n title,\n parsed.titleLineNumber,\n width,\n textColor,\n onClickItem\n );\n\n // ── Semi-transparent filled circles (non-interactive) ──\n const circleEls: d3Selection.Selection<\n SVGCircleElement,\n unknown,\n null,\n undefined\n >[] = [];\n const circleGroup = svg.append('g');\n circles.forEach((c, i) => {\n const el = circleGroup\n .append('circle')\n .attr('cx', c.x)\n .attr('cy', c.y)\n .attr('r', c.r)\n .attr('fill', setColors[i])\n .attr('fill-opacity', 0.35)\n .attr('stroke', setColors[i])\n .attr('stroke-width', 2)\n .style('pointer-events', 'none') as d3Selection.Selection<\n SVGCircleElement,\n unknown,\n null,\n undefined\n >;\n circleEls.push(el);\n });\n\n // ── Per-region highlight overlays (section-only, not full circles) ──\n // Build SVG defs with clipPaths + masks so each region can be highlighted independently.\n const defs = svg.append('defs');\n\n // Individual circle clipPaths\n circles.forEach((c, i) => {\n defs\n .append('clipPath')\n .attr('id', `vcp-${i}`)\n .append('circle')\n .attr('cx', c.x)\n .attr('cy', c.y)\n .attr('r', c.r);\n });\n\n // All region index-sets: exclusive then intersection subsets\n const regionIdxSets: number[][] = circles.map((_, i) => [i]);\n if (n === 2) {\n regionIdxSets.push([0, 1]);\n } else {\n regionIdxSets.push([0, 1], [0, 2], [1, 2], [0, 1, 2]);\n }\n\n const overlayGroup = svg.append('g').style('pointer-events', 'none');\n const overlayEls = new Map<\n string,\n d3Selection.Selection<SVGRectElement, unknown, null, undefined>\n >();\n\n for (const idxs of regionIdxSets) {\n const key = idxs.join('-');\n const excluded = Array.from({ length: n }, (_, j) => j).filter(\n (j) => !idxs.includes(j)\n );\n\n // Build nested clipPath for intersection of all idxs\n let clipId = `vcp-${idxs[0]}`;\n for (let k = 1; k < idxs.length; k++) {\n const nestedId = `vcp-n-${idxs.slice(0, k + 1).join('-')}`;\n const ci = idxs[k];\n defs\n .append('clipPath')\n .attr('id', nestedId)\n .append('circle')\n .attr('cx', circles[ci].x)\n .attr('cy', circles[ci].y)\n .attr('r', circles[ci].r)\n .attr('clip-path', `url(#${clipId})`);\n clipId = nestedId;\n }\n\n // Determine line number for this region (for editor sync)\n let regionLineNumber: number | null = null; // eslint-disable-line no-useless-assignment\n if (idxs.length === 1) {\n regionLineNumber = vennSets[idxs[0]].lineNumber;\n } else {\n const sortedNames = idxs.map((i) => vennSets[i].name).sort();\n const ov = vennOverlaps.find(\n (o) =>\n o.sets.length === sortedNames.length &&\n o.sets.every((s, k) => s === sortedNames[k])\n );\n regionLineNumber = ov?.lineNumber ?? null;\n }\n\n const el = overlayGroup\n .append('rect')\n .attr('x', 0)\n .attr('y', 0)\n .attr('width', width)\n .attr('height', height)\n .attr('fill', 'white')\n .attr('fill-opacity', 0)\n .attr('class', 'venn-region-overlay')\n .attr(\n 'data-line-number',\n regionLineNumber != null ? String(regionLineNumber) : '0'\n )\n .attr('clip-path', `url(#${clipId})`);\n\n if (excluded.length > 0) {\n // Mask subtracts excluded circles so only the exact region shape highlights\n const maskId = `vvm-${key}`;\n const mask = defs.append('mask').attr('id', maskId);\n mask\n .append('rect')\n .attr('x', 0)\n .attr('y', 0)\n .attr('width', width)\n .attr('height', height)\n .attr('fill', 'white');\n for (const j of excluded) {\n mask\n .append('circle')\n .attr('cx', circles[j].x)\n .attr('cy', circles[j].y)\n .attr('r', circles[j].r)\n .attr('fill', 'black');\n }\n el.attr('mask', `url(#${maskId})`);\n }\n\n overlayEls.set(key, el);\n }\n\n const showRegionOverlay = (idxs: number[]) => {\n const key = [...idxs].sort((a, b) => a - b).join('-');\n overlayEls.forEach((el, k) =>\n el.attr('fill-opacity', k === key ? 0 : 0.55)\n );\n };\n const hideAllOverlays = () => {\n overlayEls.forEach((el) => el.attr('fill-opacity', 0));\n };\n\n // ── Labels ──\n const gcx = circles.reduce((s, c) => s + c.x, 0) / n;\n const gcy = circles.reduce((s, c) => s + c.y, 0) / n;\n\n function exclusiveHSpan(px: number, py: number, ci: number): number {\n const dy = py - circles[ci].y;\n const halfChord = Math.sqrt(\n Math.max(0, circles[ci].r * circles[ci].r - dy * dy)\n );\n let left = circles[ci].x - halfChord;\n let right = circles[ci].x + halfChord;\n for (let j = 0; j < n; j++) {\n if (j === ci) continue;\n const djy = py - circles[j].y;\n if (Math.abs(djy) >= circles[j].r) continue;\n const hc = Math.sqrt(circles[j].r * circles[j].r - djy * djy);\n const jLeft = circles[j].x - hc;\n const jRight = circles[j].x + hc;\n if (jLeft <= left && jRight >= right) return 0;\n if (jLeft <= left && jRight > left) left = jRight;\n if (jRight >= right && jLeft < right) right = jLeft;\n }\n return Math.max(0, right - left);\n }\n\n const CH_RATIO = 0.6;\n const MIN_FONT = 10;\n const MAX_FONT = 22;\n const INTERNAL_PAD = 12;\n\n const labelGroup = svg.append('g').style('pointer-events', 'none');\n\n // Set name labels: prefer inside exclusive region, fall back to external leader line\n circles.forEach((c, i) => {\n const text = vennSets[i].name;\n const inside = circles.map((_, j) => j === i);\n const centroid = regionCentroid(circles, inside);\n\n const availW = exclusiveHSpan(centroid.x, centroid.y, i);\n const fitFont = Math.min(\n MAX_FONT,\n Math.max(MIN_FONT, (availW - INTERNAL_PAD * 2) / (text.length * CH_RATIO))\n );\n const estTextW = text.length * CH_RATIO * fitFont;\n\n const fitsInside =\n estTextW + INTERNAL_PAD * 2 < availW &&\n pointInCircle({ x: centroid.x, y: centroid.y - fitFont / 2 }, c) &&\n pointInCircle({ x: centroid.x, y: centroid.y + fitFont / 2 }, c);\n\n if (fitsInside) {\n labelGroup\n .append('text')\n .attr('x', centroid.x)\n .attr('y', centroid.y)\n .attr('text-anchor', 'middle')\n .attr('dominant-baseline', 'central')\n .attr('fill', textColor)\n .attr('font-size', `${Math.round(fitFont)}px`)\n .attr('font-weight', 'bold')\n .text(text);\n } else {\n let dx = c.x - gcx;\n let dy = c.y - gcy;\n const mag = Math.sqrt(dx * dx + dy * dy);\n if (mag < 1e-6) {\n dx = 1;\n dy = 0;\n } else {\n dx /= mag;\n dy /= mag;\n }\n\n const exitX = c.x + dx * c.r;\n const exitY = c.y + dy * c.r;\n const edgeX = exitX + dx * edgePad;\n const edgeY = exitY + dy * edgePad;\n const stubEndX = edgeX + dx * stubLen;\n const stubEndY = edgeY + dy * stubLen;\n\n labelGroup\n .append('line')\n .attr('x1', edgeX)\n .attr('y1', edgeY)\n .attr('x2', stubEndX)\n .attr('y2', stubEndY)\n .attr('stroke', textColor)\n .attr('stroke-width', 1);\n\n const isRight = stubEndX >= gcx;\n const textAnchor = isRight ? 'start' : 'end';\n let textX = stubEndX + (isRight ? labelTextPad : -labelTextPad);\n const textY = stubEndY;\n const estW = text.length * 8.5;\n if (isRight) textX = Math.min(textX, width - estW - 4);\n else textX = Math.max(textX, estW + 4);\n\n labelGroup\n .append('text')\n .attr('x', textX)\n .attr('y', Math.max(14, Math.min(height - 4, textY)))\n .attr('text-anchor', textAnchor)\n .attr('dominant-baseline', 'central')\n .attr('fill', textColor)\n .attr('font-size', '14px')\n .attr('font-weight', 'bold')\n .text(text);\n }\n });\n\n // ── Overlap labels (inline at region centroid) ──\n function overlapHSpan(py: number, idxs: number[]): number {\n let left = -Infinity,\n right = Infinity;\n for (const ci of idxs) {\n const dy = py - circles[ci].y;\n if (Math.abs(dy) >= circles[ci].r) return 0;\n const hc = Math.sqrt(circles[ci].r * circles[ci].r - dy * dy);\n left = Math.max(left, circles[ci].x - hc);\n right = Math.min(right, circles[ci].x + hc);\n }\n if (left >= right) return 0;\n for (let j = 0; j < n; j++) {\n if (idxs.includes(j)) continue;\n const dy = py - circles[j].y;\n if (Math.abs(dy) >= circles[j].r) continue;\n const hc = Math.sqrt(circles[j].r * circles[j].r - dy * dy);\n const jLeft = circles[j].x - hc;\n const jRight = circles[j].x + hc;\n if (jLeft <= left && jRight >= right) return 0;\n if (jLeft <= left && jRight > left) left = jRight;\n if (jRight >= right && jLeft < right) right = jLeft;\n }\n return Math.max(0, right - left);\n }\n\n for (const ov of vennOverlaps) {\n if (!ov.label) continue;\n const idxs = ov.sets.map((s) => vennSets.findIndex((vs) => vs.name === s));\n if (idxs.some((idx) => idx < 0)) continue;\n const inside = circles.map((_, j) => idxs.includes(j));\n const centroid = regionCentroid(circles, inside);\n const availW = overlapHSpan(centroid.y, idxs);\n const fitFont = Math.min(\n MAX_FONT,\n Math.max(\n MIN_FONT,\n (availW - INTERNAL_PAD * 2) / (ov.label.length * CH_RATIO)\n )\n );\n labelGroup\n .append('text')\n .attr('x', centroid.x)\n .attr('y', centroid.y)\n .attr('text-anchor', 'middle')\n .attr('dominant-baseline', 'central')\n .attr('fill', textColor)\n .attr('font-size', `${Math.round(fitFont)}px`)\n .attr('font-weight', '600')\n .text(ov.label);\n }\n\n // ── Hover targets ──\n // Exclusive circle targets first (lower z-order), then intersection targets (higher z-order)\n const hoverGroup = svg.append('g');\n\n circles.forEach((c, i) => {\n hoverGroup\n .append('circle')\n .attr('cx', c.x)\n .attr('cy', c.y)\n .attr('r', c.r)\n .attr('fill', 'transparent')\n .attr('stroke', 'none')\n .attr('class', 'venn-hit-target')\n .attr('data-line-number', String(vennSets[i].lineNumber))\n .style('cursor', onClickItem ? 'pointer' : 'default')\n .style('outline-solid', 'none')\n .on('mouseenter', () => {\n showRegionOverlay([i]);\n })\n .on('mouseleave', () => {\n hideAllOverlays();\n })\n .on('click', function () {\n (this as SVGElement).blur?.();\n if (onClickItem && vennSets[i].lineNumber)\n onClickItem(vennSets[i].lineNumber);\n });\n });\n\n // Intersection targets: centroid-based circles for all overlap regions (declared + undeclared)\n const overlayR = scaledR * 0.35;\n\n const subsets: { idxs: number[]; sets: string[] }[] = [];\n if (n === 2) {\n subsets.push({\n idxs: [0, 1],\n sets: [vennSets[0].name, vennSets[1].name].sort(),\n });\n } else {\n for (let a = 0; a < n; a++) {\n for (let b = a + 1; b < n; b++) {\n subsets.push({\n idxs: [a, b],\n sets: [vennSets[a].name, vennSets[b].name].sort(),\n });\n }\n }\n subsets.push({\n idxs: [0, 1, 2],\n sets: [vennSets[0].name, vennSets[1].name, vennSets[2].name].sort(),\n });\n }\n\n for (const subset of subsets) {\n const { idxs, sets } = subset;\n const inside = circles.map((_, j) => idxs.includes(j));\n const centroid = regionCentroid(circles, inside);\n const declaredOv = vennOverlaps.find(\n (ov) =>\n ov.sets.length === sets.length && ov.sets.every((s, k) => s === sets[k])\n );\n hoverGroup\n .append('circle')\n .attr('cx', centroid.x)\n .attr('cy', centroid.y)\n .attr('r', overlayR)\n .attr('fill', 'transparent')\n .attr('stroke', 'none')\n .attr('class', 'venn-hit-target')\n .attr('data-line-number', declaredOv ? String(declaredOv.lineNumber) : '')\n .style('cursor', onClickItem && declaredOv ? 'pointer' : 'default')\n .style('outline-solid', 'none')\n .on('mouseenter', () => {\n showRegionOverlay(idxs);\n })\n .on('mouseleave', () => {\n hideAllOverlays();\n })\n .on('click', function () {\n (this as SVGElement).blur?.();\n if (onClickItem && declaredOv) onClickItem(declaredOv.lineNumber);\n });\n }\n}\n\n// ============================================================\n// Quadrant Chart Renderer\n// ============================================================\n\ntype QuadrantPosition =\n | 'top-right'\n | 'top-left'\n | 'bottom-left'\n | 'bottom-right';\n\n/**\n * Renders a quadrant chart using D3.\n * Displays 4 colored quadrant regions, axis labels, quadrant labels, and data points.\n */\nexport function renderQuadrant(\n container: HTMLDivElement,\n parsed: ParsedVisualization,\n palette: PaletteColors,\n isDark: boolean,\n onClickItem?: (lineNumber: number) => void,\n exportDims?: D3ExportDimensions\n): void {\n const {\n title,\n quadrantLabels,\n quadrantPoints,\n quadrantXAxis,\n quadrantYAxis,\n quadrantTitleLineNumber,\n quadrantXAxisLineNumber,\n quadrantYAxisLineNumber,\n } = parsed;\n\n if (quadrantPoints.length === 0) return;\n\n const init = initD3Chart(container, palette, exportDims);\n if (!init) return;\n const { svg, width, height, textColor } = init;\n const borderColor = palette.border;\n\n // Default quadrant colors with alpha\n const defaultColors = [\n palette.colors.blue,\n palette.colors.green,\n palette.colors.yellow,\n palette.colors.purple,\n ];\n\n // Margins\n const hasXAxis = !!quadrantXAxis;\n const hasYAxis = !!quadrantYAxis;\n const margin = {\n top: title ? 60 : 30,\n right: 30,\n bottom: hasXAxis ? 70 : 40,\n left: hasYAxis ? 80 : 40,\n };\n const chartWidth = width - margin.left - margin.right;\n const chartHeight = height - margin.top - margin.bottom;\n\n // Scales: data uses 0-1 range\n const xScale = d3Scale.scaleLinear().domain([0, 1]).range([0, chartWidth]);\n const yScale = d3Scale.scaleLinear().domain([0, 1]).range([chartHeight, 0]);\n\n // Tooltip\n const tooltip = createTooltip(container, palette, isDark);\n\n // Title\n renderChartTitle(\n svg,\n title,\n quadrantTitleLineNumber,\n width,\n textColor,\n onClickItem\n );\n\n // Chart group (translated by margins)\n const chartG = svg\n .append('g')\n .attr('transform', `translate(${margin.left}, ${margin.top})`);\n\n // Mix two hex colors: pct=100 → all `a`, pct=0 → all `b`\n const mixHex = (a: string, b: string, pct: number): string => {\n const parse = (h: string) => {\n const r = h.replace('#', '');\n const f = r.length === 3 ? r[0] + r[0] + r[1] + r[1] + r[2] + r[2] : r;\n return [\n parseInt(f.substring(0, 2), 16),\n parseInt(f.substring(2, 4), 16),\n parseInt(f.substring(4, 6), 16),\n ];\n };\n const [ar, ag, ab] = parse(a),\n [br, bg, bb] = parse(b),\n t = pct / 100;\n const c = (x: number, y: number) =>\n Math.round(x * t + y * (1 - t))\n .toString(16)\n .padStart(2, '0');\n return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;\n };\n\n const bg = isDark ? palette.surface : palette.bg;\n\n // Full palette color for a quadrant (used for border and label tinting)\n const getQuadrantColor = (\n label: QuadrantLabel | null,\n defaultIdx: number\n ): string => {\n return label?.color ?? defaultColors[defaultIdx % defaultColors.length];\n };\n\n // Muted fill: palette color blended 30% toward bg — matches other chart fill style\n const getQuadrantFill = (\n label: QuadrantLabel | null,\n defaultIdx: number\n ): string => {\n return mixHex(getQuadrantColor(label, defaultIdx), bg, 30);\n };\n\n // Quadrant definitions: position, rect bounds, label position\n const quadrantDefs: {\n position: QuadrantPosition;\n x: number;\n y: number;\n w: number;\n h: number;\n labelX: number;\n labelY: number;\n label: QuadrantLabel | null;\n colorIdx: number;\n }[] = [\n {\n position: 'top-left',\n x: 0,\n y: 0,\n w: chartWidth / 2,\n h: chartHeight / 2,\n labelX: chartWidth / 4,\n labelY: chartHeight / 4,\n label: quadrantLabels.topLeft,\n colorIdx: 1, // green\n },\n {\n position: 'top-right',\n x: chartWidth / 2,\n y: 0,\n w: chartWidth / 2,\n h: chartHeight / 2,\n labelX: (chartWidth * 3) / 4,\n labelY: chartHeight / 4,\n label: quadrantLabels.topRight,\n colorIdx: 0, // blue\n },\n {\n position: 'bottom-left',\n x: 0,\n y: chartHeight / 2,\n w: chartWidth / 2,\n h: chartHeight / 2,\n labelX: chartWidth / 4,\n labelY: (chartHeight * 3) / 4,\n label: quadrantLabels.bottomLeft,\n colorIdx: 2, // yellow\n },\n {\n position: 'bottom-right',\n x: chartWidth / 2,\n y: chartHeight / 2,\n w: chartWidth / 2,\n h: chartHeight / 2,\n labelX: (chartWidth * 3) / 4,\n labelY: (chartHeight * 3) / 4,\n label: quadrantLabels.bottomRight,\n colorIdx: 3, // purple\n },\n ];\n\n // Draw quadrant rectangles\n const quadrantRects = chartG\n .selectAll('rect.quadrant')\n .data(quadrantDefs)\n .enter()\n .append('rect')\n .attr('class', 'quadrant')\n .attr('x', (d) => d.x)\n .attr('y', (d) => d.y)\n .attr('width', (d) => d.w)\n .attr('height', (d) => d.h)\n .attr('fill', (d) => getQuadrantFill(d.label, d.colorIdx))\n .attr('stroke', (d) => getQuadrantColor(d.label, d.colorIdx))\n .attr('stroke-width', 2);\n\n // White text for points; quadrant labels use a darkened shade of their fill\n const shadowColor = 'rgba(0,0,0,0.4)';\n\n // Darken the full palette color (not the muted fill) to create a watermark-style label\n const getQuadrantLabelColor = (d: (typeof quadrantDefs)[number]): string => {\n const color = getQuadrantColor(d.label, d.colorIdx);\n return mixHex('#000000', color, 40);\n };\n\n // Scale label font size to fit within quadrant bounds, wrapping into multiple lines if needed\n const LABEL_MAX_FONT = 48;\n const LABEL_MIN_FONT = 14;\n const LABEL_PAD = 40;\n const CHAR_WIDTH_RATIO = 0.6;\n\n const estTextWidth = (text: string, fontSize: number): number =>\n text.length * fontSize * CHAR_WIDTH_RATIO;\n\n interface QuadrantLabelLayout {\n lines: string[];\n fontSize: number;\n }\n\n const quadrantLabelLayout = (\n text: string,\n qw: number,\n qh: number\n ): QuadrantLabelLayout => {\n const availW = qw - LABEL_PAD;\n const availH = qh - LABEL_PAD;\n const words = text.split(/\\s+/);\n\n // Try single line first\n if (estTextWidth(text, LABEL_MAX_FONT) <= availW) {\n const fs = Math.min(LABEL_MAX_FONT, availH);\n return {\n lines: [text],\n fontSize: Math.max(LABEL_MIN_FONT, Math.round(fs)),\n };\n }\n\n // Try wrapping into 2+ lines: greedily pack words so each line fits availW\n const wrapLines = (fs: number): string[] => {\n const result: string[] = [];\n let cur = '';\n for (const w of words) {\n const trial = cur ? `${cur} ${w}` : w;\n if (estTextWidth(trial, fs) > availW && cur) {\n result.push(cur);\n cur = w;\n } else {\n cur = trial;\n }\n }\n if (cur) result.push(cur);\n return result;\n };\n\n // Binary-search for largest font size where wrapped text fits both width and height\n let lo = LABEL_MIN_FONT;\n let hi = LABEL_MAX_FONT;\n let bestLines = wrapLines(lo);\n let bestFs = lo;\n while (lo <= hi) {\n const mid = Math.round((lo + hi) / 2);\n const lines = wrapLines(mid);\n const totalH = lines.length * mid * 1.2; // line height ~1.2em\n const maxLineW = Math.max(...lines.map((l) => estTextWidth(l, mid)));\n if (maxLineW <= availW && totalH <= availH) {\n bestFs = mid;\n bestLines = lines;\n lo = mid + 1;\n } else {\n hi = mid - 1;\n }\n }\n return { lines: bestLines, fontSize: Math.max(LABEL_MIN_FONT, bestFs) };\n };\n\n // Draw quadrant labels (large, centered, darkened shade of fill — recedes behind points)\n // Pre-compute layout (lines + font size) for each quadrant label\n const qw = chartWidth / 2;\n const qh = chartHeight / 2;\n const quadrantDefsWithLabel = quadrantDefs.filter((d) => d.label !== null);\n const labelLayouts = new Map(\n quadrantDefsWithLabel.map((d) => [\n d.label!.text,\n quadrantLabelLayout(d.label!.text, qw, qh),\n ])\n );\n\n const quadrantLabelTexts = chartG\n .selectAll('text.quadrant-label')\n .data(quadrantDefsWithLabel)\n .enter()\n .append('text')\n .attr('class', 'quadrant-label')\n .attr('x', (d) => d.labelX)\n .attr('y', (d) => d.labelY)\n .attr('text-anchor', 'middle')\n .attr('dominant-baseline', 'central')\n .attr('fill', (d) => getQuadrantLabelColor(d))\n .attr('font-size', (d) => `${labelLayouts.get(d.label!.text)!.fontSize}px`)\n .attr('font-weight', '700')\n .attr('data-line-number', (d) =>\n d.label?.lineNumber ? String(d.label.lineNumber) : null\n )\n .style('cursor', (d) =>\n onClickItem && d.label?.lineNumber ? 'pointer' : 'default'\n )\n .each(function (d) {\n const layout = labelLayouts.get(d.label!.text)!;\n const el = d3Selection.select(this);\n if (layout.lines.length === 1) {\n el.text(layout.lines[0]);\n } else {\n // Multi-line: use tspan elements, offset from center\n const lineH = layout.fontSize * 1.2;\n const totalH = layout.lines.length * lineH;\n const startY = -totalH / 2 + lineH / 2;\n layout.lines.forEach((line, i) => {\n el.append('tspan')\n .attr('x', d.labelX)\n .attr('dy', i === 0 ? `${startY}px` : `${lineH}px`)\n .text(line);\n });\n }\n });\n\n if (onClickItem) {\n quadrantLabelTexts\n .on('click', (_, d) => {\n if (d.label?.lineNumber) onClickItem(d.label.lineNumber);\n })\n .on('mouseenter', function () {\n d3Selection.select(this).attr('opacity', 0.7);\n })\n .on('mouseleave', function () {\n d3Selection.select(this).attr('opacity', 1);\n });\n }\n\n // X-axis labels — centered on left/right halves\n if (quadrantXAxis) {\n // Low label (centered on left half)\n const xLowLabel = svg\n .append('text')\n .attr('class', 'quadrant-axis-label')\n .attr('x', margin.left + chartWidth / 4)\n .attr('y', height - 20)\n .attr('text-anchor', 'middle')\n .attr('fill', textColor)\n .attr('font-size', '18px')\n .attr(\n 'data-line-number',\n quadrantXAxisLineNumber ? String(quadrantXAxisLineNumber) : null\n )\n .style(\n 'cursor',\n onClickItem && quadrantXAxisLineNumber ? 'pointer' : 'default'\n )\n .text(quadrantXAxis[0]);\n\n // High label (centered on right half)\n const xHighLabel = svg\n .append('text')\n .attr('class', 'quadrant-axis-label')\n .attr('x', margin.left + (chartWidth * 3) / 4)\n .attr('y', height - 20)\n .attr('text-anchor', 'middle')\n .attr('fill', textColor)\n .attr('font-size', '18px')\n .attr(\n 'data-line-number',\n quadrantXAxisLineNumber ? String(quadrantXAxisLineNumber) : null\n )\n .style(\n 'cursor',\n onClickItem && quadrantXAxisLineNumber ? 'pointer' : 'default'\n )\n .text(quadrantXAxis[1]);\n\n if (onClickItem && quadrantXAxisLineNumber) {\n [xLowLabel, xHighLabel].forEach((label) => {\n label\n .on('click', () => onClickItem(quadrantXAxisLineNumber))\n .on('mouseenter', function () {\n d3Selection.select(this).attr('opacity', 0.7);\n })\n .on('mouseleave', function () {\n d3Selection.select(this).attr('opacity', 1);\n });\n });\n }\n }\n\n // Y-axis labels — centered on top/bottom halves\n if (quadrantYAxis) {\n const yMidBottom = margin.top + (chartHeight * 3) / 4;\n const yMidTop = margin.top + chartHeight / 4;\n\n // Low label (centered on bottom half)\n const yLowLabel = svg\n .append('text')\n .attr('class', 'quadrant-axis-label')\n .attr('x', 22)\n .attr('y', yMidBottom)\n .attr('text-anchor', 'middle')\n .attr('fill', textColor)\n .attr('font-size', '18px')\n .attr('transform', `rotate(-90, 22, ${yMidBottom})`)\n .attr(\n 'data-line-number',\n quadrantYAxisLineNumber ? String(quadrantYAxisLineNumber) : null\n )\n .style(\n 'cursor',\n onClickItem && quadrantYAxisLineNumber ? 'pointer' : 'default'\n )\n .text(quadrantYAxis[0]);\n\n // High label (centered on top half)\n const yHighLabel = svg\n .append('text')\n .attr('class', 'quadrant-axis-label')\n .attr('x', 22)\n .attr('y', yMidTop)\n .attr('text-anchor', 'middle')\n .attr('fill', textColor)\n .attr('font-size', '18px')\n .attr('transform', `rotate(-90, 22, ${yMidTop})`)\n .attr(\n 'data-line-number',\n quadrantYAxisLineNumber ? String(quadrantYAxisLineNumber) : null\n )\n .style(\n 'cursor',\n onClickItem && quadrantYAxisLineNumber ? 'pointer' : 'default'\n )\n .text(quadrantYAxis[1]);\n\n if (onClickItem && quadrantYAxisLineNumber) {\n [yLowLabel, yHighLabel].forEach((label) => {\n label\n .on('click', () => onClickItem(quadrantYAxisLineNumber))\n .on('mouseenter', function () {\n d3Selection.select(this).attr('opacity', 0.7);\n })\n .on('mouseleave', function () {\n d3Selection.select(this).attr('opacity', 1);\n });\n });\n }\n }\n\n // Draw center cross lines\n chartG\n .append('line')\n .attr('x1', chartWidth / 2)\n .attr('y1', 0)\n .attr('x2', chartWidth / 2)\n .attr('y2', chartHeight)\n .attr('stroke', borderColor)\n .attr('stroke-width', 1);\n\n chartG\n .append('line')\n .attr('x1', 0)\n .attr('y1', chartHeight / 2)\n .attr('x2', chartWidth)\n .attr('y2', chartHeight / 2)\n .attr('stroke', borderColor)\n .attr('stroke-width', 1);\n\n // Get which quadrant a point belongs to\n const getPointQuadrant = (x: number, y: number): QuadrantPosition => {\n if (x >= 0.5 && y >= 0.5) return 'top-right';\n if (x < 0.5 && y >= 0.5) return 'top-left';\n if (x < 0.5 && y < 0.5) return 'bottom-left';\n return 'bottom-right';\n };\n\n // Build obstacle rects from quadrant watermark labels for collision avoidance\n const POINT_RADIUS = 6;\n const POINT_LABEL_FONT_SIZE = 12;\n const quadrantLabelObstacles: LabelRect[] = quadrantDefsWithLabel.map((d) => {\n const layout = labelLayouts.get(d.label!.text)!;\n const totalW =\n Math.max(...layout.lines.map((l) => l.length)) *\n layout.fontSize *\n CHAR_WIDTH_RATIO;\n const totalH = layout.lines.length * layout.fontSize * 1.2;\n return {\n x: d.labelX - totalW / 2,\n y: d.labelY - totalH / 2,\n w: totalW,\n h: totalH,\n };\n });\n\n // Compute collision-free label positions for all points\n const pointPixels = quadrantPoints.map((point) => ({\n label: point.label,\n cx: xScale(point.x),\n cy: yScale(point.y),\n }));\n\n const placedPointLabels = computeQuadrantPointLabels(\n pointPixels,\n { left: 0, top: 0, right: chartWidth, bottom: chartHeight },\n quadrantLabelObstacles,\n POINT_RADIUS,\n POINT_LABEL_FONT_SIZE\n );\n\n // Draw data points (circles and labels)\n const pointsG = chartG.append('g').attr('class', 'points');\n\n quadrantPoints.forEach((point, i) => {\n const cx = xScale(point.x);\n const cy = yScale(point.y);\n const quadrant = getPointQuadrant(point.x, point.y);\n const quadDef = quadrantDefs.find((d) => d.position === quadrant);\n const pointColor =\n quadDef?.label?.color ?? defaultColors[quadDef?.colorIdx ?? 0];\n const placed = placedPointLabels[i];\n\n const pointG = pointsG\n .append('g')\n .attr('class', 'point-group')\n .attr('data-line-number', String(point.lineNumber));\n\n // Connector line (drawn first so it renders behind circle and label)\n if (placed.connectorLine) {\n pointG\n .append('line')\n .attr('x1', placed.connectorLine.x1)\n .attr('y1', placed.connectorLine.y1)\n .attr('x2', placed.connectorLine.x2)\n .attr('y2', placed.connectorLine.y2)\n .attr('stroke', pointColor)\n .attr('stroke-width', 1)\n .attr('opacity', 0.5);\n }\n\n // Circle with white fill and colored border for visibility on opaque quadrants\n pointG\n .append('circle')\n .attr('cx', cx)\n .attr('cy', cy)\n .attr('r', POINT_RADIUS)\n .attr('fill', '#ffffff')\n .attr('stroke', pointColor)\n .attr('stroke-width', 2);\n\n // Label at computed position\n pointG\n .append('text')\n .attr('x', placed.x)\n .attr('y', placed.y)\n .attr('text-anchor', placed.anchor)\n .attr('dominant-baseline', 'central')\n .attr('fill', textColor)\n .attr('font-size', `${POINT_LABEL_FONT_SIZE}px`)\n .attr('font-weight', '700')\n .style('text-shadow', `0 1px 2px ${shadowColor}`)\n .text(point.label);\n\n // Interactivity\n const tipHtml = `<strong>${point.label}</strong><br>x: ${point.x.toFixed(2)}, y: ${point.y.toFixed(2)}`;\n\n pointG\n .style('cursor', onClickItem ? 'pointer' : 'default')\n .on('mouseenter', (event: MouseEvent) => {\n showTooltip(tooltip, tipHtml, event);\n pointG.select('circle').attr('r', 8);\n })\n .on('mousemove', (event: MouseEvent) => {\n showTooltip(tooltip, tipHtml, event);\n })\n .on('mouseleave', () => {\n hideTooltip(tooltip);\n pointG.select('circle').attr('r', POINT_RADIUS);\n })\n .on('click', () => {\n if (onClickItem && point.lineNumber) onClickItem(point.lineNumber);\n });\n });\n\n // Quadrant highlighting on hover and click-to-navigate\n quadrantRects\n .style('cursor', onClickItem ? 'pointer' : 'default')\n .on('mouseenter', function (_, d) {\n // Dim other quadrants\n quadrantRects.attr('opacity', (qd) =>\n qd.position === d.position ? 1 : 0.3\n );\n quadrantLabelTexts.attr('opacity', (qd) =>\n qd.position === d.position ? 1 : 0.3\n );\n // Dim points not in this quadrant\n pointsG.selectAll('g.point-group').each(function (_, i) {\n const pt = quadrantPoints[i];\n const ptQuad = getPointQuadrant(pt.x, pt.y);\n d3Selection\n .select(this)\n .attr('opacity', ptQuad === d.position ? 1 : 0.2);\n });\n })\n .on('mouseleave', () => {\n quadrantRects.attr('opacity', 1);\n quadrantLabelTexts.attr('opacity', 1);\n pointsG.selectAll('g.point-group').attr('opacity', 1);\n })\n .on('click', (_, d) => {\n // Navigate to the quadrant label's line in the source\n if (onClickItem && d.label?.lineNumber) {\n onClickItem(d.label.lineNumber);\n }\n });\n}\n\n// ============================================================\n// Export Renderer\n// ============================================================\n\nconst EXPORT_WIDTH = 1200;\nconst EXPORT_HEIGHT = 800;\n\n/**\n * Resolves the palette for export, falling back to Nord light/dark.\n */\nasync function resolveExportPalette(\n theme: string,\n palette?: PaletteColors\n): Promise<PaletteColors> {\n if (palette) return palette;\n const { getPalette } = await import('./palettes');\n return theme === 'dark' ? getPalette('nord').dark : getPalette('nord').light;\n}\n\n/**\n * Creates an offscreen container for export rendering.\n */\nfunction createExportContainer(width: number, height: number): HTMLDivElement {\n const container = document.createElement('div');\n container.style.width = `${width}px`;\n container.style.height = `${height}px`;\n container.style.position = 'absolute';\n container.style.left = '-9999px';\n document.body.appendChild(container);\n return container;\n}\n\n/**\n * Extracts the SVG from a container, applies common export styling, and cleans up.\n */\nfunction finalizeSvgExport(\n container: HTMLDivElement,\n theme: string,\n palette: PaletteColors\n): string {\n const svgEl = container.querySelector('svg');\n if (!svgEl) return '';\n if (theme === 'transparent') {\n svgEl.style.background = 'none';\n } else if (!svgEl.style.background) {\n svgEl.style.background = palette.bg;\n }\n svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');\n svgEl.style.fontFamily = FONT_FAMILY;\n // Strip elements marked for export exclusion (e.g., inactive legend pills)\n svgEl.querySelectorAll('[data-export-ignore]').forEach((el) => el.remove());\n const svgHtml = svgEl.outerHTML;\n document.body.removeChild(container);\n return svgHtml;\n}\n\n/**\n * Renders a D3 chart to an SVG string for export.\n * Creates a detached DOM element, renders into it, extracts the SVG, then cleans up.\n */\nexport async function renderForExport(\n content: string,\n theme: 'light' | 'dark' | 'transparent',\n palette?: PaletteColors,\n viewState?: import('./sharing').CompactViewState,\n options?: {\n c4Level?: 'context' | 'containers' | 'components' | 'deployment';\n c4System?: string;\n c4Container?: string;\n tagGroup?: string;\n }\n): Promise<string> {\n // Flowchart and org chart use their own parser pipelines — intercept before parseVisualization()\n const { parseDgmoChartType } = await import('./dgmo-router');\n const detectedType = parseDgmoChartType(content);\n\n if (detectedType === 'org') {\n const { parseOrg } = await import('./org/parser');\n const { layoutOrg } = await import('./org/layout');\n const { collapseOrgTree } = await import('./org/collapse');\n const { renderOrg } = await import('./org/renderer');\n\n const isDark = theme === 'dark';\n const effectivePalette = await resolveExportPalette(theme, palette);\n\n const orgParsed = parseOrg(content, effectivePalette);\n if (orgParsed.error) return '';\n\n // Apply interactive collapse state when provided (read from unified viewState)\n const collapsedNodes = viewState?.cg ? new Set(viewState.cg) : undefined;\n const activeTagGroup = resolveActiveTagGroup(\n orgParsed.tagGroups,\n orgParsed.options['active-tag'],\n viewState?.tag ?? options?.tagGroup\n );\n const hiddenAttributes = viewState?.ha ? new Set(viewState.ha) : undefined;\n\n const { parsed: effectiveParsed, hiddenCounts } =\n collapsedNodes && collapsedNodes.size > 0\n ? collapseOrgTree(orgParsed, collapsedNodes)\n : { parsed: orgParsed, hiddenCounts: new Map<string, number>() };\n\n const orgLayout = layoutOrg(\n effectiveParsed,\n hiddenCounts.size > 0 ? hiddenCounts : undefined,\n activeTagGroup,\n hiddenAttributes,\n true // expandAllLegend — show all tag groups expanded in export\n );\n\n const PADDING = 20;\n const titleOffset = effectiveParsed.title ? 30 : 0;\n const exportWidth = orgLayout.width + PADDING * 2;\n const exportHeight = orgLayout.height + PADDING * 2 + titleOffset;\n const container = createExportContainer(exportWidth, exportHeight);\n\n renderOrg(\n container,\n effectiveParsed,\n orgLayout,\n effectivePalette,\n isDark,\n undefined,\n { width: exportWidth, height: exportHeight },\n activeTagGroup,\n hiddenAttributes\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'sitemap') {\n const { parseSitemap } = await import('./sitemap/parser');\n const { layoutSitemap } = await import('./sitemap/layout');\n const { collapseSitemapTree } = await import('./sitemap/collapse');\n const { renderSitemap } = await import('./sitemap/renderer');\n\n const isDark = theme === 'dark';\n const effectivePalette = await resolveExportPalette(theme, palette);\n\n const sitemapParsed = parseSitemap(content, effectivePalette);\n if (sitemapParsed.error || sitemapParsed.roots.length === 0) return '';\n\n // Apply interactive collapse state when provided (read from unified viewState)\n const collapsedNodes = viewState?.cg ? new Set(viewState.cg) : undefined;\n const activeTagGroup = resolveActiveTagGroup(\n sitemapParsed.tagGroups,\n sitemapParsed.options['active-tag'],\n viewState?.tag ?? options?.tagGroup\n );\n const hiddenAttributes = viewState?.ha ? new Set(viewState.ha) : undefined;\n\n const { parsed: effectiveParsed, hiddenCounts } =\n collapsedNodes && collapsedNodes.size > 0\n ? collapseSitemapTree(sitemapParsed, collapsedNodes)\n : { parsed: sitemapParsed, hiddenCounts: new Map<string, number>() };\n\n const sitemapLayout = layoutSitemap(\n effectiveParsed,\n hiddenCounts.size > 0 ? hiddenCounts : undefined,\n activeTagGroup,\n hiddenAttributes,\n true\n );\n\n const PADDING = 20;\n const titleOffset = effectiveParsed.title ? 30 : 0;\n const exportWidth = sitemapLayout.width + PADDING * 2;\n const exportHeight = sitemapLayout.height + PADDING * 2 + titleOffset;\n const container = createExportContainer(exportWidth, exportHeight);\n\n renderSitemap(\n container,\n effectiveParsed,\n sitemapLayout,\n effectivePalette,\n isDark,\n undefined,\n { width: exportWidth, height: exportHeight },\n activeTagGroup,\n hiddenAttributes\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'kanban') {\n const { parseKanban } = await import('./kanban/parser');\n const { renderKanban } = await import('./kanban/renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const kanbanParsed = parseKanban(content, effectivePalette);\n if (kanbanParsed.error || kanbanParsed.columns.length === 0) return '';\n\n // Kanban renderer self-sizes — no explicit width/height needed\n const container = document.createElement('div');\n container.style.position = 'absolute';\n container.style.left = '-9999px';\n document.body.appendChild(container);\n\n renderKanban(container, kanbanParsed, effectivePalette, theme === 'dark', {\n activeTagGroup: resolveActiveTagGroup(\n kanbanParsed.tagGroups,\n kanbanParsed.options['active-tag'],\n viewState?.tag ?? options?.tagGroup\n ),\n currentSwimlaneGroup: viewState?.swim ?? null,\n collapsedLanes: viewState?.cl ? new Set(viewState.cl) : undefined,\n collapsedColumns: viewState?.cc ? new Set(viewState.cc) : undefined,\n compactMeta: viewState?.cm,\n });\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'class') {\n const { parseClassDiagram } = await import('./class/parser');\n const { layoutClassDiagram } = await import('./class/layout');\n const { renderClassDiagram } = await import('./class/renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const classParsed = parseClassDiagram(content, effectivePalette);\n if (classParsed.error || classParsed.classes.length === 0) return '';\n\n const classLayout = layoutClassDiagram(classParsed);\n const PADDING = 20;\n const titleOffset = classParsed.title ? 40 : 0;\n const exportWidth = classLayout.width + PADDING * 2;\n const exportHeight = classLayout.height + PADDING * 2 + titleOffset;\n const container = createExportContainer(exportWidth, exportHeight);\n\n renderClassDiagram(\n container,\n classParsed,\n classLayout,\n effectivePalette,\n theme === 'dark',\n undefined,\n { width: exportWidth, height: exportHeight }\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'er') {\n const { parseERDiagram } = await import('./er/parser');\n const { layoutERDiagram } = await import('./er/layout');\n const { renderERDiagram } = await import('./er/renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const erParsed = parseERDiagram(content, effectivePalette);\n if (erParsed.error || erParsed.tables.length === 0) return '';\n\n const erLayout = layoutERDiagram(erParsed);\n const PADDING = 20;\n const titleOffset = erParsed.title ? 40 : 0;\n const exportWidth = erLayout.width + PADDING * 2;\n const exportHeight = erLayout.height + PADDING * 2 + titleOffset;\n const container = createExportContainer(exportWidth, exportHeight);\n\n renderERDiagram(\n container,\n erParsed,\n erLayout,\n effectivePalette,\n theme === 'dark',\n undefined,\n { width: exportWidth, height: exportHeight },\n resolveActiveTagGroup(\n erParsed.tagGroups,\n erParsed.options['active-tag'],\n viewState?.tag ?? options?.tagGroup\n ),\n viewState?.sem\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'boxes-and-lines') {\n const { parseBoxesAndLines } = await import('./boxes-and-lines/parser');\n const effectivePalette = await resolveExportPalette(theme, palette);\n const blParsed = parseBoxesAndLines(content);\n if (blParsed.error || blParsed.nodes.length === 0) return '';\n\n // Convert viewState.htv (Record<string, string[]>) to Map<string, Set<string>>\n let blHiddenTagValues: Map<string, Set<string>> | undefined;\n if (viewState?.htv) {\n blHiddenTagValues = new Map();\n for (const [k, v] of Object.entries(viewState.htv)) {\n blHiddenTagValues.set(k, new Set(v));\n }\n }\n\n const { layoutBoxesAndLines } = await import('./boxes-and-lines/layout');\n const { renderBoxesAndLinesForExport } =\n await import('./boxes-and-lines/renderer');\n\n const blLayout = layoutBoxesAndLines(blParsed);\n const PADDING = 20;\n const titleOffset = blParsed.title ? 40 : 0;\n const exportWidth = blLayout.width + PADDING * 2;\n const exportHeight = blLayout.height + PADDING * 2 + titleOffset;\n const container = createExportContainer(exportWidth, exportHeight);\n\n renderBoxesAndLinesForExport(\n container,\n blParsed,\n blLayout,\n effectivePalette,\n theme === 'dark',\n {\n exportDims: { width: exportWidth, height: exportHeight },\n activeTagGroup: viewState?.tag ?? options?.tagGroup,\n hiddenTagValues: blHiddenTagValues,\n }\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'mindmap') {\n const { parseMindmap } = await import('./mindmap/parser');\n const { layoutMindmap } = await import('./mindmap/layout');\n const { collapseMindmapTree } = await import('./mindmap/collapse');\n const { renderMindmap } = await import('./mindmap/renderer');\n\n const isDark = theme === 'dark';\n const effectivePalette = await resolveExportPalette(theme, palette);\n\n const mmParsed = parseMindmap(content, effectivePalette);\n if (mmParsed.error) return '';\n\n const collapsedNodes = viewState?.cg ? new Set(viewState.cg) : undefined;\n const activeTagGroup = resolveActiveTagGroup(\n mmParsed.tagGroups,\n mmParsed.options['active-tag'],\n viewState?.tag ?? options?.tagGroup\n );\n const hideDescriptions =\n mmParsed.options['hide-descriptions'] === 'true' ||\n viewState?.hd === true;\n\n const { roots: effectiveRoots, hiddenCounts } =\n collapsedNodes && collapsedNodes.size > 0\n ? collapseMindmapTree(mmParsed.roots, collapsedNodes)\n : { roots: mmParsed.roots, hiddenCounts: new Map<string, number>() };\n\n const effectiveParsed = { ...mmParsed, roots: effectiveRoots };\n\n const mmLayout = layoutMindmap(effectiveParsed, effectivePalette, {\n interactive: false,\n hiddenCounts: hiddenCounts.size > 0 ? hiddenCounts : undefined,\n activeTagGroup,\n hideDescriptions,\n });\n\n const PADDING = 20;\n const titleOffset = effectiveParsed.title ? 30 : 0;\n const exportWidth = mmLayout.width + PADDING * 2;\n const exportHeight = mmLayout.height + PADDING * 2 + titleOffset;\n const container = createExportContainer(exportWidth, exportHeight);\n\n const colorByDepth = viewState?.cbd === true;\n\n renderMindmap(\n container,\n effectiveParsed,\n mmLayout,\n effectivePalette,\n isDark,\n undefined,\n { width: exportWidth, height: exportHeight },\n undefined,\n hideDescriptions,\n colorByDepth ? null : activeTagGroup,\n colorByDepth ? { colorByDepth: true } : undefined\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'wireframe') {\n const { parseWireframe } = await import('./wireframe/parser');\n const { layoutWireframe } = await import('./wireframe/layout');\n const { renderWireframe } = await import('./wireframe/renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const wireframeParsed = parseWireframe(content);\n if (\n wireframeParsed.error ||\n (wireframeParsed.roots.length === 0 &&\n wireframeParsed.modals.length === 0)\n )\n return '';\n\n const wireframeLayout = layoutWireframe(wireframeParsed);\n\n const exportWidth = wireframeLayout.width;\n const exportHeight = wireframeLayout.height;\n const container = createExportContainer(exportWidth, exportHeight);\n\n renderWireframe(\n container,\n wireframeParsed,\n wireframeLayout,\n effectivePalette,\n theme === 'dark',\n undefined,\n { width: exportWidth, height: exportHeight },\n theme\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'c4') {\n const { parseC4 } = await import('./c4/parser');\n const {\n layoutC4Context,\n layoutC4Containers,\n layoutC4Components,\n layoutC4Deployment,\n } = await import('./c4/layout');\n const { renderC4Context, renderC4Containers } =\n await import('./c4/renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const c4Parsed = parseC4(content, effectivePalette);\n if (c4Parsed.error || c4Parsed.elements.length === 0) return '';\n\n // Container/component-level rendering (viewState fallback for share links)\n const c4Level =\n options?.c4Level ??\n (viewState?.c4l as\n | 'context'\n | 'containers'\n | 'components'\n | 'deployment'\n | undefined) ??\n 'context';\n const c4System = options?.c4System ?? viewState?.c4s;\n const c4Container = options?.c4Container ?? viewState?.c4c;\n\n const c4Layout =\n c4Level === 'deployment'\n ? layoutC4Deployment(c4Parsed)\n : c4Level === 'components' && c4System && c4Container\n ? layoutC4Components(c4Parsed, c4System, c4Container)\n : c4Level === 'containers' && c4System\n ? layoutC4Containers(c4Parsed, c4System)\n : layoutC4Context(c4Parsed);\n\n if (c4Layout.nodes.length === 0) return '';\n\n const PADDING = 20;\n const titleOffset = c4Parsed.title ? 40 : 0;\n const exportWidth = c4Layout.width + PADDING * 2;\n const exportHeight = c4Layout.height + PADDING * 2 + titleOffset;\n const container = createExportContainer(exportWidth, exportHeight);\n\n const renderFn =\n c4Level === 'deployment' ||\n (c4Level === 'components' && c4System && c4Container) ||\n (c4Level === 'containers' && c4System)\n ? renderC4Containers\n : renderC4Context;\n\n renderFn(\n container,\n c4Parsed,\n c4Layout,\n effectivePalette,\n theme === 'dark',\n undefined,\n { width: exportWidth, height: exportHeight },\n resolveActiveTagGroup(\n c4Parsed.tagGroups,\n c4Parsed.options['active-tag'],\n viewState?.tag ?? options?.tagGroup\n )\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'flowchart') {\n const { parseFlowchart } = await import('./graph/flowchart-parser');\n const { layoutGraph } = await import('./graph/layout');\n const { renderFlowchart } = await import('./graph/flowchart-renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const fcParsed = parseFlowchart(content, effectivePalette);\n if (fcParsed.error || fcParsed.nodes.length === 0) return '';\n\n const layout = layoutGraph(fcParsed);\n const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);\n\n renderFlowchart(\n container,\n fcParsed,\n layout,\n effectivePalette,\n theme === 'dark',\n undefined,\n { width: EXPORT_WIDTH, height: EXPORT_HEIGHT }\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'infra') {\n const { parseInfra } = await import('./infra/parser');\n const { computeInfra } = await import('./infra/compute');\n const { layoutInfra } = await import('./infra/layout');\n const { renderInfra, computeInfraLegendGroups } =\n await import('./infra/renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const infraParsed = parseInfra(content);\n if (infraParsed.error || infraParsed.nodes.length === 0) return '';\n\n const infraComputed = computeInfra(infraParsed);\n const infraLayout = layoutInfra(infraComputed);\n const activeTagGroup = resolveActiveTagGroup(\n infraParsed.tagGroups,\n infraParsed.options['active-tag'],\n viewState?.tag ?? options?.tagGroup\n );\n\n const titleOffset = infraParsed.title ? 40 : 0;\n const legendGroups = computeInfraLegendGroups(\n infraLayout.nodes,\n infraParsed.tagGroups,\n effectivePalette\n );\n const legendOffset = legendGroups.length > 0 ? 28 : 0;\n const exportWidth = infraLayout.width;\n const exportHeight = infraLayout.height + titleOffset + legendOffset;\n const container = createExportContainer(exportWidth, exportHeight);\n\n renderInfra(\n container,\n infraLayout,\n effectivePalette,\n theme === 'dark',\n infraParsed.title,\n infraParsed.titleLineNumber,\n infraParsed.tagGroups,\n activeTagGroup,\n false,\n null,\n null,\n true,\n viewState?.cg ? new Set(viewState.cg) : null\n );\n // Restore explicit pixel dimensions for resvg (renderer uses 100%/viewBox for app scaling)\n const infraSvg = container.querySelector('svg');\n if (infraSvg) {\n infraSvg.setAttribute('width', String(exportWidth));\n infraSvg.setAttribute('height', String(exportHeight));\n }\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'gantt') {\n const { parseGantt } = await import('./gantt/parser');\n const { calculateSchedule } = await import('./gantt/calculator');\n const { renderGantt } = await import('./gantt/renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const ganttParsed = parseGantt(content, effectivePalette);\n const resolved = calculateSchedule(ganttParsed);\n if (resolved.tasks.length === 0) return '';\n\n const EXPORT_W = 1200;\n const EXPORT_H = 800;\n const container = createExportContainer(EXPORT_W, EXPORT_H);\n\n renderGantt(\n container,\n resolved,\n effectivePalette,\n theme === 'dark',\n {\n collapsedGroups: viewState?.cg ? new Set(viewState.cg) : undefined,\n currentSwimlaneGroup: viewState?.swim ?? undefined,\n collapsedLanes: viewState?.cl ? new Set(viewState.cl) : undefined,\n currentActiveGroup: resolveActiveTagGroup(\n resolved.tagGroups,\n resolved.options.activeTag ?? undefined,\n viewState?.tag ?? options?.tagGroup\n ),\n },\n { width: EXPORT_W, height: EXPORT_H }\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'state') {\n const { parseState } = await import('./graph/state-parser');\n const { layoutGraph } = await import('./graph/layout');\n const { renderState } = await import('./graph/state-renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const stateParsed = parseState(content, effectivePalette);\n if (stateParsed.error || stateParsed.nodes.length === 0) return '';\n\n const layout = layoutGraph(stateParsed);\n const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);\n\n renderState(\n container,\n stateParsed,\n layout,\n effectivePalette,\n theme === 'dark',\n undefined,\n { width: EXPORT_WIDTH, height: EXPORT_HEIGHT }\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'tech-radar') {\n const { parseTechRadar } = await import('./tech-radar/parser');\n const { renderTechRadarForExport } = await import('./tech-radar/renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const radarParsed = parseTechRadar(content);\n if (radarParsed.error || radarParsed.quadrants.length === 0) return '';\n\n const RADAR_EXPORT_W = 1600;\n const RADAR_EXPORT_H = 1200;\n const container = createExportContainer(RADAR_EXPORT_W, RADAR_EXPORT_H);\n renderTechRadarForExport(\n container,\n radarParsed,\n effectivePalette,\n theme === 'dark',\n { width: RADAR_EXPORT_W, height: RADAR_EXPORT_H },\n viewState\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'journey-map') {\n const { parseJourneyMap } = await import('./journey-map/parser');\n const { renderJourneyMap } = await import('./journey-map/renderer');\n const { layoutJourneyMap } = await import('./journey-map/layout');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const jmParsed = parseJourneyMap(content, effectivePalette);\n if (\n jmParsed.error ||\n (jmParsed.phases.length === 0 && jmParsed.steps.length === 0)\n )\n return '';\n\n const jmLayout = layoutJourneyMap(jmParsed, effectivePalette);\n const container = createExportContainer(\n jmLayout.totalWidth,\n jmLayout.totalHeight\n );\n renderJourneyMap(container, jmParsed, effectivePalette, theme === 'dark', {\n exportDims: { width: jmLayout.totalWidth, height: jmLayout.totalHeight },\n });\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'cycle') {\n const { parseCycle } = await import('./cycle/parser');\n const { renderCycleForExport } = await import('./cycle/renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const cycleParsed = parseCycle(content);\n if (cycleParsed.error || cycleParsed.nodes.length === 0) return '';\n\n const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);\n renderCycleForExport(\n container,\n cycleParsed,\n effectivePalette,\n theme === 'dark',\n { width: EXPORT_WIDTH, height: EXPORT_HEIGHT },\n viewState\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n if (detectedType === 'pyramid') {\n const { parsePyramid } = await import('./pyramid/parser');\n const { renderPyramidForExport } = await import('./pyramid/renderer');\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const pyramidParsed = parsePyramid(content);\n if (pyramidParsed.error || pyramidParsed.layers.length === 0) return '';\n\n const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);\n renderPyramidForExport(\n container,\n pyramidParsed,\n effectivePalette,\n theme === 'dark',\n { width: EXPORT_WIDTH, height: EXPORT_HEIGHT }\n );\n return finalizeSvgExport(container, theme, effectivePalette);\n }\n\n const parsed = parseVisualization(content, palette);\n // Allow sequence diagrams through even if parseVisualization errors —\n // sequence is parsed by its own dedicated parser (parseSequenceDgmo)\n // and may not have a \"chart:\" line (auto-detected from arrow syntax).\n if (parsed.error && parsed.type !== 'sequence') {\n // Check if content looks like a sequence diagram (has arrows but no chart: line)\n const looksLikeSequence = /->|~>|<-/.test(content);\n if (!looksLikeSequence) return '';\n parsed.type = 'sequence';\n }\n if (parsed.type === 'wordcloud' && parsed.words.length === 0) return '';\n if (parsed.type === 'slope' && parsed.data.length === 0) return '';\n if (parsed.type === 'arc' && parsed.links.length === 0) return '';\n if (parsed.type === 'timeline' && parsed.timelineEvents.length === 0)\n return '';\n if (parsed.type === 'venn' && parsed.vennSets.length < 2) return '';\n if (parsed.type === 'quadrant' && parsed.quadrantPoints.length === 0)\n return '';\n\n const effectivePalette = await resolveExportPalette(theme, palette);\n const isDark = theme === 'dark';\n const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);\n const dims: D3ExportDimensions = {\n width: EXPORT_WIDTH,\n height: EXPORT_HEIGHT,\n };\n\n if (parsed.type === 'sequence') {\n const { parseSequenceDgmo } = await import('./sequence/parser');\n const { renderSequenceDiagram } = await import('./sequence/renderer');\n const seqParsed = parseSequenceDgmo(content);\n if (seqParsed.error || seqParsed.participants.length === 0) return '';\n renderSequenceDiagram(\n container,\n seqParsed,\n effectivePalette,\n isDark,\n undefined,\n {\n exportWidth: EXPORT_WIDTH,\n activeTagGroup: options?.tagGroup,\n }\n );\n } else if (parsed.type === 'wordcloud') {\n await renderWordCloudAsync(\n container,\n parsed,\n effectivePalette,\n isDark,\n dims\n );\n } else if (parsed.type === 'arc') {\n renderArcDiagram(\n container,\n parsed,\n effectivePalette,\n isDark,\n undefined,\n dims\n );\n } else if (parsed.type === 'timeline') {\n renderTimeline(\n container,\n parsed,\n effectivePalette,\n isDark,\n undefined,\n dims,\n resolveActiveTagGroup(\n parsed.timelineTagGroups,\n undefined,\n viewState?.tag ?? options?.tagGroup\n ),\n viewState?.swim\n );\n } else if (parsed.type === 'venn') {\n renderVenn(container, parsed, effectivePalette, isDark, undefined, dims);\n } else if (parsed.type === 'quadrant') {\n renderQuadrant(\n container,\n parsed,\n effectivePalette,\n isDark,\n undefined,\n dims\n );\n } else {\n renderSlopeChart(\n container,\n parsed,\n effectivePalette,\n isDark,\n undefined,\n dims\n );\n }\n\n return finalizeSvgExport(container, theme, effectivePalette);\n}\n","// ============================================================\n// @diagrammo/dgmo/internal — internal helpers for app consumers.\n// Not part of the public API; may change between versions.\n// ============================================================\n\nexport { parseDataRowValues } from './chart';\nexport {\n computeCardArchive,\n computeCardMove,\n isArchiveColumn,\n} from './kanban/mutations';\nexport {\n applyGroupOrdering,\n applyPositionOverrides,\n buildNoteMessageMap,\n buildRenderSequence,\n computeActivations,\n groupMessagesBySection,\n} from './sequence/renderer';\nexport { orderArcNodes } from './d3';\n"],"mappings":";;;;;;AAiOO,SAAS,sBAAsB,OAA8B;AAElE,MAAI,CAAC,MAAM,SAAS,GAAG,KAAK,CAAC,MAAM,SAAS,GAAG,EAAG,QAAO;AAEzD,MAAI,MAAM,SAAS,GAAG,KAAK,MAAM,SAAS,GAAG,EAAG,QAAO;AAGvD,MAAI,OAAO;AACX,MAAI,WAAW;AACf,MAAI,SAAS,WAAW,GAAG,GAAG;AAC5B,WAAO;AACP,eAAW,SAAS,UAAU,CAAC;AAAA,EACjC;AACA,MAAI,CAAC,SAAU,QAAO;AAEtB,MAAI,SAAS,SAAS,GAAG,GAAG;AAE1B,QAAI,qBAAqB,KAAK,QAAQ;AACpC,aAAO,OAAO,SAAS,QAAQ,MAAM,EAAE;AAEzC,QAAI,0BAA0B,KAAK,QAAQ;AACzC,aAAO,OAAO,SAAS,QAAQ,MAAM,EAAE;AACzC,WAAO;AAAA,EACT;AAGA,MAAI,eAAe,KAAK,QAAQ,EAAG,QAAO,OAAO,SAAS,QAAQ,MAAM,EAAE;AAE1E,MAAI,oBAAoB,KAAK,QAAQ,KAAK,SAAS,SAAS,GAAG;AAC7D,WAAO,OAAO,SAAS,QAAQ,MAAM,EAAE;AACzC,SAAO;AACT;AAhQA;AAAA;AAAA;AAAA;AAAA;;;AC8dO,SAAS,mBACdA,OACA,SAC4C;AAM5C,QAAM,WAAWA,MAAK,MAAM,GAAG;AAI/B,QAAM,aAAuB,CAAC;AAC9B,WAAS,IAAI,GAAG,IAAI,SAAS,QAAQ,KAAK;AACxC,UAAM,MAAM,SAAS,CAAC,EAAE,KAAK;AAM7B,QAAI,IAAI,KAAK,kBAAkB,KAAK,GAAG,KAAK,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,GAAG;AACpE,YAAM,UAAU,WAAW,WAAW,SAAS,CAAC,EAAE,QAAQ;AAE1D,UAAI,WAAW,KAAK,OAAO,GAAG;AAG5B,cAAM,YAAY,QAAQ,MAAM,YAAY;AAC5C,YAAI,WAAW;AAIb,qBAAW,WAAW,SAAS,CAAC,IAAI,UAAU;AAC9C;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,eAAW,KAAK,SAAS,CAAC,CAAC;AAAA,EAC7B;AAEA,QAAM,UAAU,WAAW,KAAK,GAAG;AAQnC,QAAM,aAAa,QAAQ,MAAM,GAAG;AACpC,MAAI,WAAW,SAAS,GAAG;AAEzB,QAAI,eAAe;AACnB,aAAS,IAAI,WAAW,SAAS,GAAG,KAAK,GAAG,KAAK;AAC/C,YAAM,OACJ,sBAAsB,WAAW,CAAC,EAAE,KAAK,CAAC,KAAK,WAAW,CAAC,EAAE,KAAK;AACpE,UAAI,QAAQ,CAAC,MAAM,WAAW,IAAI,CAAC,KAAK,SAAS,OAAO,IAAI,CAAC,GAAG;AAC9D;AAAA,MACF,OAAO;AACL;AAAA,MACF;AAAA,IACF;AACA,QAAI,eAAe,GAAG;AAGpB,YAAM,UAAU,WAAW,SAAS;AACpC,YAAM,kBAAkB,WAAW,MAAM,OAAO;AAChD,YAAM,YAAY,WAAW,MAAM,GAAG,OAAO,EAAE,KAAK,GAAG,EAAE,KAAK;AAG9D,YAAM,eAAe,UAAU,YAAY,GAAG;AAC9C,UAAI,gBAAgB,GAAG;AACrB,cAAM,cAAc,UAAU,UAAU,eAAe,CAAC,EAAE,KAAK;AAC/D,cAAM,mBACJ,sBAAsB,WAAW,KAAK;AACxC,YACE,oBACA,CAAC,MAAM,WAAW,gBAAgB,CAAC,KACnC,SAAS,OAAO,gBAAgB,CAAC,GACjC;AACA,gBAAMC,SAAQ,UAAU,UAAU,GAAG,YAAY,EAAE,KAAK;AACxD,cAAIA,QAAO;AACT,kBAAM,SAAS,CAAC,WAAW,gBAAgB,CAAC;AAC5C,uBAAW,KAAK,iBAAiB;AAC/B,oBAAM,QAAQ,sBAAsB,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK;AACxD,qBAAO,KAAK,WAAW,KAAK,CAAC;AAAA,YAC/B;AACA,mBAAO,EAAE,OAAAA,QAAO,OAAO;AAAA,UACzB;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAMA,QAAM,SAAS,QAAQ,MAAM,KAAK;AAClC,MAAI,OAAO,SAAS,EAAG,QAAO;AAE9B,MAAI,SAAS,YAAY;AACvB,UAAM,QAAQ,QAAQ,kBAAkB;AACxC,UAAM,SAAmB,CAAC;AAC1B,QAAI,MAAM,OAAO,SAAS;AAC1B,WAAO,OAAO,KAAK,OAAO,SAAS,OAAO;AACxC,YAAM,MAAM,OAAO,GAAG;AACtB,YAAM,UAAU,sBAAsB,GAAG,KAAK;AAC9C,YAAMC,OAAM,WAAW,OAAO;AAC9B,UAAI,MAAMA,IAAG,KAAK,CAAC,SAAS,OAAO,OAAO,CAAC,EAAG;AAC9C,aAAO,QAAQA,IAAG;AAClB;AAAA,IACF;AACA,QAAI,OAAO,WAAW,EAAG,QAAO;AAChC,UAAMD,SAAQ,OAAO,MAAM,GAAG,MAAM,CAAC,EAAE,KAAK,GAAG;AAC/C,QAAI,CAACA,OAAO,QAAO;AACnB,WAAO,EAAE,OAAAA,QAAO,OAAO;AAAA,EACzB;AAGA,QAAM,YAAY,OAAO,OAAO,SAAS,CAAC;AAC1C,QAAM,iBAAiB,sBAAsB,SAAS,KAAK;AAC3D,QAAM,MAAM,WAAW,cAAc;AACrC,MAAI,MAAM,GAAG,KAAK,CAAC,SAAS,OAAO,cAAc,CAAC,EAAG,QAAO;AAE5D,QAAM,QAAQ,OAAO,MAAM,GAAG,EAAE,EAAE,KAAK,GAAG;AAC1C,MAAI,CAAC,MAAO,QAAO;AAEnB,SAAO,EAAE,OAAO,QAAQ,CAAC,GAAG,EAAE;AAChC;AA9lBA;AAAA;AAAA;AAgEA;AAAA;AAAA;;;AC9CO,SAAS,gBACd,SACA,QACA,QACA,gBACA,aACe;AAEf,MAAI,aAAgC;AACpC,MAAI,eAAoC;AAExC,aAAW,OAAO,OAAO,SAAS;AAChC,eAAW,QAAQ,IAAI,OAAO;AAC5B,UAAI,KAAK,OAAO,QAAQ;AACtB,qBAAa;AACb,uBAAe;AACf;AAAA,MACF;AAAA,IACF;AACA,QAAI,WAAY;AAAA,EAClB;AAEA,MAAI,CAAC,cAAc,CAAC,aAAc,QAAO;AAEzC,QAAM,eAAe,OAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,cAAc;AACvE,MAAI,CAAC,aAAc,QAAO;AAE1B,QAAM,QAAQ,QAAQ,MAAM,IAAI;AAGhC,QAAM,WAAW,WAAW,aAAa;AACzC,QAAM,SAAS,WAAW,gBAAgB;AAC1C,QAAM,YAAY,MAAM,MAAM,UAAU,SAAS,CAAC;AAGlD,QAAM,cAAc;AAAA,IAClB,GAAG,MAAM,MAAM,GAAG,QAAQ;AAAA,IAC1B,GAAG,MAAM,MAAM,SAAS,CAAC;AAAA,EAC3B;AAGA,MAAI;AAIJ,QAAM,eAAe,SAAS,WAAW;AACzC,QAAM,aAAa,CAAC,OAAuB;AAEzC,QAAI,KAAK,SAAS,EAAG,QAAO,KAAK;AACjC,WAAO;AAAA,EACT;AAEA,MAAI,gBAAgB,GAAG;AAErB,UAAM,aAAa,WAAW,aAAa,UAAU;AACrD,gBAAY;AAAA,EACd,OAAO;AAGL,UAAM,cAAc,aAAa,MAAM,OAAO,CAAC,MAAM,EAAE,OAAO,MAAM;AACpE,UAAM,aAAa,KAAK,IAAI,aAAa,YAAY,MAAM;AAC3D,UAAM,gBAAgB,YAAY,aAAa,CAAC;AAChD,QAAI,CAAC,eAAe;AAElB,YAAM,aAAa,WAAW,aAAa,UAAU;AACrD,kBAAY;AAAA,IACd,OAAO;AACL,YAAM,aAAa,WAAW,cAAc,aAAa;AACzD,kBAAY;AAAA,IACd;AAAA,EACF;AAGA,QAAM,SAAS;AAAA,IACb,GAAG,YAAY,MAAM,GAAG,SAAS;AAAA,IACjC,GAAG;AAAA,IACH,GAAG,YAAY,MAAM,SAAS;AAAA,EAChC;AAEA,SAAO,OAAO,KAAK,IAAI;AACzB;AAYO,SAAS,mBACd,SACA,QACA,QACe;AACf,MAAI,aAAgC;AACpC,aAAW,OAAO,OAAO,SAAS;AAChC,eAAW,QAAQ,IAAI,OAAO;AAC5B,UAAI,KAAK,OAAO,QAAQ;AACtB,qBAAa;AACb;AAAA,MACF;AAAA,IACF;AACA,QAAI,WAAY;AAAA,EAClB;AACA,MAAI,CAAC,WAAY,QAAO;AAExB,QAAM,QAAQ,QAAQ,MAAM,IAAI;AAGhC,QAAM,WAAW,WAAW,aAAa;AACzC,QAAM,SAAS,WAAW,gBAAgB;AAC1C,QAAM,YAAY,MAAM,MAAM,UAAU,SAAS,CAAC;AAGlD,QAAM,cAAc;AAAA,IAClB,GAAG,MAAM,MAAM,GAAG,QAAQ;AAAA,IAC1B,GAAG,MAAM,MAAM,SAAS,CAAC;AAAA,EAC3B;AAGA,QAAM,aAAa,OAAO,QAAQ;AAAA,IAChC,CAAC,MAAM,EAAE,KAAK,YAAY,MAAM;AAAA,EAClC;AAEA,MAAI,YAAY;AAGd,UAAM,eAAe,SAAS,WAAW;AACzC,QAAI,iBAAiB,WAAW;AAChC,QAAI,WAAW,MAAM,SAAS,GAAG;AAC/B,YAAM,WAAW,WAAW,MAAM,WAAW,MAAM,SAAS,CAAC;AAC7D,uBAAiB,SAAS;AAAA,IAC5B;AAEA,QAAI,iBAAiB,SAAS,GAAG;AAC/B,wBAAkB;AAAA,IACpB;AAEA,UAAM,YAAY;AAClB,WAAO;AAAA,MACL,GAAG,YAAY,MAAM,GAAG,SAAS;AAAA,MACjC,GAAG;AAAA,MACH,GAAG,YAAY,MAAM,SAAS;AAAA,IAChC,EAAE,KAAK,IAAI;AAAA,EACb,OAAO;AAGL,UAAM,aAAa,YAAY,SAAS,KAAK,YAAY,YAAY,SAAS,CAAC,EAAE,KAAK,MAAM,KACxF,cACA,CAAC,GAAG,aAAa,EAAE;AACvB,WAAO;AAAA,MACL,GAAG;AAAA,MACH;AAAA,MACA,GAAG;AAAA,IACL,EAAE,KAAK,IAAI;AAAA,EACb;AACF;AAGO,SAAS,gBAAgB,MAAuB;AACrD,SAAO,KAAK,YAAY,MAAM;AAChC;AAtLA,IAEM;AAFN;AAAA;AAAA;AAEA,IAAM,sBAAsB;AAAA;AAAA;;;ACqIrB,SAAS,gBAAgB,IAA0C;AACxE,SAAO,UAAU,MAAO,GAAqB,SAAS;AACxD;AAEO,SAAS,kBAAkB,IAA4C;AAC5E,SAAO,UAAU,MAAO,GAAuB,SAAS;AAC1D;AAEO,SAAS,eAAe,IAAyC;AACtE,SAAO,UAAU,MAAO,GAAoB,SAAS;AACvD;AAjJA;AAAA;AAAA;AAAA;AAAA;;;ACIA,YAAY,iBAAiB;AAkjBtB,SAAS,uBACd,UACA,UACuB;AACvB,QAAM,SAAgC,CAAC;AACvC,MAAI,eAA2C;AAG/C,QAAM,iBAAiB,CAAC,QAAqC;AAC3D,UAAM,UAAoB,CAAC;AAC3B,eAAW,MAAM,KAAK;AACpB,UAAI,gBAAgB,EAAE,GAAG;AACvB,gBAAQ;AAAA,UACN,GAAG,eAAe,GAAG,QAAQ;AAAA,UAC7B,GAAG,eAAe,GAAG,YAAY;AAAA,QACnC;AACA,YAAI,GAAG,gBAAgB;AACrB,qBAAW,UAAU,GAAG,gBAAgB;AACtC,oBAAQ,KAAK,GAAG,eAAe,OAAO,QAAQ,CAAC;AAAA,UACjD;AAAA,QACF;AAAA,MACF,WAAW,kBAAkB,EAAE,KAAK,eAAe,EAAE,GAAG;AAEtD;AAAA,MACF,OAAO;AACL,cAAM,MAAM,SAAS,QAAQ,EAAqB;AAClD,YAAI,OAAO,EAAG,SAAQ,KAAK,GAAG;AAAA,MAChC;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,aAAW,MAAM,UAAU;AACzB,QAAI,kBAAkB,EAAE,GAAG;AAEzB,qBAAe,EAAE,SAAS,IAAI,gBAAgB,CAAC,EAAE;AACjD,aAAO,KAAK,YAAY;AAAA,IAC1B,WAAW,cAAc;AAEvB,UAAI,gBAAgB,EAAE,GAAG;AACvB,qBAAa,eAAe,KAAK,GAAG,eAAe,CAAC,EAAE,CAAC,CAAC;AAAA,MAC1D,WAAW,CAAC,eAAe,EAAE,GAAG;AAC9B,cAAM,MAAM,SAAS,QAAQ,EAAqB;AAClD,YAAI,OAAO,EAAG,cAAa,eAAe,KAAK,GAAG;AAAA,MACpD;AAAA,IACF;AAAA,EAEF;AAEA,SAAO;AACT;AAoBO,SAAS,oBAAoB,UAA2C;AAC7E,QAAM,QAAsB,CAAC;AAC7B,QAAM,QAIA,CAAC;AAEP,WAAS,KAAK,GAAG,KAAK,SAAS,QAAQ,MAAM;AAC3C,UAAM,MAAM,SAAS,EAAE;AAEvB,WAAO,MAAM,SAAS,GAAG;AACvB,YAAM,MAAM,MAAM,MAAM,SAAS,CAAC;AAClC,UAAI,IAAI,OAAO,IAAI,KAAM;AACzB,YAAM,IAAI;AACV,YAAM,KAAK;AAAA,QACT,MAAM;AAAA,QACN,MAAM,IAAI;AAAA,QACV,IAAI,IAAI;AAAA,QACR,OAAO;AAAA,QACP,cAAc,IAAI;AAAA,MACpB,CAAC;AAAA,IACH;AAGA,UAAM,KAAK;AAAA,MACT,MAAM;AAAA,MACN,MAAM,IAAI;AAAA,MACV,IAAI,IAAI;AAAA,MACR,OAAO,IAAI;AAAA,MACX,cAAc;AAAA,MACd,GAAI,IAAI,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;AAAA,IACrC,CAAC;AAGD,QAAI,IAAI,OAAO;AACb;AAAA,IACF;AAEA,QAAI,IAAI,SAAS,IAAI,IAAI;AAEvB,YAAM,KAAK;AAAA,QACT,MAAM;AAAA,QACN,MAAM,IAAI;AAAA,QACV,IAAI,IAAI;AAAA,QACR,OAAO;AAAA,QACP,cAAc;AAAA,MAChB,CAAC;AAAA,IACH,OAAO;AAEL,YAAM,KAAK;AAAA,QACT,MAAM,IAAI;AAAA,QACV,IAAI,IAAI;AAAA,QACR,cAAc;AAAA,MAChB,CAAC;AAAA,IACH;AAAA,EACF;AAGA,SAAO,MAAM,SAAS,GAAG;AACvB,UAAM,MAAM,MAAM,IAAI;AACtB,UAAM,KAAK;AAAA,MACT,MAAM;AAAA,MACN,MAAM,IAAI;AAAA,MACV,IAAI,IAAI;AAAA,MACR,OAAO;AAAA,MACP,cAAc,IAAI;AAAA,IACpB,CAAC;AAAA,EACH;AAEA,SAAO;AACT;AAiBO,SAAS,mBAAmB,OAAmC;AACpE,QAAM,cAA4B,CAAC;AAEnC,QAAM,SAAS,oBAAI,IAAsB;AAEzC,QAAM,WAAW,CAAC,OAAyB;AACzC,QAAI,CAAC,OAAO,IAAI,EAAE,EAAG,QAAO,IAAI,IAAI,CAAC,CAAC;AACtC,WAAO,OAAO,IAAI,EAAE;AAAA,EACtB;AAEA,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,UAAM,OAAO,MAAM,CAAC;AACpB,QAAI,KAAK,SAAS,QAAQ;AACxB,YAAM,IAAI,SAAS,KAAK,EAAE;AAC1B,QAAE,KAAK,CAAC;AAAA,IACV,OAAO;AAEL,YAAM,IAAI,SAAS,KAAK,IAAI;AAC5B,UAAI,EAAE,SAAS,GAAG;AAChB,cAAM,WAAW,EAAE,IAAI;AACvB,oBAAY,KAAK;AAAA,UACf,eAAe,KAAK;AAAA,UACpB,WAAW;AAAA,UACX,SAAS;AAAA,UACT,OAAO,EAAE;AAAA,QACX,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAWO,SAAS,uBACd,cACuB;AACvB,MAAI,CAAC,aAAa,KAAK,CAAC,MAAM,EAAE,aAAa,MAAS,EAAG,QAAO;AAEhE,QAAM,QAAQ,aAAa;AAC3B,QAAM,aAAoE,CAAC;AAC3E,QAAM,eAAsC,CAAC;AAE7C,aAAW,KAAK,cAAc;AAC5B,QAAI,EAAE,aAAa,QAAW;AAE5B,UAAI,MAAM,EAAE,WAAW,IAAI,QAAQ,EAAE,WAAW,EAAE;AAElD,YAAM,KAAK,IAAI,GAAG,KAAK,IAAI,QAAQ,GAAG,GAAG,CAAC;AAC1C,iBAAW,KAAK,EAAE,aAAa,GAAG,OAAO,IAAI,CAAC;AAAA,IAChD,OAAO;AACL,mBAAa,KAAK,CAAC;AAAA,IACrB;AAAA,EACF;AAGA,aAAW,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;AAG3C,QAAM,SAAyC,IAAI,MAAM,KAAK,EAAE,KAAK,IAAI;AACzE,QAAM,cAAc,oBAAI,IAAY;AAEpC,aAAW,EAAE,aAAa,MAAM,KAAK,YAAY;AAC/C,QAAI,MAAM;AACV,QAAI,YAAY,IAAI,GAAG,GAAG;AAExB,eAAS,SAAS,GAAG,SAAS,OAAO,UAAU;AAC7C,YAAI,MAAM,SAAS,SAAS,CAAC,YAAY,IAAI,MAAM,MAAM,GAAG;AAC1D,gBAAM,MAAM;AACZ;AAAA,QACF;AACA,YAAI,MAAM,UAAU,KAAK,CAAC,YAAY,IAAI,MAAM,MAAM,GAAG;AACvD,gBAAM,MAAM;AACZ;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,WAAO,GAAG,IAAI;AACd,gBAAY,IAAI,GAAG;AAAA,EACrB;AAGA,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,OAAO,KAAK;AAC9B,QAAI,OAAO,CAAC,MAAM,MAAM;AACtB,aAAO,CAAC,IAAI,aAAa,MAAM;AAAA,IACjC;AAAA,EACF;AAEA,SAAO;AACT;AAcO,SAAS,mBACd,cACA,QACA,WAA8B,CAAC,GACR;AACvB,MAAI,OAAO,WAAW,EAAG,QAAO;AAGhC,QAAM,YAAY,oBAAI,IAA2B;AACjD,aAAW,SAAS,QAAQ;AAC1B,eAAW,MAAM,MAAM,gBAAgB;AACrC,gBAAU,IAAI,IAAI,KAAK;AAAA,IACzB;AAAA,EACF;AAKA,QAAM,kBAA4B,CAAC;AACnC,QAAM,OAAO,oBAAI,IAAY;AAC7B,aAAW,OAAO,UAAU;AAC1B,eAAW,MAAM,CAAC,IAAI,MAAM,IAAI,EAAE,GAAG;AACnC,UAAI,CAAC,KAAK,IAAI,EAAE,GAAG;AACjB,aAAK,IAAI,EAAE;AACX,wBAAgB,KAAK,EAAE;AAAA,MACzB;AAAA,IACF;AAAA,EACF;AAEA,aAAW,KAAK,cAAc;AAC5B,QAAI,CAAC,KAAK,IAAI,EAAE,EAAE,GAAG;AACnB,WAAK,IAAI,EAAE,EAAE;AACb,sBAAgB,KAAK,EAAE,EAAE;AAAA,IAC3B;AAAA,EACF;AAIA,QAAM,SAAgC,CAAC;AACvC,QAAM,SAAS,oBAAI,IAAY;AAC/B,QAAM,eAAe,oBAAI,IAAmB;AAE5C,aAAW,MAAM,iBAAiB;AAChC,QAAI,OAAO,IAAI,EAAE,EAAG;AAEpB,UAAM,QAAQ,UAAU,IAAI,EAAE;AAC9B,QAAI,SAAS,CAAC,aAAa,IAAI,KAAK,GAAG;AAErC,mBAAa,IAAI,KAAK;AACtB,iBAAW,OAAO,MAAM,gBAAgB;AACtC,cAAM,IAAI,aAAa,KAAK,CAAC,OAAO,GAAG,OAAO,GAAG;AACjD,YAAI,KAAK,CAAC,OAAO,IAAI,GAAG,GAAG;AACzB,iBAAO,KAAK,CAAC;AACb,iBAAO,IAAI,GAAG;AAAA,QAChB;AAAA,MACF;AAAA,IACF,WAAW,CAAC,OAAO;AAEjB,YAAM,IAAI,aAAa,KAAK,CAAC,OAAO,GAAG,OAAO,EAAE;AAChD,UAAI,GAAG;AACL,eAAO,KAAK,CAAC;AACb,eAAO,IAAI,EAAE;AAAA,MACf;AAAA,IACF;AAAA,EAEF;AAEA,SAAO;AACT;AAwzDO,SAAS,oBACd,UACqB;AACrB,QAAM,MAAM,oBAAI,IAAoB;AACpC,MAAI,kBAAkB;AAEtB,QAAM,OAAO,CAAC,QAAiC;AAC7C,eAAW,MAAM,KAAK;AACpB,UAAI,eAAe,EAAE,GAAG;AACtB,YAAI,mBAAmB,GAAG;AACxB,cAAI,IAAI,GAAG,YAAY,eAAe;AAAA,QACxC;AAAA,MACF,WAAW,gBAAgB,EAAE,GAAG;AAC9B,aAAK,GAAG,QAAQ;AAChB,YAAI,GAAG,gBAAgB;AACrB,qBAAW,UAAU,GAAG,gBAAgB;AACtC,iBAAK,OAAO,QAAQ;AAAA,UACtB;AAAA,QACF;AACA,aAAK,GAAG,YAAY;AAAA,MACtB,WAAW,CAAC,kBAAkB,EAAE,GAAG;AAEjC,cAAM,MAAM;AACZ,0BAAkB,IAAI;AAAA,MACxB;AAAA,IACF;AAAA,EACF;AACA,OAAK,QAAQ;AACb,SAAO;AACT;AA7tFA,IAyCM,iBACA,uBAWA,YACA,WACA,YAIA,UACA,aACA,qBAGA,kBAIA,eA0CA,kBACA;AA/GN;AAAA;AAAA;AAsBA;AAmBA,IAAM,kBAAkB;AACxB,IAAM,wBAAwB;AAW9B,IAAM,aAAa;AACnB,IAAM,YAAY;AAClB,IAAM,aAAa;AAInB,IAAM,WAAW;AACjB,IAAM,cAAc;AACpB,IAAM,sBAAsB,KAAK;AAAA,OAC9B,aAAa,aAAa,IAAI,aAAa;AAAA,IAC9C;AACA,IAAM,mBAAmB;AAIzB,IAAM,gBAAgB,kBAAkB,mBAAmB;AA0C3D,IAAM,mBAAmB;AACzB,IAAM,kBAAkB,KAAK;AAAA,OAC1B,wBAAwB,MAAM;AAAA,IACjC;AAAA;AAAA;;;ACjHA,YAAY,aAAa;AACzB,YAAYE,kBAAiB;AAC7B,YAAY,aAAa;AACzB,YAAY,aAAa;AACzB,OAAO,WAAW;AAkjEX,SAAS,cACd,OACA,OACA,QACU;AAEV,QAAM,UAAU,oBAAI,IAAY;AAChC,aAAW,QAAQ,OAAO;AACxB,YAAQ,IAAI,KAAK,MAAM;AACvB,YAAQ,IAAI,KAAK,MAAM;AAAA,EACzB;AACA,QAAM,WAAW,MAAM,KAAK,OAAO;AAEnC,MAAI,UAAU,QAAQ;AACpB,WAAO,SAAS,MAAM,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAAA,EAC3D;AAEA,MAAI,UAAU,UAAU;AACtB,UAAM,SAAS,oBAAI,IAAoB;AACvC,eAAW,QAAQ,SAAU,QAAO,IAAI,MAAM,CAAC;AAC/C,eAAW,QAAQ,OAAO;AACxB,aAAO,IAAI,KAAK,QAAQ,OAAO,IAAI,KAAK,MAAM,IAAK,KAAK,KAAK;AAC7D,aAAO,IAAI,KAAK,QAAQ,OAAO,IAAI,KAAK,MAAM,IAAK,KAAK,KAAK;AAAA,IAC/D;AACA,WAAO,SAAS,MAAM,EAAE,KAAK,CAAC,GAAG,MAAM;AACrC,YAAM,OAAO,OAAO,IAAI,CAAC,IAAK,OAAO,IAAI,CAAC;AAC1C,aAAO,SAAS,IAAI,OAAO,EAAE,cAAc,CAAC;AAAA,IAC9C,CAAC;AAAA,EACH;AAEA,MAAI,UAAU,SAAS;AACrB,QAAI,OAAO,SAAS,GAAG;AAErB,YAAM,UAAoB,CAAC;AAC3B,YAAM,SAAS,oBAAI,IAAY;AAC/B,iBAAW,SAAS,QAAQ;AAC1B,mBAAW,QAAQ,MAAM,OAAO;AAC9B,cAAI,CAAC,OAAO,IAAI,IAAI,GAAG;AACrB,oBAAQ,KAAK,IAAI;AACjB,mBAAO,IAAI,IAAI;AAAA,UACjB;AAAA,QACF;AAAA,MACF;AAEA,iBAAW,QAAQ,UAAU;AAC3B,YAAI,CAAC,OAAO,IAAI,IAAI,GAAG;AACrB,kBAAQ,KAAK,IAAI;AACjB,iBAAO,IAAI,IAAI;AAAA,QACjB;AAAA,MACF;AACA,aAAO;AAAA,IACT;AAEA,UAAM,MAAM,oBAAI,IAAyB;AACzC,eAAW,QAAQ,SAAU,KAAI,IAAI,MAAM,oBAAI,IAAI,CAAC;AACpD,eAAW,QAAQ,OAAO;AACxB,UAAI,IAAI,KAAK,MAAM,EAAG,IAAI,KAAK,MAAM;AACrC,UAAI,IAAI,KAAK,MAAM,EAAG,IAAI,KAAK,MAAM;AAAA,IACvC;AAEA,UAAM,SAAS,oBAAI,IAAoB;AACvC,eAAW,QAAQ,SAAU,QAAO,IAAI,MAAM,CAAC;AAC/C,eAAW,QAAQ,OAAO;AACxB,aAAO,IAAI,KAAK,QAAQ,OAAO,IAAI,KAAK,MAAM,IAAK,KAAK,KAAK;AAC7D,aAAO,IAAI,KAAK,QAAQ,OAAO,IAAI,KAAK,MAAM,IAAK,KAAK,KAAK;AAAA,IAC/D;AAEA,UAAM,UAAU,oBAAI,IAAY;AAChC,UAAM,aAAyB,CAAC;AAEhC,UAAM,YAAY,IAAI,IAAI,QAAQ;AAClC,WAAO,UAAU,OAAO,GAAG;AAEzB,UAAI,OAAO;AACX,UAAI,SAAS;AACb,iBAAW,QAAQ,WAAW;AAC5B,YAAI,OAAO,IAAI,IAAI,IAAK,QAAQ;AAC9B,mBAAS,OAAO,IAAI,IAAI;AACxB,iBAAO;AAAA,QACT;AAAA,MACF;AAEA,YAAM,YAAsB,CAAC;AAC7B,YAAM,QAAQ,CAAC,IAAI;AACnB,cAAQ,IAAI,IAAI;AAChB,gBAAU,OAAO,IAAI;AACrB,aAAO,MAAM,SAAS,GAAG;AACvB,cAAM,OAAO,MAAM,MAAM;AACzB,kBAAU,KAAK,IAAI;AACnB,mBAAW,YAAY,IAAI,IAAI,IAAI,GAAI;AACrC,cAAI,CAAC,QAAQ,IAAI,QAAQ,GAAG;AAC1B,oBAAQ,IAAI,QAAQ;AACpB,sBAAU,OAAO,QAAQ;AACzB,kBAAM,KAAK,QAAQ;AAAA,UACrB;AAAA,QACF;AAAA,MACF;AACA,iBAAW,KAAK,SAAS;AAAA,IAC3B;AAEA,eAAW,KAAK,CAAC,GAAG,MAAM,EAAE,SAAS,EAAE,MAAM;AAC7C,WAAO,WAAW,KAAK;AAAA,EACzB;AAGA,SAAO;AACT;AAhqEA;AAAA;AAAA;AAAA;AAAA;;;ACKA;AACA;AAKA;AAQA;","names":["line","label","num","d3Selection"]}