rails-flow-map 0.1.0

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.
@@ -0,0 +1,488 @@
1
+ require 'cgi'
2
+
3
+ module RailsFlowMap
4
+ # Generates an interactive HTML visualization using D3.js library
5
+ #
6
+ # This formatter creates a full HTML page with embedded JavaScript that renders
7
+ # an interactive force-directed graph. Users can drag nodes, zoom, search, and
8
+ # filter by node types.
9
+ #
10
+ # @example Basic usage
11
+ # formatter = D3jsFormatter.new(graph)
12
+ # html = formatter.format
13
+ # File.write('interactive.html', html)
14
+ #
15
+ # @example With custom options
16
+ # formatter = D3jsFormatter.new(graph, {
17
+ # width: 1200,
18
+ # height: 800,
19
+ # node_colors: { model: '#ff0000', controller: '#00ff00' }
20
+ # })
21
+ #
22
+ class D3jsFormatter
23
+ # Creates a new D3.js formatter instance
24
+ #
25
+ # @param graph [FlowGraph] The graph to visualize
26
+ # @param options [Hash] Optional configuration
27
+ # @option options [Integer] :width The width of the visualization canvas
28
+ # @option options [Integer] :height The height of the visualization canvas
29
+ # @option options [Hash] :node_colors Custom colors for node types
30
+ def initialize(graph, options = {})
31
+ @graph = graph
32
+ @options = options
33
+ end
34
+
35
+ # Generates the HTML visualization
36
+ #
37
+ # @param graph [FlowGraph] Optional graph to format (uses instance graph by default)
38
+ # @return [String] Complete HTML document with embedded D3.js visualization
39
+ def format(graph = @graph)
40
+ html_content = <<~HTML
41
+ <!DOCTYPE html>
42
+ <html lang="ja">
43
+ <head>
44
+ <meta charset="UTF-8">
45
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
46
+ <title>Rails Flow Map - Interactive Visualization</title>
47
+ <script src="https://d3js.org/d3.v7.min.js"></script>
48
+ <style>
49
+ body {
50
+ font-family: Arial, sans-serif;
51
+ margin: 0;
52
+ overflow: hidden;
53
+ }
54
+
55
+ #container {
56
+ width: 100vw;
57
+ height: 100vh;
58
+ position: relative;
59
+ }
60
+
61
+ #controls {
62
+ position: absolute;
63
+ top: 10px;
64
+ left: 10px;
65
+ background: white;
66
+ padding: 15px;
67
+ border-radius: 5px;
68
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
69
+ z-index: 1000;
70
+ }
71
+
72
+ #search {
73
+ padding: 5px 10px;
74
+ border: 1px solid #ddd;
75
+ border-radius: 3px;
76
+ width: 200px;
77
+ margin-bottom: 10px;
78
+ }
79
+
80
+ .filter-group {
81
+ margin-bottom: 10px;
82
+ }
83
+
84
+ .filter-group label {
85
+ display: block;
86
+ margin: 5px 0;
87
+ cursor: pointer;
88
+ }
89
+
90
+ .node {
91
+ cursor: pointer;
92
+ }
93
+
94
+ .node circle {
95
+ stroke-width: 2px;
96
+ }
97
+
98
+ .node text {
99
+ font: 12px sans-serif;
100
+ pointer-events: none;
101
+ }
102
+
103
+ .link {
104
+ fill: none;
105
+ stroke: #999;
106
+ stroke-opacity: 0.6;
107
+ stroke-width: 1.5px;
108
+ }
109
+
110
+ .link-label {
111
+ font: 10px sans-serif;
112
+ fill: #666;
113
+ }
114
+
115
+ .highlighted {
116
+ opacity: 1 !important;
117
+ }
118
+
119
+ .dimmed {
120
+ opacity: 0.2;
121
+ }
122
+
123
+ .tooltip {
124
+ position: absolute;
125
+ padding: 10px;
126
+ background: rgba(0, 0, 0, 0.8);
127
+ color: white;
128
+ border-radius: 5px;
129
+ pointer-events: none;
130
+ opacity: 0;
131
+ transition: opacity 0.3s;
132
+ font-size: 12px;
133
+ }
134
+
135
+ #info {
136
+ position: absolute;
137
+ bottom: 10px;
138
+ right: 10px;
139
+ background: white;
140
+ padding: 10px;
141
+ border-radius: 5px;
142
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
143
+ font-size: 12px;
144
+ }
145
+
146
+ .legend {
147
+ position: absolute;
148
+ top: 10px;
149
+ right: 10px;
150
+ background: white;
151
+ padding: 15px;
152
+ border-radius: 5px;
153
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
154
+ }
155
+
156
+ .legend-item {
157
+ margin: 5px 0;
158
+ }
159
+
160
+ .legend-color {
161
+ display: inline-block;
162
+ width: 20px;
163
+ height: 20px;
164
+ margin-right: 5px;
165
+ vertical-align: middle;
166
+ border-radius: 3px;
167
+ }
168
+ </style>
169
+ </head>
170
+ <body>
171
+ <div id="container">
172
+ <div id="controls">
173
+ <h3>フィルター</h3>
174
+ <input type="text" id="search" placeholder="検索...">
175
+
176
+ <div class="filter-group">
177
+ <label><input type="checkbox" class="type-filter" value="model" checked> モデル</label>
178
+ <label><input type="checkbox" class="type-filter" value="controller" checked> コントローラー</label>
179
+ <label><input type="checkbox" class="type-filter" value="action" checked> アクション</label>
180
+ <label><input type="checkbox" class="type-filter" value="service" checked> サービス</label>
181
+ <label><input type="checkbox" class="type-filter" value="route" checked> ルート</label>
182
+ </div>
183
+
184
+ <button id="reset-zoom">ズームリセット</button>
185
+ <button id="center-graph">中央に配置</button>
186
+ </div>
187
+
188
+ <div class="legend">
189
+ <h4>凡例</h4>
190
+ <div class="legend-item">
191
+ <span class="legend-color" style="background: #ff9999"></span>モデル
192
+ </div>
193
+ <div class="legend-item">
194
+ <span class="legend-color" style="background: #9999ff"></span>コントローラー
195
+ </div>
196
+ <div class="legend-item">
197
+ <span class="legend-color" style="background: #99ff99"></span>アクション
198
+ </div>
199
+ <div class="legend-item">
200
+ <span class="legend-color" style="background: #ffcc99"></span>サービス
201
+ </div>
202
+ <div class="legend-item">
203
+ <span class="legend-color" style="background: #ffff99"></span>ルート
204
+ </div>
205
+ </div>
206
+
207
+ <div id="info">
208
+ ノード: <span id="node-count">0</span> |
209
+ エッジ: <span id="edge-count">0</span> |
210
+ ズーム: <span id="zoom-level">100%</span>
211
+ </div>
212
+
213
+ <div class="tooltip"></div>
214
+ </div>
215
+
216
+ <script>
217
+ // データの準備
218
+ const graphData = #{generate_graph_data.to_json};
219
+
220
+ // 色の定義
221
+ const colors = {
222
+ model: '#ff9999',
223
+ controller: '#9999ff',
224
+ action: '#99ff99',
225
+ service: '#ffcc99',
226
+ route: '#ffff99'
227
+ };
228
+
229
+ // SVGの設定
230
+ const width = window.innerWidth;
231
+ const height = window.innerHeight;
232
+
233
+ const svg = d3.select("#container")
234
+ .append("svg")
235
+ .attr("width", width)
236
+ .attr("height", height);
237
+
238
+ const g = svg.append("g");
239
+
240
+ // ズーム機能
241
+ const zoom = d3.zoom()
242
+ .scaleExtent([0.1, 10])
243
+ .on("zoom", (event) => {
244
+ g.attr("transform", event.transform);
245
+ d3.select("#zoom-level").text(Math.round(event.transform.k * 100) + "%");
246
+ });
247
+
248
+ svg.call(zoom);
249
+
250
+ // Force simulation
251
+ const simulation = d3.forceSimulation(graphData.nodes)
252
+ .force("link", d3.forceLink(graphData.links).id(d => d.id).distance(100))
253
+ .force("charge", d3.forceManyBody().strength(-300))
254
+ .force("center", d3.forceCenter(width / 2, height / 2))
255
+ .force("collision", d3.forceCollide().radius(50));
256
+
257
+ // リンクの描画
258
+ const link = g.append("g")
259
+ .attr("class", "links")
260
+ .selectAll("line")
261
+ .data(graphData.links)
262
+ .enter().append("line")
263
+ .attr("class", "link")
264
+ .attr("stroke", d => d.type === 'belongs_to' ? '#ff6666' : '#999')
265
+ .attr("stroke-dasharray", d => d.type === 'has_action' ? '5,5' : 'none');
266
+
267
+ // ノードグループ
268
+ const node = g.append("g")
269
+ .attr("class", "nodes")
270
+ .selectAll("g")
271
+ .data(graphData.nodes)
272
+ .enter().append("g")
273
+ .attr("class", "node")
274
+ .call(d3.drag()
275
+ .on("start", dragstarted)
276
+ .on("drag", dragged)
277
+ .on("end", dragended));
278
+
279
+ // ノードの円
280
+ node.append("circle")
281
+ .attr("r", d => d.type === 'model' ? 15 : 10)
282
+ .attr("fill", d => colors[d.type] || '#999')
283
+ .attr("stroke", "#fff");
284
+
285
+ // ノードのラベル
286
+ node.append("text")
287
+ .attr("dy", ".35em")
288
+ .attr("x", 20)
289
+ .text(d => d.name)
290
+ .style("font-size", "12px");
291
+
292
+ // ツールチップ
293
+ const tooltip = d3.select(".tooltip");
294
+
295
+ node.on("mouseover", function(event, d) {
296
+ tooltip.transition()
297
+ .duration(200)
298
+ .style("opacity", .9);
299
+
300
+ let content = `<strong>${d.name}</strong><br/>
301
+ タイプ: ${d.type}<br/>`;
302
+
303
+ if (d.attributes) {
304
+ if (d.attributes.associations) {
305
+ content += `関連: ${d.attributes.associations.join(', ')}<br/>`;
306
+ }
307
+ if (d.attributes.path) {
308
+ content += `パス: ${d.attributes.path}<br/>`;
309
+ }
310
+ if (d.attributes.verb) {
311
+ content += `メソッド: ${d.attributes.verb}<br/>`;
312
+ }
313
+ }
314
+
315
+ tooltip.html(content)
316
+ .style("left", (event.pageX + 10) + "px")
317
+ .style("top", (event.pageY - 28) + "px");
318
+ })
319
+ .on("mouseout", function(d) {
320
+ tooltip.transition()
321
+ .duration(500)
322
+ .style("opacity", 0);
323
+ });
324
+
325
+ // ダブルクリックで関連ノードのハイライト
326
+ node.on("dblclick", function(event, d) {
327
+ event.stopPropagation();
328
+ highlightConnected(d);
329
+ });
330
+
331
+ // シミュレーションの更新
332
+ simulation.on("tick", () => {
333
+ link
334
+ .attr("x1", d => d.source.x)
335
+ .attr("y1", d => d.source.y)
336
+ .attr("x2", d => d.target.x)
337
+ .attr("y2", d => d.target.y);
338
+
339
+ node
340
+ .attr("transform", d => `translate(${d.x},${d.y})`);
341
+ });
342
+
343
+ // ドラッグ機能
344
+ function dragstarted(event, d) {
345
+ if (!event.active) simulation.alphaTarget(0.3).restart();
346
+ d.fx = d.x;
347
+ d.fy = d.y;
348
+ }
349
+
350
+ function dragged(event, d) {
351
+ d.fx = event.x;
352
+ d.fy = event.y;
353
+ }
354
+
355
+ function dragended(event, d) {
356
+ if (!event.active) simulation.alphaTarget(0);
357
+ d.fx = null;
358
+ d.fy = null;
359
+ }
360
+
361
+ // 検索機能
362
+ d3.select("#search").on("input", function() {
363
+ const searchTerm = this.value.toLowerCase();
364
+
365
+ node.classed("dimmed", d => {
366
+ return searchTerm && !d.name.toLowerCase().includes(searchTerm);
367
+ });
368
+
369
+ link.classed("dimmed", d => {
370
+ return searchTerm &&
371
+ !d.source.name.toLowerCase().includes(searchTerm) &&
372
+ !d.target.name.toLowerCase().includes(searchTerm);
373
+ });
374
+ });
375
+
376
+ // フィルター機能
377
+ d3.selectAll(".type-filter").on("change", function() {
378
+ const visibleTypes = [];
379
+ d3.selectAll(".type-filter:checked").each(function() {
380
+ visibleTypes.push(this.value);
381
+ });
382
+
383
+ node.style("display", d => visibleTypes.includes(d.type) ? null : "none");
384
+
385
+ link.style("display", d => {
386
+ return visibleTypes.includes(d.source.type) &&
387
+ visibleTypes.includes(d.target.type) ? null : "none";
388
+ });
389
+ });
390
+
391
+ // 関連ノードのハイライト
392
+ function highlightConnected(selectedNode) {
393
+ const connectedNodeIds = new Set([selectedNode.id]);
394
+
395
+ graphData.links.forEach(link => {
396
+ if (link.source.id === selectedNode.id) {
397
+ connectedNodeIds.add(link.target.id);
398
+ }
399
+ if (link.target.id === selectedNode.id) {
400
+ connectedNodeIds.add(link.source.id);
401
+ }
402
+ });
403
+
404
+ node.classed("dimmed", d => !connectedNodeIds.has(d.id));
405
+ node.classed("highlighted", d => connectedNodeIds.has(d.id));
406
+
407
+ link.classed("dimmed", d => {
408
+ return !connectedNodeIds.has(d.source.id) || !connectedNodeIds.has(d.target.id);
409
+ });
410
+ link.classed("highlighted", d => {
411
+ return connectedNodeIds.has(d.source.id) && connectedNodeIds.has(d.target.id);
412
+ });
413
+ }
414
+
415
+ // リセット機能
416
+ svg.on("dblclick", function() {
417
+ node.classed("dimmed", false);
418
+ node.classed("highlighted", false);
419
+ link.classed("dimmed", false);
420
+ link.classed("highlighted", false);
421
+ });
422
+
423
+ // コントロールボタン
424
+ d3.select("#reset-zoom").on("click", () => {
425
+ svg.transition()
426
+ .duration(750)
427
+ .call(zoom.transform, d3.zoomIdentity);
428
+ });
429
+
430
+ d3.select("#center-graph").on("click", () => {
431
+ const bounds = g.node().getBBox();
432
+ const fullWidth = width;
433
+ const fullHeight = height;
434
+ const widthScale = fullWidth / bounds.width;
435
+ const heightScale = fullHeight / bounds.height;
436
+ const scale = 0.8 * Math.min(widthScale, heightScale);
437
+ const translate = [
438
+ fullWidth / 2 - scale * (bounds.x + bounds.width / 2),
439
+ fullHeight / 2 - scale * (bounds.y + bounds.height / 2)
440
+ ];
441
+
442
+ svg.transition()
443
+ .duration(750)
444
+ .call(zoom.transform, d3.zoomIdentity
445
+ .translate(translate[0], translate[1])
446
+ .scale(scale));
447
+ });
448
+
449
+ // 情報更新
450
+ d3.select("#node-count").text(graphData.nodes.length);
451
+ d3.select("#edge-count").text(graphData.links.length);
452
+ </script>
453
+ </body>
454
+ </html>
455
+ HTML
456
+
457
+ html_content
458
+ end
459
+
460
+ private
461
+
462
+ # Generates the graph data structure for D3.js consumption
463
+ #
464
+ # @return [Hash] Graph data with nodes and links arrays
465
+ # @note Node IDs and names are NOT HTML-escaped as they will be used in JavaScript
466
+ def generate_graph_data
467
+ nodes = @graph.nodes.values.map do |node|
468
+ {
469
+ id: node.id,
470
+ name: node.name,
471
+ type: node.type.to_s,
472
+ attributes: node.attributes
473
+ }
474
+ end
475
+
476
+ links = @graph.edges.map do |edge|
477
+ {
478
+ source: edge.from,
479
+ target: edge.to,
480
+ type: edge.type.to_s,
481
+ label: edge.label
482
+ }
483
+ end
484
+
485
+ { nodes: nodes, links: links }
486
+ end
487
+ end
488
+ end
@@ -0,0 +1,64 @@
1
+ module RailsFlowMap
2
+ class ErdFormatter
3
+ def initialize(graph)
4
+ @graph = graph
5
+ end
6
+
7
+ def format(graph = @graph)
8
+ output = []
9
+ output << "# Entity Relationship Diagram\n"
10
+ output << "```"
11
+
12
+ # モデルのみを抽出
13
+ models = graph.nodes_by_type(:model)
14
+
15
+ models.each do |model|
16
+ output << format_model_box(model)
17
+ output << ""
18
+ end
19
+
20
+ # 関係性を表示
21
+ output << "## Relationships\n"
22
+ graph.edges_by_type(:belongs_to).each do |edge|
23
+ from_model = graph.find_node(edge.from)
24
+ to_model = graph.find_node(edge.to)
25
+ output << "#{from_model.name} belongs_to #{to_model.name}"
26
+ end
27
+
28
+ graph.edges_by_type(:has_many).each do |edge|
29
+ from_model = graph.find_node(edge.from)
30
+ to_model = graph.find_node(edge.to)
31
+ output << "#{from_model.name} has_many #{to_model.name}"
32
+ end
33
+
34
+ output << "```"
35
+ output.join("\n")
36
+ end
37
+
38
+ private
39
+
40
+ def format_model_box(model)
41
+ lines = []
42
+ lines << "┌─────────────────────┐"
43
+ lines << "│ #{model.name.center(19)} │"
44
+ lines << "├─────────────────────┤"
45
+
46
+ # 仮のカラム情報(実際の実装では、モデルから取得)
47
+ lines << "│ id :integer │"
48
+ lines << "│ created_at :datetime│"
49
+ lines << "│ updated_at :datetime│"
50
+
51
+ if model.attributes[:associations]
52
+ model.attributes[:associations].each do |assoc|
53
+ if assoc.include?("belongs_to")
54
+ foreign_key = assoc.split(" ").last.downcase + "_id"
55
+ lines << "│ #{foreign_key.ljust(10)} :integer │"
56
+ end
57
+ end
58
+ end
59
+
60
+ lines << "└─────────────────────┘"
61
+ lines.join("\n")
62
+ end
63
+ end
64
+ end