rails-schema 0.1.2 → 0.1.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97d15de972c2e7c9c3d9e395d0b610b95806e7f6ef5729eed214f5cb7723bb61
4
- data.tar.gz: 7edc0d7d8cd246ad80cc2dd54936a0c9802b662476400f095d076bed651d6472
3
+ metadata.gz: 71fa755a65ac8dcdb27f419857dd5ede16a13d4c26e5b9a40bc7e8ec1f41d36b
4
+ data.tar.gz: 4940bf9f24e51d6caf3e5e70526f7a2ed8d097c352e99920e6191212c1d6137e
5
5
  SHA512:
6
- metadata.gz: 3ba7bf7dd2ac15538fa4a9f14b2772c4065fd6dc51a5325e1863e4d4e83838cdaa14497f5b4a4f6d7ffcdc014fe03ba9e1f3464e722ef57f73a56d14314997f0
7
- data.tar.gz: f41572f08a43be003061f2e47914ed36ed27b2718b10d0d3e238c9ca436ea3230ca7e61e5213acdbf50cab6f19b96506d5d1c8d0de33bc2831e2ab84181adac0
6
+ metadata.gz: 8004dd0d9b8a82008ec7e4b53c0ec444067ed1ba477e1bafe59dcd18810149bdc713e1c5f1009ae3bcd456a6cfcd46e62be112cd3f1ae4d0a44ccd2929f644f2
7
+ data.tar.gz: 4269f97b5b87daf25cfd24ed9138b4ed363e3a9ea4c7f70695abc53517216cc04d299567c1c48a80b6f2fae9b99b602434574110c3cd2afc1ccdea0e4c5a6bf0
data/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.1.4] - 2026-03-08
8
+
9
+ ### Added
10
+
11
+ - Support for Ruby 2.7+ and Rails 5.2+ (improved compatibility with older versions)
12
+
13
+ ### Changed
14
+
15
+ - Self-referential-only models (all edges point to themselves) are now placed in a vertical column to the left of the main graph instead of floating in the force simulation
16
+ - True orphan models (zero edges) continue to appear in rows above the diagram
17
+ - Improved class names and table names visibility
18
+
19
+ ## [0.1.3] - 2026-03-01
20
+
21
+ ### Added
22
+
23
+ - Mongoid support: visualize MongoDB-backed models without a schema file (`schema_format: :mongoid`)
24
+ - Auto-detection of Mongoid when `schema_format: :auto` and `Mongoid::Document` is defined
25
+ - Mongoid extractors: `ModelScanner`, `ModelAdapter`, `ColumnReader`, `AssociationReader`
26
+ - Support for all Mongoid association types: `has_many`, `has_one`, `belongs_to`, `has_and_belongs_to_many`, `embeds_many`, `embeds_one`, `embedded_in`
27
+ - Embedded document styling in the frontend (dashed borders for embed associations)
28
+ - Engine model eager-loading for Mongoid apps
29
+
7
30
  ## [0.1.2] - 2026-02-22
8
31
 
9
32
  ### Added
data/CLAUDE.md ADDED
@@ -0,0 +1,107 @@
1
+ # CLAUDE.md — Project Instructions for Claude Code
2
+
3
+ ## Project
4
+
5
+ `rails-schema` — a Ruby gem that generates an interactive HTML entity-relationship diagram from a Rails app's models, associations, and columns. Single self-contained HTML output, no server needed.
6
+
7
+ ## Quick Commands
8
+
9
+ ```bash
10
+ bundle exec rspec # Run all tests
11
+ bundle exec rubocop # Run linter
12
+ bundle exec rspec spec/rails/schema/extractor/mongoid/ # Mongoid tests only
13
+ ```
14
+
15
+ ## Architecture
16
+
17
+ Three-layer pipeline: **Extractor → Transformer → Renderer**
18
+
19
+ | Layer | Responsibility | Key Classes |
20
+ |---|---|---|
21
+ | **Extractor** | Introspects Rails environment; collects models, columns, associations | `ModelScanner`, `ColumnReader`, `AssociationReader`, `SchemaFileParser`, `StructureSqlParser`, plus `Mongoid::ModelScanner`, `Mongoid::ModelAdapter`, `Mongoid::ColumnReader`, `Mongoid::AssociationReader` |
22
+ | **Transformer** | Normalizes extracted data into a serializable graph (nodes + edges + metadata) | `GraphBuilder`, `Node`, `Edge` |
23
+ | **Renderer** | Injects graph data into an HTML/JS/CSS template via ERB | `HtmlGenerator` |
24
+ | **Railtie** | Provides the `rails_schema:generate` rake task | `Railtie` |
25
+
26
+ ### Key Paths
27
+
28
+ - `lib/rails/schema.rb` — entry point, `generate` dispatches to ActiveRecord or Mongoid pipeline
29
+ - `lib/rails/schema/extractor/` — model discovery, column/association reading, schema file parsing
30
+ - `lib/rails/schema/extractor/mongoid/` — Mongoid-specific extractors
31
+ - `lib/rails/schema/transformer/` — builds normalized graph JSON
32
+ - `lib/rails/schema/renderer/` — ERB-based HTML generation with inlined JS/CSS/data
33
+ - `lib/rails/schema/assets/` — frontend (vanilla JS + d3-force, CSS, HTML template)
34
+
35
+ ### Generation Pipeline
36
+
37
+ ```ruby
38
+ def generate(output: nil)
39
+ schema_data = parse_schema
40
+ models = Extractor::ModelScanner.new(schema_data: schema_data).scan
41
+ column_reader = Extractor::ColumnReader.new(schema_data: schema_data)
42
+ graph_data = Transformer::GraphBuilder.new(column_reader: column_reader).build(models)
43
+ Renderer::HtmlGenerator.new(graph_data: graph_data).render_to_file(output)
44
+ end
45
+ ```
46
+
47
+ ### Data Extraction Strategy
48
+
49
+ 1. **`db/schema.rb`** — `SchemaFileParser` parses with regex (table names, columns, types, nullable, defaults, PKs)
50
+ 2. **`db/structure.sql`** — `StructureSqlParser` parses SQL `CREATE TABLE` statements, maps SQL types to Rails types
51
+ 3. **ActiveRecord reflection API** — `AssociationReader` uses `reflect_on_all_associations` for associations
52
+ 4. **`Model.columns`** — `ColumnReader` falls back to this when table not found in schema_data
53
+
54
+ ### Model Discovery
55
+
56
+ `ModelScanner` (ActiveRecord):
57
+ 1. Calls `Rails.application.eager_load!` (Zeitwerk support, multiple fallback strategies including `LoadError` rescue)
58
+ 2. Collects `ActiveRecord::Base.descendants`
59
+ 3. Filters: abstract classes, anonymous classes, models without known tables
60
+ 4. Applies `exclude_models` config (supports wildcard prefix like `"ActiveStorage::*"`)
61
+
62
+ `Mongoid::ModelScanner`:
63
+ 1. Eager-loads via Zeitwerk or file glob with fallbacks
64
+ 2. Scans `ObjectSpace` for `Mongoid::Document` includers
65
+ 3. Also loads models from mounted Rails engines
66
+ 4. Returns models wrapped in `ModelAdapter` for GraphBuilder compatibility
67
+
68
+ ## Key Conventions
69
+
70
+ - Ruby >= 2.7, Rails >= 5.2
71
+ - Double quotes for strings (RuboCop enforced)
72
+ - RuboCop max method length: 15 lines, default ABC/complexity limits
73
+ - No `Style/Documentation` required
74
+ - `spec/support/test_models.rb` — ActiveRecord test models (User, Post, Comment, Tag)
75
+ - `spec/support/mongoid_test_models.rb` — Mongoid test models (MongoidUser, MongoidPost, MongoidComment)
76
+ - Tests use in-memory SQLite for ActiveRecord, stubbed Mongoid::Document for Mongoid
77
+ - `config.before(:each) { Rails::Schema.reset_configuration! }` in spec_helper
78
+
79
+ ## Schema Formats
80
+
81
+ `config.schema_format` supports: `:auto`, `:ruby`, `:sql`, `:mongoid`
82
+
83
+ - `:auto` — tries schema.rb, falls back to structure.sql; auto-detects Mongoid if `Mongoid::Document` is defined
84
+ - `:mongoid` — runtime introspection of Mongoid models (no schema file needed)
85
+
86
+ ## Testing Notes
87
+
88
+ - Always run `bundle exec rubocop` before committing — CI checks both tests and linting
89
+ - CI matrix: Ruby 2.7, 3.0, 3.1, 3.2, 3.3, 3.4
90
+ - Mongoid specs stub `Rails::Engine` and `Rails::Application` since they may not exist in test env
91
+ - SimpleCov is enabled; coverage report goes to `coverage/`
92
+
93
+ ## Frontend
94
+
95
+ Single self-contained HTML file — no CDN, no network requests. D3 is vendored/minified.
96
+
97
+ - **Vanilla JS + d3-force** for graph rendering
98
+ - **CSS custom properties** for dark/light theming
99
+ - Features: searchable sidebar, click-to-focus, detail panel, zoom/pan, keyboard shortcuts (`/` search, `Esc` deselect, `+/-` zoom, `F` fit)
100
+
101
+ ## Design Decisions
102
+
103
+ - **Single HTML file** — zero deployment friction, offline-first, portable (CI, GitHub Pages, email)
104
+ - **Not a mounted engine** — static file works without a running server
105
+ - **Parse schema files** — works without DB connection (CI environments, no local DB)
106
+ - **Force-directed layout** — handles unknown schemas gracefully without pre-defined positions
107
+ - **Node layout categories** — three-way partition in `app.js`: connected nodes use force simulation, self-ref-only models (all edges point to themselves) are placed in a vertical left column via `layoutSelfRefNodes()`, true orphans (zero edges) are placed in rows above the diagram via `layoutOrphans()`
data/README.md CHANGED
@@ -4,6 +4,13 @@ Interactive HTML visualization of your Rails database schema. Introspects your a
4
4
 
5
5
  No external server, no CDN — just one command and a browser.
6
6
 
7
+ ## Compatibility
8
+
9
+ | Dependency | Required version |
10
+ |-----------|-----------------|
11
+ | Ruby | >= 2.7 |
12
+ | Rails | >= 5.2 |
13
+
7
14
  **[Live example](https://andrew2net.github.io/rails-schema/)** — generated from [Fizzy](https://www.fizzy.do), a modern spin on kanban for tracking just about anything, created by [37signals](https://37signals.com).
8
15
 
9
16
  ![Rails Schema screenshot](docs/screenshot.png)
@@ -48,7 +55,7 @@ Rails::Schema.configure do |config|
48
55
  config.title = "My App Schema"
49
56
  config.theme = :auto # :auto, :light, or :dark
50
57
  config.expand_columns = false # start with columns collapsed
51
- config.schema_format = :auto # :auto, :ruby, or :sql
58
+ config.schema_format = :auto # :auto, :ruby, :sql, or :mongoid
52
59
  config.exclude_models = [
53
60
  "ActiveStorage::Blob",
54
61
  "ActiveStorage::Attachment",
@@ -63,7 +70,7 @@ end
63
70
  | `title` | `"Database Schema"` | Title shown in the HTML page |
64
71
  | `theme` | `:auto` | Color theme — `:auto`, `:light`, or `:dark` |
65
72
  | `expand_columns` | `false` | Whether model nodes start with columns expanded |
66
- | `schema_format` | `:auto` | Schema source — `:auto`, `:ruby`, or `:sql` (see below) |
73
+ | `schema_format` | `:auto` | Schema source — `:auto`, `:ruby`, `:sql`, or `:mongoid` (see below) |
67
74
  | `exclude_models` | `[]` | Models to hide; supports exact names and wildcard prefixes (`"ActionMailbox::*"`) |
68
75
 
69
76
  ### Schema format
@@ -72,18 +79,35 @@ Rails projects can use either `db/schema.rb` (Ruby DSL) or `db/structure.sql` (r
72
79
 
73
80
  | Value | Behavior |
74
81
  |-------|----------|
75
- | `:auto` | Tries `db/schema.rb` first, falls back to `db/structure.sql` |
82
+ | `:auto` | Tries `db/schema.rb` first, falls back to `db/structure.sql`. If Mongoid is detected, uses the Mongoid pipeline instead |
76
83
  | `:ruby` | Only reads `db/schema.rb` |
77
84
  | `:sql` | Only reads `db/structure.sql` |
85
+ | `:mongoid` | Introspects Mongoid models directly (see below) |
86
+
87
+ ### Mongoid support
88
+
89
+ If your app uses [Mongoid](https://www.mongodb.com/docs/mongoid/current/) instead of ActiveRecord, rails-schema can introspect your Mongoid models directly — no schema file needed.
90
+
91
+ ```ruby
92
+ Rails::Schema.configure do |config|
93
+ config.schema_format = :mongoid
94
+ end
95
+ ```
96
+
97
+ When set to `:auto`, Mongoid mode activates automatically if `Mongoid::Document` is defined.
98
+
99
+ The Mongoid pipeline reads fields, types, defaults, and presence validations from your models, and discovers all association types including `embeds_many`, `embeds_one`, `embedded_in`, and `has_and_belongs_to_many`.
78
100
 
79
101
  ## How it works
80
102
 
81
103
  The gem parses your `db/schema.rb` or `db/structure.sql` file to extract table and column information — **no database connection required**. It also introspects loaded ActiveRecord models for association metadata. This means the gem works even if you don't have a local database set up, as long as a schema file is present (which is standard in Rails projects under version control).
82
104
 
105
+ For Mongoid apps, the gem introspects model classes at runtime to read field definitions, associations, and validations — no schema file or database connection required.
106
+
83
107
  ## Features
84
108
 
85
- - **No database required** — reads from `db/schema.rb` or `db/structure.sql`
86
- - **Force-directed layout** — models cluster naturally by association density
109
+ - **No database required** — reads from `db/schema.rb`, `db/structure.sql`, or Mongoid model introspection
110
+ - **Force-directed layout** — models cluster naturally by association density; self-referential-only models are placed in a left column, true orphans in rows above
87
111
  - **Searchable sidebar** — filter models by name or table
88
112
  - **Click-to-focus** — click a model to highlight its neighborhood, fading unrelated models
89
113
  - **Detail panel** — full column list and associations for the selected model
@@ -6,6 +6,15 @@
6
6
  var nodes = data.nodes;
7
7
  var edges = data.edges;
8
8
 
9
+ // Hide legend items that don't apply to the current schema mode
10
+ var mode = data.metadata && data.metadata.mode;
11
+ if (mode) {
12
+ var hideMode = mode === "mongoid" ? "active_record" : "mongoid";
13
+ document.querySelectorAll('.legend-item[data-mode="' + hideMode + '"]').forEach(function(el) {
14
+ el.style.display = "none";
15
+ });
16
+ }
17
+
9
18
  // State
10
19
  var selectedNode = null;
11
20
  var visibleModels = new Set(nodes.map(function(n) { return n.id; }));
@@ -35,7 +44,10 @@
35
44
  belongs_to: "--edge-belongs-to",
36
45
  has_many: "--edge-has-many",
37
46
  has_one: "--edge-has-one",
38
- has_and_belongs_to_many: "--edge-habtm"
47
+ has_and_belongs_to_many: "--edge-habtm",
48
+ embeds_many: "--edge-embeds-many",
49
+ embeds_one: "--edge-embeds-one",
50
+ embedded_in: "--edge-embedded-in"
39
51
  };
40
52
 
41
53
  function buildMarkers() {
@@ -113,7 +125,10 @@
113
125
  belongs_to: { start: "many", end: "one" },
114
126
  has_many: { start: "one", end: "many" },
115
127
  has_one: { start: "one", end: "one" },
116
- has_and_belongs_to_many: { start: "many", end: "many" }
128
+ has_and_belongs_to_many: { start: "many", end: "many" },
129
+ embeds_many: { start: "one", end: "many" },
130
+ embeds_one: { start: "one", end: "one" },
131
+ embedded_in: { start: "many", end: "one" }
117
132
  };
118
133
 
119
134
  function getColumnY(node, colName) {
@@ -142,10 +157,11 @@
142
157
  var srcColY = 0;
143
158
  var tgtColY = 0;
144
159
 
145
- if (assocType === "belongs_to") {
160
+ if (assocType === "belongs_to" || assocType === "embedded_in") {
146
161
  srcColY = getColumnY(src, fk);
147
162
  tgtColY = getPkColumnY(tgt);
148
- } else if (assocType === "has_many" || assocType === "has_one") {
163
+ } else if (assocType === "has_many" || assocType === "has_one" ||
164
+ assocType === "embeds_many" || assocType === "embeds_one") {
149
165
  srcColY = getPkColumnY(src);
150
166
  tgtColY = getColumnY(tgt, fk);
151
167
  } else {
@@ -179,6 +195,10 @@
179
195
  }
180
196
 
181
197
  // d3-force simulation
198
+ var orphanNodes = [];
199
+ var connectedNodes = [];
200
+ var selfRefNodes = [];
201
+
182
202
  function setupSimulation() {
183
203
  var nodeMap = {};
184
204
  nodes.forEach(function(n) { nodeMap[n.id] = n; });
@@ -191,18 +211,156 @@
191
211
 
192
212
  var simNodes = nodes.filter(function(n) { return visibleModels.has(n.id); });
193
213
 
214
+ // Partition into connected, self-ref-only, and orphan nodes
215
+ var connectedIds = new Set();
216
+ var selfRefIds = new Set();
217
+ simEdges.forEach(function(e) {
218
+ if (e.data.from !== e.data.to) {
219
+ connectedIds.add(e.data.from);
220
+ connectedIds.add(e.data.to);
221
+ } else {
222
+ selfRefIds.add(e.data.from);
223
+ }
224
+ });
225
+ // Self-ref-only = has self-ref edge but no cross-model edges
226
+ selfRefIds.forEach(function(id) { if (connectedIds.has(id)) selfRefIds.delete(id); });
227
+
228
+ connectedNodes = simNodes.filter(function(n) { return connectedIds.has(n.id); });
229
+ selfRefNodes = simNodes.filter(function(n) { return selfRefIds.has(n.id); });
230
+ selfRefNodes.sort(function(a, b) { return a.id.localeCompare(b.id); });
231
+ orphanNodes = simNodes.filter(function(n) { return !connectedIds.has(n.id) && !selfRefIds.has(n.id); });
232
+ orphanNodes.sort(function(a, b) { return a.id.localeCompare(b.id); });
233
+
234
+ // Exclude self-ref-only edges from force simulation
235
+ var forceEdges = simEdges.filter(function(e) {
236
+ return !selfRefIds.has(e.data.from) || connectedIds.has(e.data.from);
237
+ });
238
+
194
239
  if (simulation) simulation.stop();
195
240
 
196
- simulation = d3.forceSimulation(simNodes)
197
- .force("link", d3.forceLink(simEdges).id(function(d) { return d.id; }).distance(320))
241
+ simulation = d3.forceSimulation(connectedNodes)
242
+ .force("link", d3.forceLink(forceEdges).id(function(d) { return d.id; }).distance(320))
198
243
  .force("charge", d3.forceManyBody().strength(-600))
199
244
  .force("center", d3.forceCenter(svgEl.clientWidth / 2, svgEl.clientHeight / 2))
200
245
  .force("collide", d3.forceCollide().radius(function(d) { return Math.max(NODE_WIDTH, d._height) / 2 + 50; }))
201
246
  .on("tick", ticked);
202
247
 
248
+ // Manually resolve edge references for self-ref-only nodes
249
+ simEdges.forEach(function(e) {
250
+ if (typeof e.source === 'string' && selfRefIds.has(e.source)) {
251
+ var node = selfRefNodes.find(function(n) { return n.id === e.source; });
252
+ e.source = node;
253
+ e.target = node;
254
+ }
255
+ });
256
+
203
257
  return { simNodes: simNodes, simEdges: simEdges };
204
258
  }
205
259
 
260
+ function layoutOrphans() {
261
+ if (orphanNodes.length === 0) return;
262
+
263
+ // Find connected graph bounds
264
+ var minY = Infinity, minX = Infinity, maxX = -Infinity;
265
+ connectedNodes.forEach(function(n) {
266
+ var top = n.y - n._height / 2;
267
+ if (top < minY) minY = top;
268
+ if (n.x - NODE_WIDTH / 2 < minX) minX = n.x - NODE_WIDTH / 2;
269
+ if (n.x + NODE_WIDTH / 2 > maxX) maxX = n.x + NODE_WIDTH / 2;
270
+ });
271
+
272
+ // Defaults when no connected nodes
273
+ if (minY === Infinity) minY = svgEl.clientHeight / 2;
274
+ if (minX === Infinity) minX = svgEl.clientWidth / 2 - NODE_WIDTH;
275
+ if (maxX === -Infinity) maxX = svgEl.clientWidth / 2 + NODE_WIDTH;
276
+
277
+ var centerX = (minX + maxX) / 2;
278
+ var gap = 20;
279
+ var rowGap = 30;
280
+
281
+ // Lay out orphans into rows (wrap when exceeding connected graph width)
282
+ var maxRowWidth = Math.max(maxX - minX, NODE_WIDTH * 3);
283
+ var rows = [[]];
284
+ var rowWidths = [0];
285
+
286
+ orphanNodes.forEach(function(n) {
287
+ var lastRow = rows[rows.length - 1];
288
+ var lastWidth = rowWidths[rows.length - 1];
289
+ var addedWidth = (lastRow.length > 0 ? gap : 0) + NODE_WIDTH;
290
+
291
+ if (lastRow.length > 0 && lastWidth + addedWidth > maxRowWidth) {
292
+ rows.push([n]);
293
+ rowWidths.push(NODE_WIDTH);
294
+ } else {
295
+ lastRow.push(n);
296
+ rowWidths[rows.length - 1] = lastWidth + addedWidth;
297
+ }
298
+ });
299
+
300
+ // Position rows above the diagram, bottom row first (closest to graph)
301
+ var currentY = minY - rowGap;
302
+ for (var r = rows.length - 1; r >= 0; r--) {
303
+ var row = rows[r];
304
+ var rowHeight = 0;
305
+ row.forEach(function(n) { if (n._height > rowHeight) rowHeight = n._height; });
306
+
307
+ currentY -= rowHeight / 2;
308
+ var startX = centerX - rowWidths[r] / 2 + NODE_WIDTH / 2;
309
+
310
+ row.forEach(function(n, i) {
311
+ n.x = startX + i * (NODE_WIDTH + gap);
312
+ n.y = currentY;
313
+ });
314
+
315
+ currentY -= rowHeight / 2 + rowGap;
316
+ }
317
+
318
+ }
319
+
320
+ function layoutSelfRefNodes() {
321
+ if (selfRefNodes.length === 0) return;
322
+
323
+ var minX = Infinity, minY = Infinity, maxY = -Infinity;
324
+ connectedNodes.forEach(function(n) {
325
+ if (n.x - NODE_WIDTH / 2 < minX) minX = n.x - NODE_WIDTH / 2;
326
+ if (n.y - n._height / 2 < minY) minY = n.y - n._height / 2;
327
+ if (n.y + n._height / 2 > maxY) maxY = n.y + n._height / 2;
328
+ });
329
+
330
+ if (minX === Infinity) minX = svgEl.clientWidth / 2;
331
+ if (minY === Infinity) minY = svgEl.clientHeight / 2 - 100;
332
+ if (maxY === -Infinity) maxY = svgEl.clientHeight / 2 + 100;
333
+
334
+ var gap = 20;
335
+ var colGap = 40;
336
+ var centerY = (minY + maxY) / 2;
337
+
338
+ var totalHeight = 0;
339
+ selfRefNodes.forEach(function(n) { totalHeight += n._height; });
340
+ totalHeight += (selfRefNodes.length - 1) * gap;
341
+
342
+ var currentY = centerY - totalHeight / 2;
343
+ var x = minX - NODE_WIDTH - colGap;
344
+
345
+ selfRefNodes.forEach(function(n) {
346
+ n.x = x;
347
+ n.y = currentY + n._height / 2;
348
+ currentY += n._height + gap;
349
+ });
350
+ }
351
+
352
+ function truncateText(text, maxWidth, font) {
353
+ var canvas = document.createElement("canvas");
354
+ var ctx = canvas.getContext("2d");
355
+ ctx.font = font + " " + getComputedStyle(document.body).fontFamily;
356
+ if (ctx.measureText(text).width <= maxWidth) return text;
357
+ var truncated = text;
358
+ while (truncated.length > 0 && ctx.measureText(truncated + "…").width > maxWidth) {
359
+ truncated = truncated.slice(0, -1);
360
+ }
361
+ return truncated + "…";
362
+ }
363
+
206
364
  // Render
207
365
  var currentSim;
208
366
 
@@ -251,6 +409,9 @@
251
409
  return m ? "url(#marker-" + m.end + "-" + d.data.association_type + ")" : null;
252
410
  });
253
411
 
412
+ eGroups.append("rect")
413
+ .attr("class", "edge-label-bg");
414
+
254
415
  eGroups.append("text")
255
416
  .attr("class", "edge-label")
256
417
  .attr("text-anchor", "middle")
@@ -305,7 +466,7 @@
305
466
  .attr("y", function(d) { return -d._height / 2 + 16; })
306
467
  .attr("text-anchor", "middle")
307
468
  .attr("dominant-baseline", "central")
308
- .text(function(d) { return d.id; });
469
+ .text(function(d) { return truncateText(d.id, NODE_WIDTH - NODE_PADDING * 2, "bold 13px"); });
309
470
 
310
471
  // Table name
311
472
  nGroups.append("text")
@@ -314,7 +475,7 @@
314
475
  .attr("y", function(d) { return -d._height / 2 + 30; })
315
476
  .attr("text-anchor", "middle")
316
477
  .attr("dominant-baseline", "central")
317
- .text(function(d) { return d.table_name; });
478
+ .text(function(d) { return truncateText(d.table_name, NODE_WIDTH - NODE_PADDING * 2, "10px"); });
318
479
 
319
480
  // Columns
320
481
  nGroups.each(function(d) {
@@ -350,6 +511,8 @@
350
511
  }
351
512
 
352
513
  function ticked() {
514
+ layoutOrphans();
515
+ layoutSelfRefNodes();
353
516
  edgeGroup.selectAll(".edge-group").each(function(d) {
354
517
  var g = d3.select(this);
355
518
  var src = d.source;
@@ -414,23 +577,84 @@
414
577
  .attr("y", labelY);
415
578
  });
416
579
 
580
+ resolveEdgeLabelOverlaps();
581
+
417
582
  nodeGroup.selectAll(".node-group")
418
583
  .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; });
419
584
  }
420
585
 
586
+ function resolveEdgeLabelOverlaps() {
587
+ var labels = [];
588
+ edgeGroup.selectAll(".edge-group text").each(function() {
589
+ var el = d3.select(this);
590
+ var x = +el.attr("x");
591
+ var y = +el.attr("y");
592
+ var text = el.text();
593
+ var w = text.length * 6; // approximate width at 10px font
594
+ var h = 14; // approximate height
595
+ labels.push({ el: el, x: x, y: y, w: w, h: h });
596
+ });
597
+
598
+ // Run a few nudge passes
599
+ for (var pass = 0; pass < 3; pass++) {
600
+ for (var i = 0; i < labels.length; i++) {
601
+ for (var j = i + 1; j < labels.length; j++) {
602
+ var a = labels[i], b = labels[j];
603
+ var overlapX = (a.w + b.w) / 2 - Math.abs(a.x - b.x);
604
+ var overlapY = (a.h + b.h) / 2 - Math.abs(a.y - b.y);
605
+ if (overlapX > 0 && overlapY > 0) {
606
+ // Push apart vertically
607
+ var shift = (overlapY / 2) + 2;
608
+ if (a.y <= b.y) {
609
+ a.y -= shift;
610
+ b.y += shift;
611
+ } else {
612
+ a.y += shift;
613
+ b.y -= shift;
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+
620
+ // Apply resolved positions and update background rects
621
+ labels.forEach(function(l) {
622
+ l.el.attr("y", l.y);
623
+ var bg = d3.select(l.el.node().previousElementSibling);
624
+ if (bg.classed("edge-label-bg")) {
625
+ bg.attr("x", l.x - l.w / 2)
626
+ .attr("y", l.y - l.h + 2)
627
+ .attr("width", l.w)
628
+ .attr("height", l.h);
629
+ }
630
+ });
631
+ }
632
+
421
633
  // Drag behavior
634
+ function isOrphan(d) {
635
+ return orphanNodes.indexOf(d) !== -1 || selfRefNodes.indexOf(d) !== -1;
636
+ }
637
+
422
638
  function dragStarted(event, d) {
639
+ if (isOrphan(d)) return;
423
640
  if (!event.active) simulation.alphaTarget(0.3).restart();
424
641
  d.fx = d.x;
425
642
  d.fy = d.y;
426
643
  }
427
644
 
428
645
  function dragged(event, d) {
646
+ if (isOrphan(d)) {
647
+ d.x = event.x;
648
+ d.y = event.y;
649
+ ticked();
650
+ return;
651
+ }
429
652
  d.fx = event.x;
430
653
  d.fy = event.y;
431
654
  }
432
655
 
433
656
  function dragEnded(event, d) {
657
+ if (isOrphan(d)) return;
434
658
  if (!event.active) simulation.alphaTarget(0);
435
659
  d.fx = null;
436
660
  d.fy = null;
@@ -491,10 +715,11 @@
491
715
 
492
716
  var nodeEdges = edges.filter(function(e) { return e.from === nodeId || e.to === nodeId; });
493
717
 
494
- var html = '<div style="position:relative;">';
718
+ var html = '<div class="detail-header">';
719
+ html += '<h2 title="' + escapeHtml(node.id) + '">' + escapeHtml(node.id) + '</h2>';
495
720
  html += '<button id="detail-close" onclick="window.__closeDetail()">&times;</button>';
496
- html += '<h2>' + escapeHtml(node.id) + '</h2>';
497
- html += '<div class="detail-table">' + escapeHtml(node.table_name) + '</div>';
721
+ html += '</div>';
722
+ html += '<div class="detail-table" title="' + escapeHtml(node.table_name) + '">' + escapeHtml(node.table_name) + '</div>';
498
723
 
499
724
  html += '<h3>Columns</h3>';
500
725
  html += '<ul class="column-list">';
@@ -519,7 +744,6 @@
519
744
  html += '</ul>';
520
745
  }
521
746
 
522
- html += '</div>';
523
747
  content.innerHTML = html;
524
748
 
525
749
  // Click association targets to navigate
@@ -701,5 +925,5 @@
701
925
  render();
702
926
 
703
927
  // Fit after simulation settles
704
- setTimeout(fitToScreen, 1500);
928
+ setTimeout(function() { fitToScreen(); }, 1500);
705
929
  })();