@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
package/src/cli.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  /* eslint-disable no-console */
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { execSync } from 'node:child_process';
4
- import { homedir } from 'node:os';
4
+ import { homedir, platform } from 'node:os';
5
5
  import { resolve, join, basename, extname } from 'node:path';
6
6
  import { createInterface } from 'node:readline';
7
7
  import { Resvg } from '@resvg/resvg-js';
8
8
  import { render } from './render';
9
- import { parseDgmo, getAllChartTypes } from './dgmo-router';
9
+ import {
10
+ parseDgmo,
11
+ getAllChartTypes,
12
+ CHART_TYPE_DESCRIPTIONS,
13
+ } from './dgmo-router';
10
14
  import { parseDgmoChartType } from './dgmo-router';
11
15
  import { formatDgmoError } from './diagnostics';
12
16
  import { getPalette, getAvailablePalettes } from './palettes';
@@ -19,40 +23,6 @@ const PALETTES = getAvailablePalettes().map((p) => p.id);
19
23
 
20
24
  const THEMES = ['light', 'dark', 'transparent'] as const;
21
25
 
22
- const CHART_TYPE_DESCRIPTIONS: Record<string, string> = {
23
- bar: 'Bar chart — categorical comparisons',
24
- line: 'Line chart — trends over time',
25
- 'multi-line': 'Multi-line chart — multiple series trends',
26
- area: 'Area chart — filled line chart',
27
- pie: 'Pie chart — part-to-whole proportions',
28
- doughnut: 'Doughnut chart — ring-style pie chart',
29
- radar: 'Radar chart — multi-dimensional metrics',
30
- 'polar-area': 'Polar area chart — radial bar chart',
31
- 'bar-stacked': 'Stacked bar chart — multi-series categorical',
32
- scatter: 'Scatter plot — 2D data points or bubble chart',
33
- sankey: 'Sankey diagram — flow/allocation visualization',
34
- chord: 'Chord diagram — circular flow relationships',
35
- function: 'Function plot — mathematical expressions',
36
- heatmap: 'Heatmap — matrix intensity visualization',
37
- funnel: 'Funnel chart — conversion pipeline',
38
- slope: 'Slope chart — change between two periods',
39
- wordcloud: 'Word cloud — term frequency visualization',
40
- arc: 'Arc diagram — network relationships',
41
- timeline: 'Timeline — events, eras, and date ranges',
42
- venn: 'Venn diagram — set overlaps',
43
- quadrant: 'Quadrant chart — 2x2 positioning matrix',
44
- sequence: 'Sequence diagram — message/interaction flows',
45
- flowchart: 'Flowchart — decision trees and process flows',
46
- class: 'Class diagram — UML class hierarchies',
47
- er: 'ER diagram — database schemas and relationships',
48
- org: 'Org chart — hierarchical tree structures',
49
- kanban: 'Kanban board — task/workflow columns',
50
- c4: 'C4 diagram — system architecture (context, container, component, deployment)',
51
- infra: 'Infra chart — infrastructure traffic flow with rps computation',
52
- 'boxes-and-lines':
53
- 'Boxes and lines — general-purpose node-edge diagrams with groups and tags',
54
- };
55
-
56
26
  const CLAUDE_SKILL_CONTENT = `# dgmo — Diagrammo Diagram Assistant
57
27
 
58
28
  You are helping the user author, render, and share diagrams using the \`dgmo\` CLI and \`.dgmo\` file format.
@@ -497,10 +467,6 @@ Options:
497
467
  With stdin and no -o, PNG is written to stdout
498
468
  --theme <theme> Theme: ${THEMES.join(', ')} (default: light)
499
469
  --palette <name> Palette: ${PALETTES.join(', ')} (default: nord)
500
- --c4-level <level> C4 render level: context (default), containers, components, deployment
501
- --c4-system <name> System to drill into (with --c4-level containers or components)
502
- --c4-container <name> Container to drill into (with --c4-level components)
503
- --tag-group <name> Pre-select a tag group for static export coloring
504
470
  --copy Copy URL to clipboard (only with -o url)
505
471
  --json Output structured JSON to stdout
506
472
  --chart-types List all supported chart types
@@ -512,6 +478,11 @@ Options:
512
478
  --install-codex-integration
513
479
  Full Codex CLI setup: write AGENTS.md to the project and configure
514
480
  the dgmo MCP server in .codex/config.toml (project) or ~/.codex/config.toml (global)
481
+ --install-claude-desktop-integration
482
+ Full Claude Desktop setup: install @diagrammo/dgmo-mcp if needed,
483
+ then merge the dgmo MCP entry into Claude Desktop's config file
484
+ (~/Library/Application Support/Claude/claude_desktop_config.json on macOS,
485
+ %APPDATA%/Claude/... on Windows, ~/.config/Claude/... on Linux)
515
486
  --help Show this help
516
487
  --version Show version`);
517
488
  }
@@ -538,10 +509,7 @@ function parseArgs(argv: string[]): {
538
509
  installClaudeSkill: boolean;
539
510
  installClaudeCodeIntegration: boolean;
540
511
  installCodexIntegration: boolean;
541
- c4Level: 'context' | 'containers' | 'components' | 'deployment';
542
- c4System: string | undefined;
543
- c4Container: string | undefined;
544
- tagGroup: string | undefined;
512
+ installClaudeDesktopIntegration: boolean;
545
513
  } {
546
514
  const result = {
547
515
  input: undefined as string | undefined,
@@ -558,14 +526,7 @@ function parseArgs(argv: string[]): {
558
526
  installClaudeSkill: false,
559
527
  installClaudeCodeIntegration: false,
560
528
  installCodexIntegration: false,
561
- c4Level: 'context' as
562
- | 'context'
563
- | 'containers'
564
- | 'components'
565
- | 'deployment',
566
- c4System: undefined as string | undefined,
567
- c4Container: undefined as string | undefined,
568
- tagGroup: undefined as string | undefined,
529
+ installClaudeDesktopIntegration: false,
569
530
  };
570
531
 
571
532
  const args = argv.slice(2); // skip node + script
@@ -609,30 +570,6 @@ function parseArgs(argv: string[]): {
609
570
  }
610
571
  result.palette = val;
611
572
  i++;
612
- } else if (arg === '--c4-level') {
613
- const val = args[++i];
614
- if (
615
- val !== 'context' &&
616
- val !== 'containers' &&
617
- val !== 'components' &&
618
- val !== 'deployment'
619
- ) {
620
- console.error(
621
- `Error: Invalid C4 level "${val}". Valid levels: context, containers, components, deployment`
622
- );
623
- process.exit(1);
624
- }
625
- result.c4Level = val;
626
- i++;
627
- } else if (arg === '--c4-system') {
628
- result.c4System = args[++i];
629
- i++;
630
- } else if (arg === '--c4-container') {
631
- result.c4Container = args[++i];
632
- i++;
633
- } else if (arg === '--tag-group') {
634
- result.tagGroup = args[++i];
635
- i++;
636
573
  } else if (arg === '--json') {
637
574
  result.json = true;
638
575
  i++;
@@ -648,6 +585,9 @@ function parseArgs(argv: string[]): {
648
585
  } else if (arg === '--install-codex-integration') {
649
586
  result.installCodexIntegration = true;
650
587
  i++;
588
+ } else if (arg === '--install-claude-desktop-integration') {
589
+ result.installClaudeDesktopIntegration = true;
590
+ i++;
651
591
  } else if (arg === '--copy') {
652
592
  result.copy = true;
653
593
  i++;
@@ -673,12 +613,19 @@ function inferFormat(outputPath: string | undefined): 'svg' | 'png' | 'url' {
673
613
  return 'png';
674
614
  }
675
615
 
616
+ const BUNDLED_FONTS = [
617
+ join(__dirname, '..', 'fonts', 'Inter-Regular.ttf'),
618
+ join(__dirname, '..', 'fonts', 'Inter-Bold.ttf'),
619
+ ];
620
+
676
621
  function svgToPng(svg: string, background?: string): Buffer {
622
+ const fontFiles = BUNDLED_FONTS.filter((f) => existsSync(f));
677
623
  const resvg = new Resvg(svg, {
678
624
  fitTo: { mode: 'zoom', value: 2 },
679
625
  ...(background ? { background } : {}),
680
626
  font: {
681
- loadSystemFonts: true,
627
+ loadSystemFonts: fontFiles.length === 0,
628
+ ...(fontFiles.length > 0 ? { fontFiles } : {}),
682
629
  defaultFontFamily: DEFAULT_FONT_NAME,
683
630
  sansSerifFamily: DEFAULT_FONT_NAME,
684
631
  },
@@ -745,7 +692,7 @@ async function main(): Promise<void> {
745
692
  } else {
746
693
  for (const id of types) {
747
694
  const desc = CHART_TYPE_DESCRIPTIONS[id];
748
- console.log(desc ? `${id} — ${desc.split(' — ')[1]}` : id);
695
+ console.log(desc ? `${id} — ${desc}` : id);
749
696
  }
750
697
  }
751
698
  return;
@@ -1068,6 +1015,133 @@ async function main(): Promise<void> {
1068
1015
  return;
1069
1016
  }
1070
1017
 
1018
+ if (opts.installClaudeDesktopIntegration) {
1019
+ const ask = (prompt: string): Promise<string> =>
1020
+ new Promise((resolve) => {
1021
+ const rl = createInterface({
1022
+ input: process.stdin,
1023
+ output: process.stdout,
1024
+ });
1025
+ rl.question(prompt, (answer) => {
1026
+ rl.close();
1027
+ resolve(answer);
1028
+ });
1029
+ });
1030
+
1031
+ // Check / install dgmo-mcp binary
1032
+ let dgmoMcpInstalled = false;
1033
+ try {
1034
+ execSync('which dgmo-mcp', { stdio: 'pipe' });
1035
+ dgmoMcpInstalled = true;
1036
+ } catch {
1037
+ /* not found */
1038
+ }
1039
+ if (!dgmoMcpInstalled) {
1040
+ const ans = await ask(
1041
+ '\ndgmo-mcp not found. Install @diagrammo/dgmo-mcp globally now? [Y/n] '
1042
+ );
1043
+ const yes =
1044
+ ans === '' || ans.toLowerCase() === 'y' || ans.toLowerCase() === 'yes';
1045
+ if (yes) {
1046
+ console.log('Installing @diagrammo/dgmo-mcp...');
1047
+ try {
1048
+ execSync('npm install -g @diagrammo/dgmo-mcp', { stdio: 'inherit' });
1049
+ console.log('✓ @diagrammo/dgmo-mcp installed');
1050
+ } catch {
1051
+ console.error('Error: Failed to install @diagrammo/dgmo-mcp.');
1052
+ console.error('Try manually: npm install -g @diagrammo/dgmo-mcp');
1053
+ }
1054
+ } else {
1055
+ console.log(
1056
+ ' Skipped. Install later with: npm install -g @diagrammo/dgmo-mcp'
1057
+ );
1058
+ }
1059
+ } else {
1060
+ console.log('✓ dgmo-mcp already installed');
1061
+ }
1062
+
1063
+ // Resolve the Claude Desktop config path for the current platform.
1064
+ // macOS and Windows use the documented Claude Desktop paths; Linux
1065
+ // doesn't have a first-party build yet, but community installs follow
1066
+ // the XDG config convention.
1067
+ const os = platform();
1068
+ let configPath: string;
1069
+ if (os === 'darwin') {
1070
+ configPath = join(
1071
+ homedir(),
1072
+ 'Library',
1073
+ 'Application Support',
1074
+ 'Claude',
1075
+ 'claude_desktop_config.json'
1076
+ );
1077
+ } else if (os === 'win32') {
1078
+ const appData =
1079
+ process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming');
1080
+ configPath = join(appData, 'Claude', 'claude_desktop_config.json');
1081
+ } else {
1082
+ configPath = join(
1083
+ homedir(),
1084
+ '.config',
1085
+ 'Claude',
1086
+ 'claude_desktop_config.json'
1087
+ );
1088
+ }
1089
+
1090
+ // Read existing config (or start fresh). Non-JSON contents are treated
1091
+ // as corruption and we bail — the user needs to resolve it manually so
1092
+ // we don't silently overwrite something they care about.
1093
+ type ClaudeDesktopConfig = {
1094
+ mcpServers?: Record<
1095
+ string,
1096
+ { command: string; args?: string[]; env?: Record<string, string> }
1097
+ >;
1098
+ [key: string]: unknown;
1099
+ };
1100
+ let config: ClaudeDesktopConfig = {};
1101
+ if (existsSync(configPath)) {
1102
+ const raw = readFileSync(configPath, 'utf-8');
1103
+ if (raw.trim().length > 0) {
1104
+ try {
1105
+ config = JSON.parse(raw) as ClaudeDesktopConfig;
1106
+ } catch {
1107
+ console.error(
1108
+ `Error: ${configPath} exists but is not valid JSON. Fix it manually and re-run, or remove the file to regenerate.`
1109
+ );
1110
+ process.exit(1);
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ const existingDgmo = config.mcpServers?.dgmo;
1116
+ if (existingDgmo && existingDgmo.command === 'dgmo-mcp') {
1117
+ console.log(`✓ dgmo MCP server already configured in ${configPath}`);
1118
+ } else {
1119
+ if (existingDgmo) {
1120
+ const ans = await ask(
1121
+ `\nA "dgmo" entry already exists in ${configPath}. Overwrite? [y/N] `
1122
+ );
1123
+ if (ans.toLowerCase() !== 'y' && ans.toLowerCase() !== 'yes') {
1124
+ console.log(' Skipped.');
1125
+ return;
1126
+ }
1127
+ }
1128
+ config.mcpServers = {
1129
+ ...(config.mcpServers ?? {}),
1130
+ dgmo: { command: 'dgmo-mcp' },
1131
+ };
1132
+ mkdirSync(join(configPath, '..'), { recursive: true });
1133
+ writeFileSync(
1134
+ configPath,
1135
+ JSON.stringify(config, null, 2) + '\n',
1136
+ 'utf-8'
1137
+ );
1138
+ console.log(`✓ dgmo MCP server configured: ${configPath}`);
1139
+ }
1140
+
1141
+ console.log('\nRestart Claude Desktop to activate the MCP server.');
1142
+ return;
1143
+ }
1144
+
1071
1145
  // Determine input source
1072
1146
  let content: string;
1073
1147
  let inputBasename: string | undefined;
@@ -1225,32 +1299,9 @@ async function main(): Promise<void> {
1225
1299
  }
1226
1300
  }
1227
1301
 
1228
- // Validate C4 options
1229
- if (opts.c4Level === 'containers' && !opts.c4System) {
1230
- exitWithJsonError(
1231
- 'Error: --c4-system is required when --c4-level is containers'
1232
- );
1233
- }
1234
- if (opts.c4Level === 'components') {
1235
- if (!opts.c4System) {
1236
- exitWithJsonError(
1237
- 'Error: --c4-system is required when --c4-level is components'
1238
- );
1239
- }
1240
- if (!opts.c4Container) {
1241
- exitWithJsonError(
1242
- 'Error: --c4-container is required when --c4-level is components'
1243
- );
1244
- }
1245
- }
1246
-
1247
1302
  const { svg } = await render(content, {
1248
1303
  theme: opts.theme,
1249
1304
  palette: opts.palette,
1250
- c4Level: opts.c4Level,
1251
- c4System: opts.c4System,
1252
- c4Container: opts.c4Container,
1253
- tagGroup: opts.tagGroup,
1254
1305
  });
1255
1306
 
1256
1307
  if (!svg) {
package/src/completion.ts CHANGED
@@ -16,6 +16,7 @@ import { extractSymbols as extractFlowchartSymbols } from './graph/flowchart-par
16
16
  import { extractSymbols as extractInfraSymbols } from './infra/parser';
17
17
  import { extractSymbols as extractClassSymbols } from './class/parser';
18
18
  import { parseFirstLine, ALL_CHART_TYPES } from './utils/parsing';
19
+ import { CHART_TYPE_DESCRIPTIONS } from './dgmo-router';
19
20
 
20
21
  // ============================================================
21
22
  // Symbol extraction
@@ -237,10 +238,6 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
237
238
  description: 'Show activation bars',
238
239
  values: ['on', 'off'],
239
240
  },
240
- 'collapse-notes': {
241
- description: 'Collapse note blocks',
242
- values: ['yes', 'no'],
243
- },
244
241
  'active-tag': { description: 'Active tag group name' },
245
242
  }),
246
243
  ],
@@ -381,56 +378,20 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
381
378
  persona: { description: 'Define the journey persona' },
382
379
  }),
383
380
  ],
381
+ [
382
+ 'pyramid',
383
+ withGlobals({
384
+ inverted: { description: 'Flip apex to the bottom (funnel orientation)' },
385
+ color: { description: 'Override layer color (pipe metadata)' },
386
+ description: { description: 'Layer description (pipe or indented body)' },
387
+ }),
388
+ ],
384
389
  ]);
385
390
 
386
391
  // ============================================================
387
392
  // Chart types array (for chart type completion popup)
388
393
  // ============================================================
389
394
 
390
- const CHART_TYPE_DESCRIPTIONS: Record<string, string> = {
391
- // Data charts
392
- bar: 'Bar chart',
393
- line: 'Line chart',
394
- pie: 'Pie chart',
395
- doughnut: 'Doughnut chart',
396
- area: 'Area chart',
397
- 'polar-area': 'Polar area chart',
398
- radar: 'Radar chart',
399
- 'bar-stacked': 'Stacked bar chart',
400
- // Extended charts
401
- scatter: 'Scatter plot',
402
- heatmap: 'Heatmap',
403
- sankey: 'Sankey flow diagram',
404
- chord: 'Chord diagram',
405
- funnel: 'Funnel chart',
406
- function: 'Mathematical function plot',
407
- // Visualizations
408
- slope: 'Slope chart',
409
- wordcloud: 'Word cloud',
410
- arc: 'Arc diagram',
411
- timeline: 'Timeline',
412
- venn: 'Venn diagram',
413
- quadrant: 'Quadrant chart',
414
- // Diagrams
415
- sequence: 'Sequence diagram',
416
- flowchart: 'Flowchart',
417
- class: 'Class diagram',
418
- er: 'Entity-relationship diagram',
419
- org: 'Organization chart',
420
- kanban: 'Kanban board',
421
- c4: 'C4 architecture diagram',
422
- state: 'State diagram',
423
- sitemap: 'Sitemap diagram',
424
- infra: 'Infrastructure diagram',
425
- gantt: 'Gantt chart',
426
- 'boxes-and-lines': 'Boxes and lines diagram',
427
- mindmap: 'Mindmap diagram',
428
- wireframe: 'UI wireframe diagram',
429
- 'tech-radar': 'Technology adoption radar (ThoughtWorks style)',
430
- cycle: 'Cycle diagram (circular process flow)',
431
- 'journey-map': 'User journey map with emotion curve',
432
- };
433
-
434
395
  /** All chart types with descriptions, for chart type autocomplete. Excludes `multi-line` alias. */
435
396
  export const CHART_TYPES: ReadonlyArray<{ name: string; description: string }> =
436
397
  [...ALL_CHART_TYPES]
@@ -2,11 +2,14 @@
2
2
  // Cycle Diagram — Layout Engine
3
3
  // ============================================================
4
4
 
5
- import type {
6
- ParsedCycle,
7
- CycleLayoutNode,
8
- CycleLayoutEdge,
9
- CycleLayoutResult,
5
+ import {
6
+ DEFAULT_EDGE_WIDTH,
7
+ MIN_EDGE_WIDTH,
8
+ arrowHeadLength,
9
+ type ParsedCycle,
10
+ type CycleLayoutNode,
11
+ type CycleLayoutEdge,
12
+ type CycleLayoutResult,
10
13
  } from './types';
11
14
 
12
15
  /** Minimum arc angle in radians (~15°) to keep arcs readable. */
@@ -519,11 +522,6 @@ function circleRectExitAngle(
519
522
  return (insideAngle + outsideAngle) / 2;
520
523
  }
521
524
 
522
- /** Default edge stroke width (must match renderer). */
523
- const DEFAULT_EDGE_WIDTH = 3;
524
- /** Arrowhead marker width in stroke-width units (must match renderer). */
525
- const ARROWHEAD_MARKER_W = 8;
526
-
527
525
  /** Compute edge paths for all edges in the parsed diagram. */
528
526
  function computeEdgePaths(
529
527
  layoutNodes: CycleLayoutNode[],
@@ -536,9 +534,13 @@ function computeEdgePaths(
536
534
  return parsed.edges.map((edge) => {
537
535
  const src = layoutNodes[edge.sourceIndex];
538
536
  const tgt = layoutNodes[edge.targetIndex];
539
- const strokeWidth = edge.width ?? DEFAULT_EDGE_WIDTH;
540
- // Arrowhead rendered length in pixels (markerUnits = strokeWidth)
541
- const arrowLen = ARROWHEAD_MARKER_W * strokeWidth;
537
+ const strokeWidth = Math.max(
538
+ edge.width ?? DEFAULT_EDGE_WIDTH,
539
+ MIN_EDGE_WIDTH
540
+ );
541
+ // Arrowhead effective reach: full length minus the 10% overlap that
542
+ // slides the marker back to cover the stroke/arrowhead junction line.
543
+ const arrowLen = arrowHeadLength(strokeWidth) * 0.9;
542
544
  const { path, labelX, labelY, labelAngle } = buildEdgeArc(
543
545
  src,
544
546
  tgt,
@@ -578,7 +580,7 @@ function fitToCanvas(
578
580
  height: number,
579
581
  _isClockwise: boolean
580
582
  ): { radius: number } | null {
581
- const PADDING = 10;
583
+ const PADDING = 30;
582
584
  let contentMinX = Infinity,
583
585
  contentMaxX = -Infinity;
584
586
  let contentMinY = Infinity,
@@ -672,20 +674,9 @@ function buildEdgeArc(
672
674
  ): { path: string; labelX: number; labelY: number; labelAngle: number } {
673
675
  const dir = isClockwise ? 1 : -1;
674
676
 
675
- // Find where the cycle circle exits the source node
676
- const startAngle = src.isCircle
677
- ? circleNodeExitAngle(src.width / 2, radius, src.angle, dir)
678
- : circleRectExitAngle(
679
- src.x,
680
- src.y,
681
- src.width / 2,
682
- src.height / 2,
683
- cx,
684
- cy,
685
- radius,
686
- src.angle,
687
- dir
688
- );
677
+ // Start arc from the source node's center angle — the node renders on top
678
+ // of the edge, so the overlap is hidden and there's no visible gap.
679
+ const startAngle = src.angle;
689
680
 
690
681
  // Find where the cycle circle exits the target node
691
682
  const nodeEndAngle = tgt.isCircle
@@ -23,13 +23,15 @@ import { renderInlineText } from '../utils/inline-markdown';
23
23
  import type { PaletteColors } from '../palettes';
24
24
  import type { D3ExportDimensions } from '../utils/d3-types';
25
25
  import type { CompactViewState } from '../sharing';
26
- import type { ParsedCycle } from './types';
26
+ import {
27
+ DEFAULT_EDGE_WIDTH,
28
+ MIN_EDGE_WIDTH,
29
+ arrowHeadLength,
30
+ type ParsedCycle,
31
+ } from './types';
27
32
  import { computeCycleLayout } from './layout';
28
33
 
29
34
  // ── Constants ────────────────────────────────────────────────
30
- const DEFAULT_EDGE_WIDTH = 3;
31
- const ARROWHEAD_W = 8;
32
- const ARROWHEAD_H = 8;
33
35
  const NODE_FONT_SIZE = 13;
34
36
  const DESC_FONT_SIZE = 11;
35
37
  const EDGE_LABEL_FONT_SIZE = 11;
@@ -183,22 +185,28 @@ export function renderCycle(
183
185
  // Resolve default node color: first palette color (uniform)
184
186
  const defaultNodeColor = palette.primary;
185
187
 
186
- // ── Arrowhead markers ──
187
- const arrowColors = new Set<string>();
188
- for (let i = 0; i < parsed.edges.length; i++) {
189
- const edge = parsed.edges[i];
188
+ // ── Arrowhead markers (per color+width, markerUnits=strokeWidth) ──
189
+ const markerKeys = new Set<string>();
190
+ for (const edge of parsed.edges) {
190
191
  const color = resolveEdgeColor(edge, parsed, palette, defaultNodeColor);
191
- arrowColors.add(color);
192
+ const sw = Math.max(edge.width ?? DEFAULT_EDGE_WIDTH, MIN_EDGE_WIDTH);
193
+ const key = `${color}|${sw}`;
194
+ if (!markerKeys.has(key)) {
195
+ markerKeys.add(key);
196
+ ensureArrowMarker(defs, color, sw);
197
+ }
192
198
  }
193
- ensureArrowMarkers(defs, arrowColors);
194
199
 
195
200
  // ── Render edges (below nodes) ──
196
201
  for (let i = 0; i < layout.edges.length; i++) {
197
202
  const le = layout.edges[i];
198
203
  const edge = parsed.edges[i];
199
204
  const color = resolveEdgeColor(edge, parsed, palette, defaultNodeColor);
200
- const strokeWidth = edge.width ?? DEFAULT_EDGE_WIDTH;
201
- const markerId = `cycle-arrow-${color.replace('#', '')}`;
205
+ const strokeWidth = Math.max(
206
+ edge.width ?? DEFAULT_EDGE_WIDTH,
207
+ MIN_EDGE_WIDTH
208
+ );
209
+ const markerId = arrowMarkerId(color, strokeWidth);
202
210
 
203
211
  const edgeG = g.append('g').attr('class', 'cycle-edge');
204
212
 
@@ -514,26 +522,45 @@ function resolveEdgeColor(
514
522
  return defaultNodeColor;
515
523
  }
516
524
 
517
- function ensureArrowMarkers(
525
+ /** Stable marker ID for a (color, strokeWidth) pair. */
526
+ function arrowMarkerId(color: string, strokeWidth: number): string {
527
+ return `cycle-arrow-${color.replace('#', '')}-w${strokeWidth}`;
528
+ }
529
+
530
+ /**
531
+ * Create an arrowhead marker using markerUnits="strokeWidth" (SVG default)
532
+ * with per-edge dimensions. The marker base automatically equals the stroke
533
+ * width — no gaps or lollipop effects. Marker dimensions are computed so
534
+ * the rendered arrowhead length follows a sublinear formula:
535
+ *
536
+ * rendered length = markerWidth × strokeWidth = arrowHeadLength(sw)
537
+ * → markerWidth = arrowHeadLength(sw) / sw
538
+ *
539
+ * The height is fixed at 1 strokeWidth unit so the base = stroke width.
540
+ */
541
+ function ensureArrowMarker(
518
542
  defs: d3Selection.Selection<SVGDefsElement, unknown, null, undefined>,
519
- colors: Set<string>
543
+ color: string,
544
+ strokeWidth: number
520
545
  ): void {
521
- for (const color of colors) {
522
- const id = `cycle-arrow-${color.replace('#', '')}`;
523
- defs
524
- .append('marker')
525
- .attr('id', id)
526
- .attr('viewBox', `0 0 ${ARROWHEAD_W * 2} ${ARROWHEAD_H * 2}`)
527
- .attr('refX', 0)
528
- .attr('refY', ARROWHEAD_H)
529
- .attr('markerWidth', ARROWHEAD_W)
530
- .attr('markerHeight', ARROWHEAD_H)
531
- .attr('orient', 'auto')
532
- .append('polygon')
533
- .attr(
534
- 'points',
535
- `0,0 ${ARROWHEAD_W * 2},${ARROWHEAD_H} 0,${ARROWHEAD_H * 2}`
536
- )
537
- .attr('fill', color);
538
- }
546
+ const id = arrowMarkerId(color, strokeWidth);
547
+ // Marker dimensions in strokeWidth units.
548
+ // Rendered size = mw × sw (length) and mh × sw (height).
549
+ const mw = arrowHeadLength(strokeWidth) / strokeWidth;
550
+ // Height proportional to length (½ ratio) but at least 1.5× stroke width
551
+ // so the arrowhead is always visibly wider than the stroke.
552
+ const mh = Math.max(1.5, mw * 0.5);
553
+
554
+ defs
555
+ .append('marker')
556
+ .attr('id', id)
557
+ .attr('viewBox', `0 0 ${mw} ${mh}`)
558
+ .attr('refX', mw * 0.1)
559
+ .attr('refY', mh / 2)
560
+ .attr('markerWidth', mw)
561
+ .attr('markerHeight', mh)
562
+ .attr('orient', 'auto')
563
+ .append('polygon')
564
+ .attr('points', `0,0 ${mw},${mh / 2} 0,${mh}`)
565
+ .attr('fill', color);
539
566
  }
@@ -64,6 +64,27 @@ export interface CycleLayoutEdge {
64
64
  label?: string;
65
65
  }
66
66
 
67
+ // ============================================================
68
+ // Shared arrow-sizing helpers (used by both layout + renderer)
69
+ // ============================================================
70
+
71
+ /** Default edge stroke width. */
72
+ export const DEFAULT_EDGE_WIDTH = 3;
73
+ /** Minimum rendered stroke width — thinner strokes produce unusable arrowheads. */
74
+ export const MIN_EDGE_WIDTH = 2;
75
+
76
+ /**
77
+ * Compute the desired arrowhead length in user-space pixels using sublinear
78
+ * scaling. The renderer uses markerUnits="strokeWidth" with computed marker
79
+ * dimensions so the arrowhead base always matches the stroke width (no gaps,
80
+ * no lollipop effect) while the rendered length follows this formula.
81
+ */
82
+ const BASE_ARROW_SIZE = 8;
83
+ const ARROW_SCALE = 6;
84
+ export function arrowHeadLength(strokeWidth: number): number {
85
+ return BASE_ARROW_SIZE + ARROW_SCALE * Math.sqrt(strokeWidth);
86
+ }
87
+
67
88
  export interface CycleLayoutResult {
68
89
  nodes: CycleLayoutNode[];
69
90
  edges: CycleLayoutEdge[];