@diagrammo/dgmo 0.6.1 → 0.6.2

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.
@@ -1,14 +1,16 @@
1
1
  # DGMO AI Integration Guide
2
2
 
3
- Use AI coding tools to generate `.dgmo` diagrams. This guide covers Claude Code, Copilot, Cursor, and other AI tools.
3
+ Use AI coding tools to generate `.dgmo` diagrams. This guide covers Claude Code, Copilot, Cursor, Windsurf, and any tool with an MCP client.
4
4
 
5
- ## MCP Server
5
+ ---
6
6
 
7
- `@diagrammo/dgmo-mcp` provides an MCP server that exposes DGMO rendering, sharing, and documentation tools. Works with Claude Desktop, Claude Code, and any MCP-compatible client.
7
+ ## MCP Server (recommended for Claude)
8
8
 
9
- 5 tools: `render_diagram`, `share_diagram`, `open_in_app`, `list_chart_types`, `get_language_reference`.
9
+ `@diagrammo/dgmo-mcp` provides an MCP server that gives Claude the ability to render, share, and look up DGMO diagrams directly — no file management needed.
10
10
 
11
- Setup (Claude Code add to `.claude/settings.local.json`):
11
+ **5 tools:** `render_diagram`, `share_diagram`, `open_in_app`, `list_chart_types`, `get_language_reference`
12
+
13
+ Add to `~/.claude/settings.json` (global) or `.claude/settings.local.json` (project):
12
14
 
13
15
  ```json
14
16
  {
@@ -23,60 +25,46 @@ Setup (Claude Code — add to `.claude/settings.local.json`):
23
25
 
24
26
  See `dgmo-mcp/README.md` for full configuration options.
25
27
 
26
- ## Claude Code — Skills
27
-
28
- Copy the `.claude/skills/dgmo-*` directories from this repo into your project's `.claude/skills/` directory. This gives you four slash commands:
28
+ ---
29
29
 
30
- | Command | What it does |
31
- |---------|-------------|
32
- | `/dgmo-generate <description>` | Picks the best diagram type automatically |
33
- | `/dgmo-sequence <flow>` | Generates a sequence diagram |
34
- | `/dgmo-flowchart <process>` | Generates a flowchart |
35
- | `/dgmo-chart <data description>` | Generates a data chart |
30
+ ## Claude Code Skill (slash command)
36
31
 
37
- ### Setup
32
+ Installs a `/dgmo` slash command that gives Claude full dgmo context — all chart types, CLI flags, workflow, and tips.
38
33
 
39
34
  ```bash
40
- # Copy skills into your project
41
- cp -r node_modules/@diagrammo/dgmo/.claude/skills/dgmo-* .claude/skills/
42
-
43
- # Or if dgmo is installed globally
44
- cp -r $(npm root -g)/@diagrammo/dgmo/.claude/skills/dgmo-* .claude/skills/
35
+ dgmo --install-claude-skill
45
36
  ```
46
37
 
47
- ### Usage examples
38
+ This copies a skill file into `~/.claude/commands/`, making `/dgmo` available in every Claude Code session.
48
39
 
49
- ```
50
- /dgmo-generate an ER diagram for a blog with users, posts, and comments
51
- /dgmo-sequence the OAuth2 authorization code flow
52
- /dgmo-flowchart CI/CD pipeline with build, test, and deploy stages
53
- /dgmo-chart quarterly revenue: Q1 100, Q2 120, Q3 110, Q4 130
54
- ```
40
+ ---
55
41
 
56
42
  ## Claude Code — CLAUDE.md snippet
57
43
 
58
- Add this to your project's `CLAUDE.md` to teach Claude about DGMO without installing skills:
44
+ To teach Claude about DGMO in a specific project without the global skill, add this to your `CLAUDE.md`:
59
45
 
60
46
  ```markdown
61
47
  ## DGMO Diagrams
62
48
 
63
- When the user asks for a diagram, generate a `.dgmo` file. DGMO is a text-based diagram language.
49
+ When the user asks for a diagram, generate a `.dgmo` file.
64
50
 
65
51
  Quick reference:
66
- - Sequence: `A -> B: message` or `A -message-> B`
52
+ - Sequence: `A -message-> B` or `A <-response- B`
67
53
  - Flowchart: `(Start) -> [Process] -> <Decision?> -yes-> (End)`
68
54
  - Bar chart: `chart: bar` then `Label: value` lines
69
55
  - ER diagram: `chart: er` then table definitions and `table1 1--* table2` relationships
70
56
  - Org chart: `chart: org` then indented hierarchy
71
57
 
72
- Full reference: see `node_modules/@diagrammo/dgmo/docs/language-reference.md`
58
+ Full reference: `node_modules/@diagrammo/dgmo/docs/language-reference.md`
73
59
 
74
- Render with: `dgmo file.dgmo -o output.svg` or `dgmo file.dgmo -o url` for shareable link.
60
+ Render with: `dgmo file.dgmo` (PNG) or `dgmo file.dgmo -o url` (shareable link).
75
61
  ```
76
62
 
63
+ ---
64
+
77
65
  ## Other AI Tools — Prompt Files
78
66
 
79
- DGMO ships prompt files for popular AI coding tools. These are included in the npm package:
67
+ DGMO ships context files for popular AI coding tools, included in the npm package and auto-loaded when present in a project root.
80
68
 
81
69
  | File | Tool | How it works |
82
70
  |------|------|-------------|
@@ -84,42 +72,37 @@ DGMO ships prompt files for popular AI coding tools. These are included in the n
84
72
  | `.cursorrules` | Cursor | Auto-loaded when present in project root |
85
73
  | `.windsurfrules` | Windsurf | Auto-loaded when present in project root |
86
74
 
87
- ### Setup
88
-
89
75
  Copy the relevant file into your project root:
90
76
 
91
77
  ```bash
92
- # From node_modules
78
+ # From node_modules (if installed as a dependency)
93
79
  cp node_modules/@diagrammo/dgmo/.cursorrules .
94
80
  cp node_modules/@diagrammo/dgmo/.windsurfrules .
95
81
  mkdir -p .github && cp node_modules/@diagrammo/dgmo/.github/copilot-instructions.md .github/
96
82
 
97
- # Or from global install
83
+ # From global npm install
98
84
  cp $(npm root -g)/@diagrammo/dgmo/.cursorrules .
99
85
  ```
100
86
 
101
- Each file contains a condensed DGMO syntax reference with examples for the most common diagram types, all 29 chart types listed, rendering commands, and common mistakes to avoid.
87
+ Each file contains a condensed DGMO syntax reference with examples, all chart types listed, rendering commands, and common mistakes to avoid.
102
88
 
103
- ## Rendering
89
+ ---
104
90
 
105
- If the `dgmo` CLI is installed, diagrams can be rendered:
91
+ ## Rendering commands
106
92
 
107
93
  ```bash
108
- # Install
109
- npm install -g @diagrammo/dgmo # or: brew install diagrammo/dgmo/dgmo
110
-
111
- # Render
112
94
  dgmo diagram.dgmo # PNG output
113
95
  dgmo diagram.dgmo -o output.svg # SVG output
114
- dgmo diagram.dgmo -o url # Shareable URL
115
-
116
- # AI-friendly JSON output
117
- dgmo diagram.dgmo -o output.svg --json
118
- dgmo --chart-types --json # List all chart types
96
+ dgmo diagram.dgmo -o url # Shareable diagrammo.app URL
97
+ dgmo diagram.dgmo -o url --copy # URL copied to clipboard
98
+ dgmo --chart-types # List all supported chart types
99
+ dgmo --chart-types --json # Machine-readable chart type list
119
100
  ```
120
101
 
102
+ ---
103
+
121
104
  ## Supported chart types
122
105
 
123
106
  Run `dgmo --chart-types` for the full list, or see `docs/language-reference.md`.
124
107
 
125
- 32 types: bar, line, area, pie, doughnut, radar, polar-area, bar-stacked, scatter, sankey, chord, function, heatmap, funnel, slope, wordcloud, arc, timeline, venn, quadrant, sequence, flowchart, state, class, er, org, kanban, c4, initiative-status, sitemap, infra.
108
+ 29 types: bar, line, area, multi-line, pie, doughnut, radar, polar-area, bar-stacked, scatter, sankey, chord, function, heatmap, funnel, slope, wordcloud, arc, timeline, venn, quadrant, sequence, flowchart, class, er, org, kanban, c4, initiative-status, infra.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  "dist",
27
27
  "src",
28
28
  "docs",
29
- ".claude/skills",
29
+ ".claude/commands",
30
30
  ".github/copilot-instructions.md",
31
31
  ".cursorrules",
32
32
  ".windsurfrules"
@@ -39,7 +39,8 @@
39
39
  "test": "vitest run",
40
40
  "test:watch": "vitest",
41
41
  "gallery": "pnpm build && node scripts/generate-gallery.mjs",
42
- "check:duplication": "jscpd ./src"
42
+ "check:duplication": "jscpd ./src",
43
+ "postinstall": "node -e \"console.log('\\n💡 Claude Code user? Run: dgmo --install-claude-skill\\n')\""
43
44
  },
44
45
  "dependencies": {
45
46
  "@dagrejs/dagre": "^2.0.4",
package/src/c4/layout.ts CHANGED
@@ -109,17 +109,31 @@ const LEGEND_CAPSULE_PAD = 4;
109
109
  // Post-Layout Crossing Reduction
110
110
  // ============================================================
111
111
 
112
+ interface NodeGeometry {
113
+ y: number;
114
+ width: number;
115
+ height: number;
116
+ }
117
+
118
+ // Large penalty per edge-node collision — dominates the distance term so the
119
+ // sifter strongly prefers orderings where edges don't pass through other nodes.
120
+ const EDGE_NODE_COLLISION_WEIGHT = 5000;
121
+
112
122
  /**
113
123
  * Compute penalty for an edge ordering. Uses degree-weighted edge distance:
114
124
  * long edges to high-degree nodes are penalized more than to low-degree nodes.
115
125
  * This places shared/important nodes closer to their neighbors, reducing
116
126
  * visual edge congestion.
117
127
  *
128
+ * When nodeGeometry is provided, also adds a heavy penalty for each case where
129
+ * a straight-line edge bounding box overlaps another node — driving the sifter
130
+ * to prefer orderings that avoid edge-node collisions.
118
131
  */
119
132
  function computeEdgePenalty(
120
133
  edgeList: { source: string; target: string }[],
121
134
  nodePositions: Map<string, number>,
122
- degrees: Map<string, number>
135
+ degrees: Map<string, number>,
136
+ nodeGeometry?: Map<string, NodeGeometry>
123
137
  ): number {
124
138
  let penalty = 0;
125
139
 
@@ -135,6 +149,48 @@ function computeEdgePenalty(
135
149
  penalty += dist * weight;
136
150
  }
137
151
 
152
+ // Edge-node collision penalty: for each edge A→B, check if any other node C
153
+ // has its bounding box inside the straight-line bounding box of the edge.
154
+ // Uses x from nodePositions (updated per permutation) and y/size from nodeGeometry.
155
+ if (nodeGeometry) {
156
+ for (const edge of edgeList) {
157
+ const geomA = nodeGeometry.get(edge.source);
158
+ const geomB = nodeGeometry.get(edge.target);
159
+ if (!geomA || !geomB) continue;
160
+
161
+ const ax = nodePositions.get(edge.source) ?? 0;
162
+ const bx = nodePositions.get(edge.target) ?? 0;
163
+ const ay = geomA.y;
164
+ const by = geomB.y;
165
+
166
+ // Skip edges within the same rank — they have no vertical span to check
167
+ if (ay === by) continue;
168
+
169
+ const edgeMinX = Math.min(ax, bx);
170
+ const edgeMaxX = Math.max(ax, bx);
171
+ const edgeMinY = Math.min(ay, by);
172
+ const edgeMaxY = Math.max(ay, by);
173
+
174
+ for (const [name, geomC] of nodeGeometry) {
175
+ if (name === edge.source || name === edge.target) continue;
176
+ const cx = nodePositions.get(name) ?? 0;
177
+ const cy = geomC.y;
178
+ const hw = geomC.width / 2;
179
+ const hh = geomC.height / 2;
180
+
181
+ // AABB overlap: node C's box intersects the edge's straight-line bounding box
182
+ if (
183
+ cx + hw > edgeMinX &&
184
+ cx - hw < edgeMaxX &&
185
+ cy + hh > edgeMinY &&
186
+ cy - hh < edgeMaxY
187
+ ) {
188
+ penalty += EDGE_NODE_COLLISION_WEIGHT;
189
+ }
190
+ }
191
+ }
192
+ }
193
+
138
194
  return penalty;
139
195
  }
140
196
 
@@ -160,6 +216,13 @@ function reduceCrossings(
160
216
  degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + 1);
161
217
  }
162
218
 
219
+ // Build geometry map for edge-node collision scoring
220
+ const nodeGeometry = new Map<string, NodeGeometry>();
221
+ for (const name of g.nodes()) {
222
+ const pos = g.node(name);
223
+ if (pos) nodeGeometry.set(name, { y: pos.y, width: pos.width, height: pos.height });
224
+ }
225
+
163
226
  // Group nodes by rank
164
227
  const rankMap = new Map<number, string[]>();
165
228
  for (const name of g.nodes()) {
@@ -216,7 +279,7 @@ function reduceCrossings(
216
279
  }
217
280
 
218
281
  // Current penalty
219
- const currentPenalty = computeEdgePenalty(edgeList, basePositions, degrees);
282
+ const currentPenalty = computeEdgePenalty(edgeList, basePositions, degrees, nodeGeometry);
220
283
 
221
284
  // Try permutations (feasible for partition sizes ≤ 8)
222
285
  let bestPerm = [...partition];
@@ -229,7 +292,7 @@ function reduceCrossings(
229
292
  for (let i = 0; i < perm.length; i++) {
230
293
  testPositions.set(perm[i]!, xSlots[i]!);
231
294
  }
232
- const penalty = computeEdgePenalty(edgeList, testPositions, degrees);
295
+ const penalty = computeEdgePenalty(edgeList, testPositions, degrees, nodeGeometry);
233
296
  if (penalty < bestPenalty) {
234
297
  bestPenalty = penalty;
235
298
  bestPerm = [...perm];
@@ -248,14 +311,14 @@ function reduceCrossings(
248
311
  for (let k = 0; k < workingOrder.length; k++) {
249
312
  testPositions.set(workingOrder[k]!, xSlots[k]!);
250
313
  }
251
- const before = computeEdgePenalty(edgeList, testPositions, degrees);
314
+ const before = computeEdgePenalty(edgeList, testPositions, degrees, nodeGeometry);
252
315
 
253
316
  [workingOrder[i], workingOrder[i + 1]] = [workingOrder[i + 1]!, workingOrder[i]!];
254
317
  const testPositions2 = new Map(basePositions);
255
318
  for (let k = 0; k < workingOrder.length; k++) {
256
319
  testPositions2.set(workingOrder[k]!, xSlots[k]!);
257
320
  }
258
- const after = computeEdgePenalty(edgeList, testPositions2, degrees);
321
+ const after = computeEdgePenalty(edgeList, testPositions2, degrees, nodeGeometry);
259
322
 
260
323
  if (after < before) {
261
324
  improved = true;
package/src/cli.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { execSync } from 'node:child_process';
3
- import { resolve, basename, extname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { resolve, join, basename, extname } from 'node:path';
5
+ import { createInterface } from 'node:readline';
4
6
  import { Resvg } from '@resvg/resvg-js';
5
7
  import { render } from './render';
6
8
  import { parseDgmo, getAllChartTypes } from './dgmo-router';
@@ -57,6 +59,84 @@ const CHART_TYPE_DESCRIPTIONS: Record<string, string> = {
57
59
  infra: 'Infra chart — infrastructure traffic flow with rps computation',
58
60
  };
59
61
 
62
+ const CLAUDE_SKILL_CONTENT = `# dgmo — Diagrammo Diagram Assistant
63
+
64
+ You are helping the user author, render, and share diagrams using the \`dgmo\` CLI and \`.dgmo\` file format.
65
+
66
+ ## What is dgmo?
67
+
68
+ \`dgmo\` is a CLI tool that renders \`.dgmo\` diagram files to PNG, SVG, or shareable URLs. Diagrams are written in a plain-text DSL.
69
+
70
+ ## CLI Reference
71
+
72
+ \`\`\`
73
+ dgmo <input.dgmo> [options]
74
+ cat input.dgmo | dgmo [options]
75
+ \`\`\`
76
+
77
+ Key options:
78
+ - \`-o <file>\` — output file; format inferred from extension (\`.svg\` → SVG, else PNG)
79
+ - \`-o url\` — output a shareable diagrammo.app URL
80
+ - \`--theme <theme>\` — \`light\` (default), \`dark\`, \`transparent\`
81
+ - \`--palette <name>\` — \`nord\` (default), \`solarized\`, \`catppuccin\`, \`rose-pine\`, \`gruvbox\`, \`tokyo-night\`, \`one-dark\`, \`bold\`
82
+ - \`--copy\` — copy the URL to clipboard (use with \`-o url\`)
83
+ - \`--no-branding\` — omit diagrammo.app branding from exports
84
+ - \`--chart-types\` — list all supported chart types
85
+
86
+ ## Supported Chart Types
87
+
88
+ | Type | Use case |
89
+ |------|----------|
90
+ | \`bar\` | Categorical comparisons |
91
+ | \`line\` / \`multi-line\` / \`area\` | Trends over time |
92
+ | \`pie\` / \`doughnut\` | Part-to-whole |
93
+ | \`radar\` / \`polar-area\` | Multi-dimensional metrics |
94
+ | \`bar-stacked\` | Multi-series categorical |
95
+ | \`scatter\` | 2D data points or bubble chart |
96
+ | \`sankey\` | Flow / allocation |
97
+ | \`chord\` | Circular flow relationships |
98
+ | \`function\` | Mathematical expressions |
99
+ | \`heatmap\` | Matrix intensity |
100
+ | \`funnel\` | Conversion pipeline |
101
+ | \`slope\` | Change between two periods |
102
+ | \`wordcloud\` | Term frequency |
103
+ | \`arc\` | Network relationships |
104
+ | \`timeline\` | Events, eras, date ranges |
105
+ | \`venn\` | Set overlaps |
106
+ | \`quadrant\` | 2x2 positioning matrix |
107
+ | \`sequence\` | Message / interaction flows |
108
+ | \`flowchart\` | Decision trees, process flows |
109
+ | \`class\` | UML class hierarchies |
110
+ | \`er\` | Database schemas |
111
+ | \`org\` | Hierarchical tree structures |
112
+ | \`kanban\` | Task / workflow columns |
113
+ | \`c4\` | System architecture (context → container → component → deployment) |
114
+ | \`initiative-status\` | Project roadmap with dependency tracking |
115
+
116
+ ## Your Workflow
117
+
118
+ When the user asks you to create or edit a diagram:
119
+
120
+ 1. **Write or edit the \`.dgmo\` file** with the appropriate chart type and data.
121
+ 2. **Render it** with \`dgmo <file>.dgmo -o <file>.png\` to verify it produces output without errors.
122
+ 3. **Show the user** what was created and suggest a shareable URL with \`dgmo <file>.dgmo -o url --copy\` if they want to share it.
123
+
124
+ When the user asks for a **shareable link**, run:
125
+ \`\`\`
126
+ dgmo <file>.dgmo -o url --copy
127
+ \`\`\`
128
+
129
+ ## Getting Syntax Help
130
+
131
+ Run \`dgmo --chart-types\` to list types. For detailed syntax of a specific chart type, the best reference is the diagrammo.app documentation or existing \`.dgmo\` files in the project.
132
+
133
+ ## Tips
134
+
135
+ - Default theme is \`light\` and default palette is \`nord\` — ask the user if they have a preference before rendering a final export.
136
+ - For C4 diagrams, use \`--c4-level\` to drill from context → containers → components → deployment.
137
+ - Stdin mode is useful for quick one-off renders: \`echo "..." | dgmo -o out.png\`
138
+ `;
139
+
60
140
  function printHelp(): void {
61
141
  console.log(`Usage: dgmo <input> [options]
62
142
  cat input.dgmo | dgmo [options]
@@ -78,6 +158,7 @@ Options:
78
158
  --copy Copy URL to clipboard (only with -o url)
79
159
  --json Output structured JSON to stdout
80
160
  --chart-types List all supported chart types
161
+ --install-claude-skill Install the dgmo Claude Code skill to ~/.claude/commands/
81
162
  --help Show this help
82
163
  --version Show version`);
83
164
  }
@@ -100,6 +181,7 @@ function parseArgs(argv: string[]): {
100
181
  copy: boolean;
101
182
  json: boolean;
102
183
  chartTypes: boolean;
184
+ installClaudeSkill: boolean;
103
185
  c4Level: 'context' | 'containers' | 'components' | 'deployment';
104
186
  c4System: string | undefined;
105
187
  c4Container: string | undefined;
@@ -116,6 +198,7 @@ function parseArgs(argv: string[]): {
116
198
  copy: false,
117
199
  json: false,
118
200
  chartTypes: false,
201
+ installClaudeSkill: false,
119
202
  c4Level: 'context' as 'context' | 'containers' | 'components' | 'deployment',
120
203
  c4System: undefined as string | undefined,
121
204
  c4Container: undefined as string | undefined,
@@ -185,6 +268,9 @@ function parseArgs(argv: string[]): {
185
268
  } else if (arg === '--chart-types') {
186
269
  result.chartTypes = true;
187
270
  i++;
271
+ } else if (arg === '--install-claude-skill') {
272
+ result.installClaudeSkill = true;
273
+ i++;
188
274
  } else if (arg === '--copy') {
189
275
  result.copy = true;
190
276
  i++;
@@ -288,6 +374,42 @@ async function main(): Promise<void> {
288
374
  return;
289
375
  }
290
376
 
377
+ if (opts.installClaudeSkill) {
378
+ const claudeDir = join(homedir(), '.claude');
379
+ if (!existsSync(claudeDir)) {
380
+ console.error('~/.claude directory not found.');
381
+ console.error('Install Claude Code first: https://claude.ai/code');
382
+ process.exit(1);
383
+ }
384
+ const commandsDir = join(claudeDir, 'commands');
385
+ const destPath = join(commandsDir, 'dgmo.md');
386
+ const alreadyExists = existsSync(destPath);
387
+ const prompt = alreadyExists
388
+ ? `~/.claude/commands/dgmo.md already exists. Overwrite? [y/N] `
389
+ : `Install dgmo Claude Code skill to ~/.claude/commands/dgmo.md? [Y/n] `;
390
+ await new Promise<void>((done) => {
391
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
392
+ rl.question(prompt, (answer) => {
393
+ rl.close();
394
+ const yes = alreadyExists
395
+ ? answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'
396
+ : answer === '' || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
397
+ if (!yes) {
398
+ console.error('Aborted.');
399
+ process.exit(0);
400
+ }
401
+ done();
402
+ });
403
+ });
404
+ if (!existsSync(commandsDir)) {
405
+ mkdirSync(commandsDir, { recursive: true });
406
+ }
407
+ writeFileSync(destPath, CLAUDE_SKILL_CONTENT, 'utf-8');
408
+ console.log(`Installed: ${destPath}`);
409
+ console.log('Use /dgmo in Claude Code to activate the skill.');
410
+ return;
411
+ }
412
+
291
413
  // Determine input source
292
414
  let content: string;
293
415
  let inputBasename: string | undefined;
@@ -0,0 +1,206 @@
1
+ // ============================================================
2
+ // ER Diagram Entity Classification
3
+ // ============================================================
4
+ //
5
+ // Classifies each ER entity into a structural role using heuristics
6
+ // derived from column constraints, FK patterns, relationship in-degree,
7
+ // and naming conventions.
8
+ //
9
+ // Only used when no explicit colors or tag groups are defined.
10
+
11
+ import type { ERTable, ERRelationship } from './types';
12
+ import type { PaletteColors } from '../palettes/types';
13
+
14
+ export type EntityRole =
15
+ | 'core'
16
+ | 'dependent'
17
+ | 'junction'
18
+ | 'ambiguous'
19
+ | 'lookup'
20
+ | 'hub'
21
+ | 'self-referential'
22
+ | 'unclassified';
23
+
24
+ /** Maps each role to a key in PaletteColors['colors'] */
25
+ export const ROLE_COLORS: Record<EntityRole, keyof PaletteColors['colors']> = {
26
+ core: 'green',
27
+ dependent: 'blue',
28
+ junction: 'red',
29
+ ambiguous: 'purple',
30
+ lookup: 'yellow',
31
+ hub: 'orange',
32
+ 'self-referential': 'teal',
33
+ unclassified: 'gray',
34
+ };
35
+
36
+ /** Human-readable legend labels per role */
37
+ export const ROLE_LABELS: Record<EntityRole, string> = {
38
+ core: 'Core entity',
39
+ dependent: 'Dependent',
40
+ junction: 'Junction / M:M',
41
+ ambiguous: 'Bridge',
42
+ lookup: 'Lookup / Reference',
43
+ hub: 'Hub',
44
+ 'self-referential': 'Self-referential',
45
+ unclassified: 'Unclassified',
46
+ };
47
+
48
+ /** Stable display order for the semantic legend */
49
+ export const ROLE_ORDER: EntityRole[] = [
50
+ 'core',
51
+ 'dependent',
52
+ 'junction',
53
+ 'ambiguous',
54
+ 'lookup',
55
+ 'hub',
56
+ 'self-referential',
57
+ 'unclassified',
58
+ ];
59
+
60
+ const LOOKUP_NAME_SUFFIXES = ['_type', '_status', '_code', '_category'];
61
+
62
+ /**
63
+ * Classify each ER entity into a structural role.
64
+ *
65
+ * Returns a Map of tableId → EntityRole.
66
+ * Pure logic — no palette or DOM dependencies.
67
+ */
68
+ export function classifyEREntities(
69
+ tables: ERTable[],
70
+ relationships: ERRelationship[]
71
+ ): Map<string, EntityRole> {
72
+ const result = new Map<string, EntityRole>();
73
+ if (tables.length === 0) return result;
74
+
75
+ // ── Pre-compute graph metrics ─────────────────────────────────────────────
76
+
77
+ // indegreeMap: how many other tables have FK references pointing to this table.
78
+ // A table's indegree increments when it is on the exclusive '1' side of a 1:* or 1:?
79
+ // relationship. 1:1 relationships are skipped on both sides to avoid double-counting.
80
+ const indegreeMap: Record<string, number> = {};
81
+ for (const t of tables) indegreeMap[t.id] = 0;
82
+ for (const rel of relationships) {
83
+ if (rel.source === rel.target) continue; // skip self-loops
84
+ if (rel.cardinality.from === '1' && rel.cardinality.to !== '1') {
85
+ indegreeMap[rel.source] = (indegreeMap[rel.source] ?? 0) + 1;
86
+ }
87
+ if (rel.cardinality.to === '1' && rel.cardinality.from !== '1') {
88
+ indegreeMap[rel.target] = (indegreeMap[rel.target] ?? 0) + 1;
89
+ }
90
+ }
91
+
92
+ // mmParticipants: tables with '*' cardinality connecting to 2+ distinct other tables.
93
+ // Catches wide junction tables (e.g. with surrogate PKs) that the FK-ratio alone misses.
94
+ const tableStarNeighbors = new Map<string, Set<string>>();
95
+ for (const rel of relationships) {
96
+ if (rel.source === rel.target) continue;
97
+ if (rel.cardinality.from === '*') {
98
+ if (!tableStarNeighbors.has(rel.source)) tableStarNeighbors.set(rel.source, new Set());
99
+ tableStarNeighbors.get(rel.source)!.add(rel.target);
100
+ }
101
+ if (rel.cardinality.to === '*') {
102
+ if (!tableStarNeighbors.has(rel.target)) tableStarNeighbors.set(rel.target, new Set());
103
+ tableStarNeighbors.get(rel.target)!.add(rel.source);
104
+ }
105
+ }
106
+ const mmParticipants = new Set<string>();
107
+ for (const [id, neighbors] of tableStarNeighbors) {
108
+ if (neighbors.size >= 2) mmParticipants.add(id);
109
+ }
110
+
111
+ // Hub outlier statistics
112
+ const indegreeValues = Object.values(indegreeMap);
113
+ const mean = indegreeValues.reduce((a, b) => a + b, 0) / indegreeValues.length;
114
+ const variance = indegreeValues.reduce((a, b) => a + (b - mean) ** 2, 0) / indegreeValues.length;
115
+ const stddev = Math.sqrt(variance);
116
+
117
+ // Median indegree (for lookup detection: table must be referenced above-median)
118
+ const sorted = [...indegreeValues].sort((a, b) => a - b);
119
+ const median =
120
+ sorted.length % 2 === 0
121
+ ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
122
+ : sorted[Math.floor(sorted.length / 2)];
123
+
124
+ // ── Per-table classification ──────────────────────────────────────────────
125
+
126
+ for (const table of tables) {
127
+ const id = table.id;
128
+ const cols = table.columns;
129
+ const fkCols = cols.filter((c) => c.constraints.includes('fk'));
130
+ const pkFkCols = cols.filter(
131
+ (c) => c.constraints.includes('pk') && c.constraints.includes('fk')
132
+ );
133
+ const fkCount = fkCols.length;
134
+ const fkRatio = cols.length === 0 ? 0 : fkCount / cols.length;
135
+ const indegree = indegreeMap[id] ?? 0;
136
+ const nameLower = table.name.toLowerCase();
137
+
138
+ // External relationships (exclude self-loops)
139
+ const externalRels = relationships.filter(
140
+ (r) => (r.source === id || r.target === id) && r.source !== r.target
141
+ );
142
+ const hasSelfRef = relationships.some((r) => r.source === id && r.target === id);
143
+
144
+ // Distinct external tables this table relates to
145
+ const externalTargets = new Set<string>();
146
+ for (const rel of externalRels) {
147
+ externalTargets.add(rel.source === id ? rel.target : rel.source);
148
+ }
149
+
150
+ // ── Decision tree (priority order) ─────────────────────────────────────
151
+
152
+ // 1. Self-referential: has a self-loop relationship AND no external relationships
153
+ if (hasSelfRef && externalRels.length === 0) {
154
+ result.set(id, 'self-referential');
155
+ continue;
156
+ }
157
+
158
+ // 2. Junction: any one of three signals qualifies.
159
+ // Inheritance exception: composite PK FKs all pointing to a single parent → skip ratio signal.
160
+ const isInheritancePattern = pkFkCols.length >= 2 && externalTargets.size === 1;
161
+ const junctionByRatio = fkRatio >= 0.6 && !isInheritancePattern;
162
+ const junctionByCompositePk = pkFkCols.length >= 2 && externalTargets.size >= 2;
163
+ const junctionByMm = mmParticipants.has(id);
164
+ if (junctionByRatio || junctionByCompositePk || junctionByMm) {
165
+ result.set(id, 'junction');
166
+ continue;
167
+ }
168
+
169
+ // 3. Ambiguous: FK ratio 0.4–0.59, no composite PK FKs, not M:M participant
170
+ if (fkRatio >= 0.4 && fkRatio < 0.6 && pkFkCols.length < 2 && !mmParticipants.has(id)) {
171
+ result.set(id, 'ambiguous');
172
+ continue;
173
+ }
174
+
175
+ // 4. Lookup: naming convention match + structural guards
176
+ // Naming only applies when cols ≤ 6 AND fkCount ≤ 1; structure overrides for larger tables.
177
+ const nameMatchesLookup = LOOKUP_NAME_SUFFIXES.some((s) => nameLower.endsWith(s));
178
+ if (nameMatchesLookup && cols.length <= 6 && fkCount <= 1 && indegree > median) {
179
+ result.set(id, 'lookup');
180
+ continue;
181
+ }
182
+
183
+ // 5. Hub: in-degree outlier in a schema of ≥ 6 tables.
184
+ // Dual condition: > mean + 1.5σ AND ≥ 2× mean (guards against near-zero mean edge cases).
185
+ if (
186
+ tables.length >= 6 &&
187
+ indegree > 0 &&
188
+ indegree > mean + 1.5 * stddev &&
189
+ indegree >= 2 * mean
190
+ ) {
191
+ result.set(id, 'hub');
192
+ continue;
193
+ }
194
+
195
+ // 6. Dependent: has FK columns
196
+ if (fkCount > 0) {
197
+ result.set(id, 'dependent');
198
+ continue;
199
+ }
200
+
201
+ // 7. Core: no FK columns (always true at this point)
202
+ result.set(id, 'core');
203
+ }
204
+
205
+ return result;
206
+ }