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 +4 -4
- data/CHANGELOG.md +23 -0
- data/CLAUDE.md +107 -0
- data/README.md +29 -5
- data/lib/rails/schema/assets/app.js +237 -13
- data/lib/rails/schema/assets/style.css +55 -3
- data/lib/rails/schema/assets/template.html.erb +3 -1
- data/lib/rails/schema/extractor/model_scanner.rb +5 -5
- data/lib/rails/schema/extractor/mongoid/association_reader.rb +59 -0
- data/lib/rails/schema/extractor/mongoid/column_reader.rb +71 -0
- data/lib/rails/schema/extractor/mongoid/model_adapter.rb +37 -0
- data/lib/rails/schema/extractor/mongoid/model_scanner.rb +135 -0
- data/lib/rails/schema/extractor/structure_sql_parser.rb +6 -4
- data/lib/rails/schema/transformer/graph_builder.rb +22 -9
- data/lib/rails/schema/version.rb +1 -1
- data/lib/rails/schema.rb +44 -1
- metadata +34 -13
- data/PROJECT.md +0 -348
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71fa755a65ac8dcdb27f419857dd5ede16a13d4c26e5b9a40bc7e8ec1f41d36b
|
|
4
|
+
data.tar.gz: 4940bf9f24e51d6caf3e5e70526f7a2ed8d097c352e99920e6191212c1d6137e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|

|
|
@@ -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 :
|
|
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 `:
|
|
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
|
|
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(
|
|
197
|
-
.force("link", d3.forceLink(
|
|
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
|
|
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()">×</button>';
|
|
496
|
-
html += '
|
|
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
|
})();
|