erd_map 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +106 -0
- data/Rakefile +5 -0
- data/app/assets/stylesheets/erd_map/application.css +15 -0
- data/app/controllers/erd_map/application_controller.rb +4 -0
- data/app/controllers/erd_map/erd_map_controller.rb +29 -0
- data/app/helpers/erd_map/application_helper.rb +4 -0
- data/app/models/erd_map/application_record.rb +5 -0
- data/app/views/layouts/erd_map/application.html.erb +17 -0
- data/config/initializers/erd_map.rb +3 -0
- data/config/routes.rb +6 -0
- data/lib/erd_map/engine.rb +5 -0
- data/lib/erd_map/graph.rb +225 -0
- data/lib/erd_map/graph_manager.js +592 -0
- data/lib/erd_map/graph_renderer.rb +298 -0
- data/lib/erd_map/map_builder.rb +115 -0
- data/lib/erd_map/plot.rb +195 -0
- data/lib/erd_map/py_call_modules.rb +25 -0
- data/lib/erd_map/version.rb +3 -0
- data/lib/erd_map.rb +19 -0
- data/lib/tasks/erd_map_tasks.rake +8 -0
- metadata +108 -0
@@ -0,0 +1,298 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ErdMap
|
4
|
+
class GraphRenderer
|
5
|
+
extend Forwardable
|
6
|
+
def_delegators :@graph_renderer, :node_renderer
|
7
|
+
|
8
|
+
attr_reader :graph_renderer
|
9
|
+
|
10
|
+
VISIBLE = 1.0
|
11
|
+
TRANSLUCENT = 0.01
|
12
|
+
HIGHLIGHT_NODE_COLOR = "black"
|
13
|
+
HIGHLIGHT_EDGE_COLOR = "orange"
|
14
|
+
HIGHLIGHT_TEXT_COLOR = "white"
|
15
|
+
BASIC_COLOR = "darkslategray"
|
16
|
+
EMPTHASIS_NODE_SIZE = 80
|
17
|
+
|
18
|
+
def renderers
|
19
|
+
[circle_renderer, rect_renderer]
|
20
|
+
end
|
21
|
+
|
22
|
+
def cardinality_label
|
23
|
+
bokeh_models.LabelSet.new(
|
24
|
+
x: "x",
|
25
|
+
y: "y",
|
26
|
+
text: "text",
|
27
|
+
source: cardinality_data_source,
|
28
|
+
text_font_size: "12pt",
|
29
|
+
text_color: "text_color",
|
30
|
+
text_alpha: { field: "alpha" },
|
31
|
+
)
|
32
|
+
end
|
33
|
+
|
34
|
+
def js_args(plot)
|
35
|
+
{
|
36
|
+
graphRenderer: graph_renderer,
|
37
|
+
rectRenderer: rect_renderer,
|
38
|
+
circleRenderer: circle_renderer,
|
39
|
+
layoutProvider: layout_provider,
|
40
|
+
cardinalityDataSource: cardinality_data_source,
|
41
|
+
connectionsData: graph.connections.to_json,
|
42
|
+
layoutsByChunkData: graph.layouts_by_chunk.to_json,
|
43
|
+
chunkedNodesData: graph.chunked_nodes.to_json,
|
44
|
+
nodeWithCommunityIndexData: graph.node_with_community_index.to_json,
|
45
|
+
searchBox: plot.button_set[:search_box],
|
46
|
+
selectingNodeLabel: plot.button_set[:selecting_node_label],
|
47
|
+
zoomModeToggle: plot.button_set[:zoom_mode_toggle],
|
48
|
+
tapModeToggle: plot.button_set[:tap_mode_toggle],
|
49
|
+
displayTitleModeToggle: plot.button_set[:display_title_mode_toggle],
|
50
|
+
nodeLabels: {
|
51
|
+
titleModelLabel: title_model_label,
|
52
|
+
foreignModelLabel: foreign_model_label,
|
53
|
+
foreignColumnsLabel: foreign_columns_label,
|
54
|
+
},
|
55
|
+
plot: plot.plot,
|
56
|
+
VISIBLE: VISIBLE,
|
57
|
+
TRANSLUCENT: TRANSLUCENT,
|
58
|
+
HIGHLIGHT_NODE_COLOR: HIGHLIGHT_NODE_COLOR,
|
59
|
+
HIGHLIGHT_EDGE_COLOR: HIGHLIGHT_EDGE_COLOR,
|
60
|
+
HIGHLIGHT_TEXT_COLOR: HIGHLIGHT_TEXT_COLOR,
|
61
|
+
BASIC_COLOR: BASIC_COLOR,
|
62
|
+
EMPTHASIS_NODE_SIZE: EMPTHASIS_NODE_SIZE,
|
63
|
+
}
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
attr_reader :bokeh_models
|
69
|
+
attr_reader :graph
|
70
|
+
|
71
|
+
def initialize(graph)
|
72
|
+
import_modules = ErdMap.py_call_modules.imported_modules
|
73
|
+
@bokeh_models = import_modules[:bokeh_models]
|
74
|
+
@graph = graph
|
75
|
+
@graph_renderer = circle_renderer
|
76
|
+
end
|
77
|
+
|
78
|
+
def node_data_source
|
79
|
+
nodes_x, nodes_y = graph.node_names.map { |node| graph.initial_layout[node] ? graph.initial_layout[node] : graph.whole_layout[node] }.transpose
|
80
|
+
nodes_alpha = graph.node_names.map { |node| graph.initial_layout[node] ? VISIBLE : TRANSLUCENT }
|
81
|
+
|
82
|
+
columns_label = []
|
83
|
+
title_label = []
|
84
|
+
rect_heights = []
|
85
|
+
graph.node_names.map do |node_name|
|
86
|
+
title_text = format_text([node_name], title: true)
|
87
|
+
columns_text = [*title_text.scan("\n"), "\n", format_text(graph.association_columns[node_name])].join
|
88
|
+
columns_label << columns_text
|
89
|
+
title_label << [title_text, "\n", *columns_text.scan("\n")].join
|
90
|
+
|
91
|
+
padding = 36
|
92
|
+
line_count = columns_text.scan("\n").size + 1
|
93
|
+
rect_heights << line_count * 20 + padding
|
94
|
+
end
|
95
|
+
|
96
|
+
bokeh_models.ColumnDataSource.new(
|
97
|
+
data: {
|
98
|
+
index: graph.node_names,
|
99
|
+
alpha: nodes_alpha,
|
100
|
+
x: nodes_x,
|
101
|
+
y: nodes_y,
|
102
|
+
radius: graph.node_radius,
|
103
|
+
original_radius: graph.node_radius,
|
104
|
+
rect_height: rect_heights,
|
105
|
+
title_label: title_label,
|
106
|
+
columns_label: columns_label,
|
107
|
+
fill_color: graph.node_colors,
|
108
|
+
circle_original_color: graph.node_colors,
|
109
|
+
rect_original_color: graph.node_names.map { "white" },
|
110
|
+
text_color: graph.node_names.map { BASIC_COLOR },
|
111
|
+
text_outline_color: graph.node_names.map { nil },
|
112
|
+
}
|
113
|
+
)
|
114
|
+
end
|
115
|
+
|
116
|
+
def format_text(columns, title: false)
|
117
|
+
max_chars_size = title ? 18 : 20
|
118
|
+
columns.flat_map { |column| column.scan(/(\w{1,#{max_chars_size}})/) }.join("\n")
|
119
|
+
end
|
120
|
+
|
121
|
+
def circle_renderer
|
122
|
+
@circle_renderer ||= bokeh_models.GraphRenderer.new(
|
123
|
+
layout_provider: layout_provider,
|
124
|
+
visible: true,
|
125
|
+
).tap do |renderer|
|
126
|
+
renderer.node_renderer.data_source = node_data_source
|
127
|
+
renderer.node_renderer.glyph = circle_glyph
|
128
|
+
renderer.node_renderer.selection_glyph = renderer.node_renderer.glyph
|
129
|
+
renderer.node_renderer.nonselection_glyph = renderer.node_renderer.glyph
|
130
|
+
renderer.edge_renderer.data_source = edge_data_source
|
131
|
+
renderer.edge_renderer.glyph = bokeh_models.MultiLine.new(
|
132
|
+
line_color: { field: "line_color" },
|
133
|
+
line_alpha: { field: "alpha" },
|
134
|
+
line_width: 1,
|
135
|
+
)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def rect_renderer
|
140
|
+
@rect_renderer ||= bokeh_models.GraphRenderer.new(
|
141
|
+
layout_provider: layout_provider,
|
142
|
+
visible: false,
|
143
|
+
).tap do |renderer|
|
144
|
+
renderer.node_renderer.data_source = node_data_source
|
145
|
+
renderer.node_renderer.glyph = rect_glyph
|
146
|
+
renderer.node_renderer.selection_glyph = renderer.node_renderer.glyph
|
147
|
+
renderer.node_renderer.nonselection_glyph = renderer.node_renderer.glyph
|
148
|
+
renderer.edge_renderer.data_source = edge_data_source
|
149
|
+
renderer.edge_renderer.glyph = bokeh_models.MultiLine.new(
|
150
|
+
line_color: { field: "line_color" },
|
151
|
+
line_alpha: { field: "alpha" },
|
152
|
+
line_width: 1,
|
153
|
+
)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def circle_glyph
|
158
|
+
bokeh_models.Circle.new(
|
159
|
+
radius: "radius",
|
160
|
+
radius_units: "screen",
|
161
|
+
fill_color: { field: "fill_color" },
|
162
|
+
fill_alpha: { field: "alpha" },
|
163
|
+
line_alpha: { field: "alpha" },
|
164
|
+
)
|
165
|
+
end
|
166
|
+
|
167
|
+
def rect_glyph
|
168
|
+
bokeh_models.Rect.new(
|
169
|
+
width: 150,
|
170
|
+
height: { field: "rect_height" },
|
171
|
+
width_units: "screen",
|
172
|
+
height_units: "screen",
|
173
|
+
fill_color: { field: "fill_color" },
|
174
|
+
fill_alpha: { field: "alpha" },
|
175
|
+
line_color: BASIC_COLOR,
|
176
|
+
line_alpha: { field: "alpha" },
|
177
|
+
)
|
178
|
+
end
|
179
|
+
|
180
|
+
def title_model_label
|
181
|
+
bokeh_models.LabelSet.new(
|
182
|
+
x: "x",
|
183
|
+
y: "y",
|
184
|
+
text: "index",
|
185
|
+
source: graph_renderer.node_renderer.data_source,
|
186
|
+
text_font_size: "12pt",
|
187
|
+
text_color: { field: "text_color" },
|
188
|
+
text_outline_color: { field: "text_outline_color" },
|
189
|
+
text_align: "center",
|
190
|
+
text_baseline: "middle",
|
191
|
+
text_alpha: { field: "alpha" },
|
192
|
+
visible: true,
|
193
|
+
)
|
194
|
+
end
|
195
|
+
|
196
|
+
def foreign_model_label
|
197
|
+
bokeh_models.LabelSet.new(
|
198
|
+
x: "x",
|
199
|
+
y: "y",
|
200
|
+
text: { field: "title_label" },
|
201
|
+
source: graph_renderer.node_renderer.data_source,
|
202
|
+
text_font_size: "10pt",
|
203
|
+
text_font_style: "bold",
|
204
|
+
text_color: { field: "text_color" },
|
205
|
+
text_outline_color: { field: "text_outline_color" },
|
206
|
+
text_align: "center",
|
207
|
+
text_baseline: "middle",
|
208
|
+
text_alpha: { field: "alpha" },
|
209
|
+
# visible: false,
|
210
|
+
)
|
211
|
+
end
|
212
|
+
|
213
|
+
def foreign_columns_label
|
214
|
+
bokeh_models.LabelSet.new(
|
215
|
+
x: "x",
|
216
|
+
y: "y",
|
217
|
+
text: { field: "columns_label" },
|
218
|
+
source: graph_renderer.node_renderer.data_source,
|
219
|
+
text_font_size: "10pt",
|
220
|
+
text_font_style: "normal",
|
221
|
+
text_color: { field: "text_color" },
|
222
|
+
text_outline_color: { field: "text_outline_color" },
|
223
|
+
text_align: "center",
|
224
|
+
text_baseline: "middle",
|
225
|
+
text_alpha: { field: "alpha" },
|
226
|
+
# visible: false,
|
227
|
+
)
|
228
|
+
end
|
229
|
+
|
230
|
+
def edge_data_source
|
231
|
+
edge_start, edge_end = graph.edges.map { |edge| [edge[0], edge[1]] }.transpose
|
232
|
+
edges_alpha = graph.edges.map { |edge| graph.initial_nodes.include?(edge[0]) && graph.initial_nodes.include?(edge[1]) ? VISIBLE : TRANSLUCENT }
|
233
|
+
bokeh_models.ColumnDataSource.new(
|
234
|
+
data: {
|
235
|
+
start: edge_start,
|
236
|
+
end: edge_end,
|
237
|
+
alpha: edges_alpha,
|
238
|
+
line_color: graph.edges.map { BASIC_COLOR },
|
239
|
+
}
|
240
|
+
)
|
241
|
+
end
|
242
|
+
|
243
|
+
def cardinality_data_source
|
244
|
+
return @cardinality_data_source if @cardinality_data_source
|
245
|
+
|
246
|
+
@cardinality_data_source = bokeh_models.ColumnDataSource.new(
|
247
|
+
data: {
|
248
|
+
x: [],
|
249
|
+
y: [],
|
250
|
+
source: [],
|
251
|
+
target: [],
|
252
|
+
text: [],
|
253
|
+
alpha: [],
|
254
|
+
text_color: [],
|
255
|
+
}
|
256
|
+
)
|
257
|
+
|
258
|
+
graph.edges.each do |(source_node, target_node)|
|
259
|
+
next if source_node == target_node
|
260
|
+
|
261
|
+
label_alpha = graph.initial_nodes.include?(source_node) && graph.initial_nodes.include?(target_node) ?
|
262
|
+
VISIBLE : 0
|
263
|
+
|
264
|
+
x_offset = 0.2
|
265
|
+
y_offset = 0.3
|
266
|
+
source_x, source_y = graph.initial_layout[source_node] || graph.whole_layout[source_node]
|
267
|
+
target_x, target_y = graph.initial_layout[target_node] || graph.whole_layout[target_node]
|
268
|
+
vector_x = target_x - source_x
|
269
|
+
vector_y = target_y - source_y
|
270
|
+
length = Math.sqrt(vector_x**2 + vector_y**2)
|
271
|
+
|
272
|
+
@cardinality_data_source.data[:x] << source_x + (vector_x / length) * x_offset
|
273
|
+
@cardinality_data_source.data[:y] << source_y + (vector_y / length) * y_offset
|
274
|
+
@cardinality_data_source.data[:source] << source_node
|
275
|
+
@cardinality_data_source.data[:target] << target_node
|
276
|
+
@cardinality_data_source.data[:text] << "1"
|
277
|
+
@cardinality_data_source.data[:alpha] << label_alpha
|
278
|
+
|
279
|
+
@cardinality_data_source.data[:x] << target_x - (vector_x / length) * x_offset
|
280
|
+
@cardinality_data_source.data[:y] << target_y - (vector_y / length) * y_offset
|
281
|
+
@cardinality_data_source.data[:source] << source_node
|
282
|
+
@cardinality_data_source.data[:target] << target_node
|
283
|
+
@cardinality_data_source.data[:text] << "n" # FIXME: Show "1" when has_one association
|
284
|
+
@cardinality_data_source.data[:alpha] << label_alpha
|
285
|
+
end
|
286
|
+
@cardinality_data_source.data[:text_color] = Array.new(@cardinality_data_source.data[:x].to_a.size) { BASIC_COLOR }
|
287
|
+
@cardinality_data_source
|
288
|
+
end
|
289
|
+
|
290
|
+
def layout_provider
|
291
|
+
return @layout_provider if @layout_provider
|
292
|
+
|
293
|
+
nodes_x, nodes_y = graph.node_names.map { |node| graph.initial_layout[node] ? graph.initial_layout[node] : graph.whole_layout[node] }.transpose
|
294
|
+
graph_layout = graph.node_names.zip(nodes_x, nodes_y).map { |node, x, y| [node, [x, y]] }.to_h
|
295
|
+
@layout_provider = bokeh_models.StaticLayoutProvider.new(graph_layout: graph_layout)
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ErdMap
|
4
|
+
class MapBuilder
|
5
|
+
def execute
|
6
|
+
import_modules
|
7
|
+
@graph = ErdMap::Graph.new
|
8
|
+
@graph_renderer = ErdMap::GraphRenderer.new(@graph)
|
9
|
+
save(build_layout)
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
attr_reader :bokeh_io, :bokeh_models, :bokeh_plotting
|
15
|
+
attr_reader :graph, :graph_renderer
|
16
|
+
|
17
|
+
def import_modules
|
18
|
+
import_modules = ErdMap.py_call_modules.imported_modules
|
19
|
+
@bokeh_io = import_modules[:bokeh_io]
|
20
|
+
@bokeh_models = import_modules[:bokeh_models]
|
21
|
+
@bokeh_plotting = import_modules[:bokeh_plotting]
|
22
|
+
end
|
23
|
+
|
24
|
+
def build_layout
|
25
|
+
plot = Plot.new(graph)
|
26
|
+
graph_renderer.renderers.each do |renderer|
|
27
|
+
plot.renderers.append(renderer)
|
28
|
+
renderer.node_renderer.data_source.selected.js_on_change("indices", toggle_tapped)
|
29
|
+
end
|
30
|
+
plot.add_layout(graph_renderer.cardinality_label)
|
31
|
+
bokeh_io.curdoc.js_on_event("document_ready", setup_graph_manager(plot))
|
32
|
+
|
33
|
+
bokeh_models.Column.new(
|
34
|
+
children: [
|
35
|
+
bokeh_models.Row.new(
|
36
|
+
children: [
|
37
|
+
plot.button_set[:left_spacer],
|
38
|
+
plot.button_set[:selecting_node_label],
|
39
|
+
plot.button_set[:search_box],
|
40
|
+
plot.button_set[:zoom_mode_toggle],
|
41
|
+
plot.button_set[:tap_mode_toggle],
|
42
|
+
plot.button_set[:display_title_mode_toggle],
|
43
|
+
plot.button_set[:re_layout_button],
|
44
|
+
plot.button_set[:zoom_in_button],
|
45
|
+
plot.button_set[:zoom_out_button],
|
46
|
+
plot.button_set[:re_compute_button],
|
47
|
+
plot.button_set[:right_spacer],
|
48
|
+
],
|
49
|
+
sizing_mode: "stretch_width",
|
50
|
+
),
|
51
|
+
plot.plot,
|
52
|
+
],
|
53
|
+
sizing_mode: "stretch_both",
|
54
|
+
)
|
55
|
+
end
|
56
|
+
|
57
|
+
def save(layout)
|
58
|
+
tmp_dir = Rails.root.join("tmp", "erd_map")
|
59
|
+
FileUtils.makedirs(tmp_dir) unless Dir.exist?(tmp_dir)
|
60
|
+
output_path = File.join(tmp_dir, "map.html")
|
61
|
+
|
62
|
+
bokeh_io.output_file(output_path)
|
63
|
+
bokeh_io.save(layout)
|
64
|
+
puts output_path
|
65
|
+
end
|
66
|
+
|
67
|
+
def setup_graph_manager(plot)
|
68
|
+
bokeh_models.CustomJS.new(
|
69
|
+
args: graph_renderer.js_args(plot),
|
70
|
+
code: <<~JS
|
71
|
+
#{graph_manager}
|
72
|
+
window.graphManager = new GraphManager({
|
73
|
+
graphRenderer,
|
74
|
+
rectRenderer,
|
75
|
+
circleRenderer,
|
76
|
+
layoutProvider,
|
77
|
+
connectionsData,
|
78
|
+
layoutsByChunkData,
|
79
|
+
chunkedNodesData,
|
80
|
+
nodeWithCommunityIndexData,
|
81
|
+
selectingNodeLabel,
|
82
|
+
searchBox,
|
83
|
+
zoomModeToggle,
|
84
|
+
tapModeToggle,
|
85
|
+
displayTitleModeToggle,
|
86
|
+
nodeLabels,
|
87
|
+
plot,
|
88
|
+
windowObj: window,
|
89
|
+
})
|
90
|
+
JS
|
91
|
+
)
|
92
|
+
end
|
93
|
+
|
94
|
+
def toggle_tapped
|
95
|
+
bokeh_models.CustomJS.new(
|
96
|
+
code: <<~JS
|
97
|
+
window.graphManager.cbObj = cb_obj
|
98
|
+
window.graphManager.toggleTapped()
|
99
|
+
JS
|
100
|
+
)
|
101
|
+
end
|
102
|
+
|
103
|
+
def graph_manager
|
104
|
+
@graph_manager ||= File.read(__dir__ + "/graph_manager.js")
|
105
|
+
end
|
106
|
+
|
107
|
+
class << self
|
108
|
+
def build
|
109
|
+
Rails.logger.info "build start"
|
110
|
+
new.execute
|
111
|
+
Rails.logger.info "build completed"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
data/lib/erd_map/plot.rb
ADDED
@@ -0,0 +1,195 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ErdMap
|
4
|
+
class Plot
|
5
|
+
extend Forwardable
|
6
|
+
def_delegators :@plot, :renderers, :add_layout
|
7
|
+
|
8
|
+
def plot
|
9
|
+
return @plot if @plot
|
10
|
+
|
11
|
+
padding_ratio = 0.1
|
12
|
+
x_min, x_max, y_min, y_max = graph.initial_layout.values.transpose.map(&:minmax).flatten
|
13
|
+
x_padding, y_padding = [(x_max - x_min) * padding_ratio, (y_max - y_min) * padding_ratio]
|
14
|
+
@plot = bokeh_models.Plot.new(
|
15
|
+
sizing_mode: "stretch_both",
|
16
|
+
x_range: bokeh_models.Range1d.new(start: x_min - x_padding, end: x_max + x_padding),
|
17
|
+
y_range: bokeh_models.Range1d.new(start: y_min - y_padding, end: y_max + y_padding),
|
18
|
+
tools: [
|
19
|
+
wheel_zoom_tool = bokeh_models.WheelZoomTool.new,
|
20
|
+
bokeh_models.ResetTool.new,
|
21
|
+
bokeh_models.PanTool.new,
|
22
|
+
bokeh_models.TapTool.new,
|
23
|
+
],
|
24
|
+
).tap do |plot|
|
25
|
+
plot.toolbar.active_scroll = wheel_zoom_tool
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def button_set
|
30
|
+
@button_set ||= ButtonSet.new
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
attr_reader :graph
|
36
|
+
|
37
|
+
def initialize(graph)
|
38
|
+
@graph = graph
|
39
|
+
register_callback
|
40
|
+
end
|
41
|
+
|
42
|
+
def register_callback
|
43
|
+
plot.x_range.js_on_change("start", custom_js("triggerZoom"))
|
44
|
+
plot.x_range.js_on_change("end", custom_js("triggerZoom"))
|
45
|
+
plot.js_on_event("mousemove", custom_js("toggleHovered"))
|
46
|
+
plot.js_on_event("mousemove", bokeh_models.CustomJS.new(code: save_mouse_position))
|
47
|
+
plot.js_on_event("reset", custom_js("resetPlot"))
|
48
|
+
end
|
49
|
+
|
50
|
+
def bokeh_models
|
51
|
+
@bokeh_models ||= ErdMap.py_call_modules.imported_modules[:bokeh_models]
|
52
|
+
end
|
53
|
+
|
54
|
+
def custom_js(function_name)
|
55
|
+
bokeh_models.CustomJS.new(
|
56
|
+
code: <<~JS
|
57
|
+
window.graphManager.cbObj = cb_obj
|
58
|
+
window.graphManager.#{function_name}()
|
59
|
+
JS
|
60
|
+
)
|
61
|
+
end
|
62
|
+
|
63
|
+
def save_mouse_position
|
64
|
+
<<~JS
|
65
|
+
if (window.saveMousePosition !== undefined) { clearTimeout(window.saveMousePosition) }
|
66
|
+
window.saveMousePosition = setTimeout(function() {
|
67
|
+
window.lastMouseX = cb_obj.x
|
68
|
+
window.lastMouseY = cb_obj.y
|
69
|
+
}, 100)
|
70
|
+
JS
|
71
|
+
end
|
72
|
+
|
73
|
+
class ButtonSet
|
74
|
+
extend Forwardable
|
75
|
+
def_delegators :@button_set, :[]
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def initialize
|
80
|
+
@button_set = {
|
81
|
+
left_spacer: left_spacer,
|
82
|
+
selecting_node_label: selecting_node_label,
|
83
|
+
search_box: search_box,
|
84
|
+
zoom_mode_toggle: zoom_mode_toggle,
|
85
|
+
tap_mode_toggle: tap_mode_toggle,
|
86
|
+
display_title_mode_toggle: display_title_mode_toggle,
|
87
|
+
re_layout_button: re_layout_button,
|
88
|
+
zoom_in_button: zoom_in_button,
|
89
|
+
zoom_out_button: zoom_out_button,
|
90
|
+
re_compute_button: re_compute_button,
|
91
|
+
right_spacer: right_spacer,
|
92
|
+
}
|
93
|
+
end
|
94
|
+
|
95
|
+
def left_spacer
|
96
|
+
bokeh_models.Spacer.new(width: 0, sizing_mode: "stretch_width")
|
97
|
+
end
|
98
|
+
|
99
|
+
def right_spacer
|
100
|
+
bokeh_models.Spacer.new(width: 30, sizing_mode: "fixed")
|
101
|
+
end
|
102
|
+
|
103
|
+
def selecting_node_label
|
104
|
+
bokeh_models.Div.new(
|
105
|
+
text: "",
|
106
|
+
height: 28,
|
107
|
+
styles: { display: :flex, align_items: :center },
|
108
|
+
)
|
109
|
+
end
|
110
|
+
|
111
|
+
def search_box
|
112
|
+
bokeh_models.TextInput.new(placeholder: "🔍 Search model", width: 200).tap do |input|
|
113
|
+
input.js_on_change("value", custom_js("searchNodes"))
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def zoom_mode_toggle
|
118
|
+
bokeh_models.Button.new(label: "Wheel mode: fix", button_type: "default").tap do |button|
|
119
|
+
button.js_on_click(custom_js("toggleZoomMode"))
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def tap_mode_toggle
|
124
|
+
bokeh_models.Button.new(label: "Tap mode: association", button_type: "default").tap do |button|
|
125
|
+
button.js_on_click(custom_js("toggleTapMode"))
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def display_title_mode_toggle
|
130
|
+
bokeh_models.Button.new(label: "Display mode: title", button_type: "default").tap do |button|
|
131
|
+
button.js_on_click(custom_js("toggleDisplayTitleMode"))
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def re_layout_button
|
136
|
+
bokeh_models.Button.new(label: "Re-Layout", button_type: "default").tap do |button|
|
137
|
+
button.js_on_click(custom_js("reLayout"))
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def zoom_in_button
|
142
|
+
bokeh_models.Button.new(label: "Zoom In", button_type: "primary").tap do |button|
|
143
|
+
button.js_on_click(custom_js("zoomIn",))
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def zoom_out_button
|
148
|
+
bokeh_models.Button.new(label: "Zoom Out", button_type: "success").tap do |button|
|
149
|
+
button.js_on_click(custom_js("zoomOut"))
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def re_compute_button
|
154
|
+
bokeh_models.Button.new(label: "Re-Compute", button_type: "default").tap do |button|
|
155
|
+
button.js_on_click(
|
156
|
+
bokeh_models.CustomJS.new(
|
157
|
+
args: { button: button },
|
158
|
+
code: <<~JS
|
159
|
+
button.disabled = true
|
160
|
+
button.label = "Computing ..."
|
161
|
+
|
162
|
+
fetch("/erd_map", { method: "PUT" })
|
163
|
+
.then(response => {
|
164
|
+
if (response.ok) { return response.text() }
|
165
|
+
else { return response.json().then(json => { throw new Error(json.message) }) }
|
166
|
+
})
|
167
|
+
.then(data => { window.location.reload() })
|
168
|
+
.catch(error => {
|
169
|
+
alert(error.message)
|
170
|
+
console.error(error)
|
171
|
+
|
172
|
+
button.disabled = false
|
173
|
+
button.label = "Re-Compute"
|
174
|
+
})
|
175
|
+
JS
|
176
|
+
)
|
177
|
+
)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def bokeh_models
|
182
|
+
@bokeh_models ||= ErdMap.py_call_modules.imported_modules[:bokeh_models]
|
183
|
+
end
|
184
|
+
|
185
|
+
def custom_js(function_name)
|
186
|
+
bokeh_models.CustomJS.new(
|
187
|
+
code: <<~JS
|
188
|
+
window.graphManager.cbObj = cb_obj
|
189
|
+
window.graphManager.#{function_name}()
|
190
|
+
JS
|
191
|
+
)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pycall"
|
4
|
+
|
5
|
+
module ErdMap
|
6
|
+
class PyCallModules
|
7
|
+
def initialize
|
8
|
+
@nx = PyCall.import_module("networkx")
|
9
|
+
@bokeh_io = PyCall.import_module("bokeh.io")
|
10
|
+
@bokeh_models = PyCall.import_module("bokeh.models")
|
11
|
+
@bokeh_plotting = PyCall.import_module("bokeh.plotting")
|
12
|
+
@networkx_community = PyCall.import_module("networkx.algorithms.community")
|
13
|
+
end
|
14
|
+
|
15
|
+
def imported_modules
|
16
|
+
{
|
17
|
+
nx: @nx,
|
18
|
+
bokeh_io: @bokeh_io,
|
19
|
+
bokeh_models: @bokeh_models,
|
20
|
+
bokeh_plotting: @bokeh_plotting,
|
21
|
+
networkx_community: @networkx_community,
|
22
|
+
}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/erd_map.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require "erd_map/version"
|
2
|
+
require "erd_map/engine"
|
3
|
+
require "erd_map/py_call_modules"
|
4
|
+
require "erd_map/graph"
|
5
|
+
require "erd_map/graph_renderer"
|
6
|
+
require "erd_map/map_builder"
|
7
|
+
require "erd_map/plot"
|
8
|
+
|
9
|
+
module ErdMap
|
10
|
+
class << self
|
11
|
+
def py_call_modules
|
12
|
+
@py_call_modules
|
13
|
+
end
|
14
|
+
|
15
|
+
def py_call_modules=(py_call_modules)
|
16
|
+
@py_call_modules = py_call_modules
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|