dbdoc_engine 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.
Files changed (198) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +331 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/builds/dbdoc_engine/application.css +5 -0
  5. data/app/assets/images/dbdoc_engine/arrowdown.svg +3 -0
  6. data/app/assets/images/dbdoc_engine/arrowhorizontal.svg +3 -0
  7. data/app/assets/images/dbdoc_engine/arrowleft.svg +3 -0
  8. data/app/assets/images/dbdoc_engine/changelog.svg +3 -0
  9. data/app/assets/images/dbdoc_engine/column_stats_dbdocs.svg +23 -0
  10. data/app/assets/images/dbdoc_engine/diagram.svg +3 -0
  11. data/app/assets/images/dbdoc_engine/double_arrow.svg +4 -0
  12. data/app/assets/images/dbdoc_engine/group_bu.svg +3 -0
  13. data/app/assets/images/dbdoc_engine/japan_circle.png +0 -0
  14. data/app/assets/images/dbdoc_engine/log_in_image.png +0 -0
  15. data/app/assets/images/dbdoc_engine/logo.svg +12 -0
  16. data/app/assets/images/dbdoc_engine/orange_changelog.svg +3 -0
  17. data/app/assets/images/dbdoc_engine/orange_fields.svg +23 -0
  18. data/app/assets/images/dbdoc_engine/orange_logo.svg +12 -0
  19. data/app/assets/images/dbdoc_engine/orange_table.svg +21 -0
  20. data/app/assets/images/dbdoc_engine/orange_updates.svg +43 -0
  21. data/app/assets/images/dbdoc_engine/orange_wiki.svg +3 -0
  22. data/app/assets/images/dbdoc_engine/search.svg +3 -0
  23. data/app/assets/images/dbdoc_engine/setting.svg +3 -0
  24. data/app/assets/images/dbdoc_engine/table_dbdocs.svg +21 -0
  25. data/app/assets/images/dbdoc_engine/uk_circle_transparent.png +0 -0
  26. data/app/assets/images/dbdoc_engine/update_stats_dbdocs.svg +43 -0
  27. data/app/assets/images/dbdoc_engine/wiki.svg +3 -0
  28. data/app/assets/stylesheets/dbdoc_engine/admin.css +176 -0
  29. data/app/assets/stylesheets/dbdoc_engine/admin_header.css +179 -0
  30. data/app/assets/stylesheets/dbdoc_engine/application.scss +1 -0
  31. data/app/assets/stylesheets/dbdoc_engine/changelog.css +173 -0
  32. data/app/assets/stylesheets/dbdoc_engine/dashboard.css +513 -0
  33. data/app/assets/stylesheets/dbdoc_engine/dbdoc_application.css +117 -0
  34. data/app/assets/stylesheets/dbdoc_engine/ecommerce.css +253 -0
  35. data/app/assets/stylesheets/dbdoc_engine/group_details.css +178 -0
  36. data/app/assets/stylesheets/dbdoc_engine/header.css +212 -0
  37. data/app/assets/stylesheets/dbdoc_engine/loading_spinner.css +127 -0
  38. data/app/assets/stylesheets/dbdoc_engine/login.css +213 -0
  39. data/app/assets/stylesheets/dbdoc_engine/schema_diagram.css +149 -0
  40. data/app/assets/stylesheets/dbdoc_engine/sidebar.css +296 -0
  41. data/app/assets/stylesheets/dbdoc_engine/table_details.css +417 -0
  42. data/app/controllers/dbdoc_engine/admin/base_controller.rb +23 -0
  43. data/app/controllers/dbdoc_engine/admin/dashboard_controller.rb +16 -0
  44. data/app/controllers/dbdoc_engine/admin/data_transfer_controller.rb +63 -0
  45. data/app/controllers/dbdoc_engine/admin/db_design_dynamic_tables_controller.rb +198 -0
  46. data/app/controllers/dbdoc_engine/admin/db_design_table_groups_controller.rb +107 -0
  47. data/app/controllers/dbdoc_engine/application_controller.rb +65 -0
  48. data/app/controllers/dbdoc_engine/concerns/internationalization.rb +57 -0
  49. data/app/controllers/dbdoc_engine/db_doc_sessions_controller.rb +33 -0
  50. data/app/controllers/dbdoc_engine/home_controller.rb +79 -0
  51. data/app/controllers/dbdoc_engine/schema_diagram_controller.rb +293 -0
  52. data/app/helper/dbdoc_engine/application_helper.rb +35 -0
  53. data/app/helpers/dbdoc_engine/application_helper.rb +4 -0
  54. data/app/helpers/dbdoc_engine/changelogs_helper.rb +27 -0
  55. data/app/helpers/dbdoc_engine/column_helper.rb +30 -0
  56. data/app/helpers/dbdoc_engine/db_design_dynamic_tables_helper.rb +15 -0
  57. data/app/helpers/dbdoc_engine/home_helper.rb +75 -0
  58. data/app/javascript/dbdoc_engine/application.js +12 -0
  59. data/app/javascript/dbdoc_engine/controllers/application.js +29 -0
  60. data/app/javascript/dbdoc_engine/controllers/auto_submit_controller.js +17 -0
  61. data/app/javascript/dbdoc_engine/controllers/chart_controller.js +58 -0
  62. data/app/javascript/dbdoc_engine/controllers/column-type_controller.js +149 -0
  63. data/app/javascript/dbdoc_engine/controllers/column_controller.js +362 -0
  64. data/app/javascript/dbdoc_engine/controllers/column_search_controller.js +42 -0
  65. data/app/javascript/dbdoc_engine/controllers/dbdoc_accordion_controller.js +42 -0
  66. data/app/javascript/dbdoc_engine/controllers/ecommerce_controller.js +73 -0
  67. data/app/javascript/dbdoc_engine/controllers/group_details_controller.js +88 -0
  68. data/app/javascript/dbdoc_engine/controllers/import_export_controller.js +200 -0
  69. data/app/javascript/dbdoc_engine/controllers/index.js +9 -0
  70. data/app/javascript/dbdoc_engine/controllers/language_controller.js +100 -0
  71. data/app/javascript/dbdoc_engine/controllers/loading_spinner_controller.js +48 -0
  72. data/app/javascript/dbdoc_engine/controllers/login_controller.js +75 -0
  73. data/app/javascript/dbdoc_engine/controllers/notification_controller.js +15 -0
  74. data/app/javascript/dbdoc_engine/controllers/schema_diagram_controller.js +1129 -0
  75. data/app/javascript/dbdoc_engine/controllers/select2_controller.js +67 -0
  76. data/app/javascript/dbdoc_engine/controllers/sidebar_controller.js +943 -0
  77. data/app/javascript/dbdoc_engine/controllers/table_details_controller.js +245 -0
  78. data/app/javascript/dbdoc_engine/controllers/table_group_validation_controller.js +148 -0
  79. data/app/javascript/dbdoc_engine/controllers/table_validation_controller.js +423 -0
  80. data/app/jobs/dbdoc_engine/application_job.rb +4 -0
  81. data/app/mailers/dbdoc_engine/application_mailer.rb +6 -0
  82. data/app/models/dbdoc_engine/application_record.rb +6 -0
  83. data/app/models/dbdoc_engine/concerns/soft_deletable.rb +30 -0
  84. data/app/models/dbdoc_engine/db_design_changelog.rb +44 -0
  85. data/app/models/dbdoc_engine/db_design_dynamic_column.rb +211 -0
  86. data/app/models/dbdoc_engine/db_design_dynamic_table.rb +124 -0
  87. data/app/models/dbdoc_engine/db_design_table_group.rb +88 -0
  88. data/app/models/dbdoc_engine/user.rb +21 -0
  89. data/app/queries/dbdoc_engine/admin_dashboard_queries.rb +71 -0
  90. data/app/queries/dbdoc_engine/db_design_changelog_queries.rb +68 -0
  91. data/app/queries/dbdoc_engine/db_design_dynamic_column_queries.rb +37 -0
  92. data/app/queries/dbdoc_engine/db_design_dynamic_table_commands.rb +106 -0
  93. data/app/queries/dbdoc_engine/db_design_dynamic_table_queries.rb +194 -0
  94. data/app/queries/dbdoc_engine/db_design_table_group_queries.rb +154 -0
  95. data/app/services/dbdoc_engine/db_design_dynamic_table_export_service.rb +38 -0
  96. data/app/services/dbdoc_engine/db_design_dynamic_table_handler_service.rb +49 -0
  97. data/app/services/dbdoc_engine/db_design_dynamic_tables_service.rb +21 -0
  98. data/app/services/dbdoc_engine/error_handler_service.rb +43 -0
  99. data/app/services/dbdoc_engine/schema_rb_import_service.rb +194 -0
  100. data/app/services/dbdoc_engine/schema_rb_parser_service.rb +339 -0
  101. data/app/services/dbdoc_engine/table_filter_service.rb +35 -0
  102. data/app/services/dbdoc_engine/table_groups_service.rb +199 -0
  103. data/app/services/dbdoc_engine/table_management_service.rb +192 -0
  104. data/app/views/dbdoc_engine/admin/dashboard/_action_badge.html.erb +11 -0
  105. data/app/views/dbdoc_engine/admin/dashboard/_changelog_rows.html.erb +22 -0
  106. data/app/views/dbdoc_engine/admin/dashboard/_changelog_table_headers.html.erb +8 -0
  107. data/app/views/dbdoc_engine/admin/dashboard/_filter_fields.html.erb +43 -0
  108. data/app/views/dbdoc_engine/admin/dashboard/index.html.erb +159 -0
  109. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/_column_fields.html.erb +225 -0
  110. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/_deleted_table_index.html.erb +110 -0
  111. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/_foreign_key_fields.html.erb +51 -0
  112. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/_form.html.erb +75 -0
  113. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/_recent_activity.html.erb +39 -0
  114. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/_table_columns.html.erb +127 -0
  115. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/_table_index.html.erb +109 -0
  116. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/_table_information.html.erb +99 -0
  117. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/deleted_tables.html.erb +95 -0
  118. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/edit.html.erb +23 -0
  119. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/export_all_to_excel.xlsx.axlsx +240 -0
  120. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/export_to_excel.xlsx.axlsx +135 -0
  121. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/index.html.erb +109 -0
  122. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/new.html.erb +25 -0
  123. data/app/views/dbdoc_engine/admin/db_design_dynamic_tables/show_table_info.html.erb +125 -0
  124. data/app/views/dbdoc_engine/admin/db_design_table_groups/_deleted_table_groups_list.html.erb +75 -0
  125. data/app/views/dbdoc_engine/admin/db_design_table_groups/_form.html.erb +88 -0
  126. data/app/views/dbdoc_engine/admin/db_design_table_groups/_table_groups_list.html.erb +82 -0
  127. data/app/views/dbdoc_engine/admin/db_design_table_groups/deleted_groups.html.erb +60 -0
  128. data/app/views/dbdoc_engine/admin/db_design_table_groups/edit.html.erb +25 -0
  129. data/app/views/dbdoc_engine/admin/db_design_table_groups/index.html.erb +85 -0
  130. data/app/views/dbdoc_engine/admin/db_design_table_groups/new.html.erb +26 -0
  131. data/app/views/dbdoc_engine/db_doc_sessions/new.html.erb +59 -0
  132. data/app/views/dbdoc_engine/home/changelog_details.html.erb +80 -0
  133. data/app/views/dbdoc_engine/home/changelogs.html.erb +20 -0
  134. data/app/views/dbdoc_engine/home/group_details.html.erb +94 -0
  135. data/app/views/dbdoc_engine/home/index.html.erb +11 -0
  136. data/app/views/dbdoc_engine/home/partials/_action_badge.html.erb +11 -0
  137. data/app/views/dbdoc_engine/home/partials/_breadcrumb_navigation.html.erb +30 -0
  138. data/app/views/dbdoc_engine/home/partials/_changelog_rows.html.erb +35 -0
  139. data/app/views/dbdoc_engine/home/partials/_changelog_table_headers.html.erb +16 -0
  140. data/app/views/dbdoc_engine/home/partials/_column_headers.html.erb +23 -0
  141. data/app/views/dbdoc_engine/home/partials/_column_row.html.erb +157 -0
  142. data/app/views/dbdoc_engine/home/partials/_filter_form.html.erb +47 -0
  143. data/app/views/dbdoc_engine/home/partials/_group_section.html.erb +84 -0
  144. data/app/views/dbdoc_engine/home/partials/_pagination.html.erb +5 -0
  145. data/app/views/dbdoc_engine/home/partials/_stats_container.html.erb +46 -0
  146. data/app/views/dbdoc_engine/home/partials/_table_groups.html.erb +7 -0
  147. data/app/views/dbdoc_engine/home/partials/_table_information_section.html.erb +50 -0
  148. data/app/views/dbdoc_engine/home/partials/_table_section.html.erb +48 -0
  149. data/app/views/dbdoc_engine/home/table_details.html.erb +9 -0
  150. data/app/views/dbdoc_engine/schema_diagram/index.html.erb +102 -0
  151. data/app/views/dbdoc_engine/shared/_admin_header.html.erb +78 -0
  152. data/app/views/dbdoc_engine/shared/_header.html.erb +94 -0
  153. data/app/views/dbdoc_engine/shared/_js_translations.html.erb +3 -0
  154. data/app/views/dbdoc_engine/shared/_language_button.html.erb +14 -0
  155. data/app/views/dbdoc_engine/shared/_sidebar.html.erb +128 -0
  156. data/app/views/kaminari/dbdoc_engine/_first_page.html.erb +3 -0
  157. data/app/views/kaminari/dbdoc_engine/_gap.html.erb +3 -0
  158. data/app/views/kaminari/dbdoc_engine/_last_page.html.erb +3 -0
  159. data/app/views/kaminari/dbdoc_engine/_next_page.html.erb +3 -0
  160. data/app/views/kaminari/dbdoc_engine/_page.html.erb +9 -0
  161. data/app/views/kaminari/dbdoc_engine/_paginator.html.erb +17 -0
  162. data/app/views/kaminari/dbdoc_engine/_prev_page.html.erb +3 -0
  163. data/app/views/layouts/dbdoc_engine/application.html.erb +107 -0
  164. data/app/views/layouts/dbdoc_engine/header.html.erb +108 -0
  165. data/config/importmap.rb +11 -0
  166. data/config/locales/en.yml +307 -0
  167. data/config/locales/ja.yml +306 -0
  168. data/config/routes.rb +73 -0
  169. data/db/migrate/rails7/20250227060610_create_db_design_table_groups.rb +15 -0
  170. data/db/migrate/rails7/20250227094626_create_db_design_dynamic_tables.rb +19 -0
  171. data/db/migrate/rails7/20250228022732_create_db_design_dynamic_columns.rb +34 -0
  172. data/db/migrate/rails7/20250401051453_create_db_design_changelogs.rb +26 -0
  173. data/db/migrate/rails7/20250411040822_create_users.rb +14 -0
  174. data/db/migrate/rails7/20250421080851_add_missing_indexes_to_dbdoc_tables.rb +23 -0
  175. data/db/migrate/rails8/20250227060610_create_db_design_table_groups.rb +15 -0
  176. data/db/migrate/rails8/20250227094626_create_db_design_dynamic_tables.rb +19 -0
  177. data/db/migrate/rails8/20250228022732_create_db_design_dynamic_columns.rb +34 -0
  178. data/db/migrate/rails8/20250401051453_create_db_design_changelogs.rb +26 -0
  179. data/db/migrate/rails8/20250411040822_create_users.rb +14 -0
  180. data/db/migrate/rails8/20250421080851_add_missing_indexes_to_dbdoc_tables.rb +23 -0
  181. data/db/seeds.rb +28 -0
  182. data/lib/dbdoc_engine/engine.rb +57 -0
  183. data/lib/dbdoc_engine/version.rb +3 -0
  184. data/lib/dbdoc_engine.rb +9 -0
  185. data/lib/generators/dbdoc_engine/install/install_generator.rb +245 -0
  186. data/lib/generators/dbdoc_engine/uninstall/uninstall_generator.rb +196 -0
  187. data/lib/tasks/dbdoc_engine_tasks.rake +44 -0
  188. data/public/dbdoc_engine_assets/images/camel_chess_head.png +0 -0
  189. data/public/dbdoc_engine_assets/images/dblogo.svg +4 -0
  190. data/public/dbdoc_engine_assets/images/japan_circle.png +0 -0
  191. data/public/dbdoc_engine_assets/images/king_chess_head.png +0 -0
  192. data/public/dbdoc_engine_assets/images/login-bg.svg +44 -0
  193. data/public/dbdoc_engine_assets/images/logo.png +0 -0
  194. data/public/dbdoc_engine_assets/images/logo.svg +12 -0
  195. data/public/dbdoc_engine_assets/images/queen_chess_head.png +0 -0
  196. data/public/dbdoc_engine_assets/images/soldier_chess_headd.png +0 -0
  197. data/public/dbdoc_engine_assets/images/uk_circle_transparent.png +0 -0
  198. metadata +415 -0
@@ -0,0 +1,1129 @@
1
+ // app/javascript/controllers/schema_diagram_controller.js
2
+ import { Controller } from "@hotwired/stimulus"
3
+
4
+ export default class extends Controller {
5
+ static targets = [
6
+ "diagram", "groupSelect", "zoomIn", "zoomOut", "resetView"
7
+ ]
8
+
9
+ connect() {
10
+ this.zoom = 1
11
+ this.pan = { x: 0, y: 0 }
12
+ this.isDragging = false
13
+ this.lastMouse = { x: 0, y: 0 }
14
+ this.columnData = {}
15
+ this.relationshipData = {}
16
+ this.groupColors = {}
17
+ this.originalViewBox = null
18
+
19
+ this.renderDiagram()
20
+
21
+ // Event listeners for controls
22
+ if (this.hasGroupSelectTarget) {
23
+ this.groupSelectTarget.addEventListener("change", () => this.renderDiagram())
24
+ }
25
+
26
+ if (this.hasZoomInTarget) {
27
+ this.zoomInTarget.addEventListener("click", (e) => {
28
+ e.preventDefault()
29
+ this.zoomDiagram(0.2)
30
+ })
31
+ }
32
+
33
+ if (this.hasZoomOutTarget) {
34
+ this.zoomOutTarget.addEventListener("click", (e) => {
35
+ e.preventDefault()
36
+ this.zoomDiagram(-0.2)
37
+ })
38
+ }
39
+
40
+ if (this.hasResetViewTarget) {
41
+ this.resetViewTarget.addEventListener("click", (e) => {
42
+ e.preventDefault()
43
+ this.resetView()
44
+ })
45
+ }
46
+
47
+ // Pan events
48
+ this.diagramTarget.addEventListener("mousedown", this.startPan.bind(this))
49
+ document.addEventListener("mousemove", this.handlePan.bind(this))
50
+ document.addEventListener("mouseup", this.endPan.bind(this))
51
+ this.diagramTarget.addEventListener("contextmenu", (e) => {
52
+ if (this.isDragging) e.preventDefault()
53
+ })
54
+ this.diagramTarget.style.cursor = 'grab'
55
+
56
+ this.createTooltip()
57
+ }
58
+
59
+ createTooltip() {
60
+ this.tooltip = document.createElement('div')
61
+ this.tooltip.id = 'column-tooltip'
62
+ this.tooltip.className = 'position-absolute p-2'
63
+ this.tooltip.style.cssText = `
64
+ display: none;
65
+ z-index: 1000;
66
+ max-width: 300px;
67
+ font-size: 12px;
68
+ line-height: 1.4;
69
+ pointer-events: none;
70
+ background: rgba(0, 0, 0, 0.9);
71
+ color: white;
72
+ border-radius: 4px;
73
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
74
+ `
75
+ document.body.appendChild(this.tooltip)
76
+ }
77
+
78
+ async renderDiagram() {
79
+ const groupId = this.hasGroupSelectTarget ? this.groupSelectTarget.value : "all"
80
+ this.diagramTarget.innerHTML = '<div class="d-flex justify-content-center align-items-center h-100"><span class="text-muted fst-italic">Generating diagram...</span></div>'
81
+
82
+ try {
83
+ const url = '/dbdoc/schema_diagram/data?group_id=' + groupId
84
+ const response = await fetch(url, { headers: { 'Accept': 'application/json' } })
85
+ if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`)
86
+ const data = await response.json()
87
+ this.diagramTarget.innerHTML = data.diagram_svg
88
+ this.initializeTableHoverListeners()
89
+ this.columnData = data.column_data
90
+ this.relationshipData = data.relationship_data || {}
91
+ this.groupColors = data.group_colors || {}
92
+
93
+ // Wait a bit for SVG to render, then initialize listeners
94
+ setTimeout(() => {
95
+ this.addTooltipListeners()
96
+ this.initializeEdgeListeners()
97
+ this.addHighlightStyles()
98
+ }, 100)
99
+ } catch (error) {
100
+ this.diagramTarget.innerHTML = `<div class="alert alert-danger">Error loading diagram: ${error.message}</div>`
101
+ console.error(error)
102
+ }
103
+ }
104
+
105
+ addHighlightStyles() {
106
+ // Add CSS styles for highlighting if not already present
107
+ if (!document.getElementById('diagram-highlight-styles')) {
108
+ const style = document.createElement('style')
109
+ style.id = 'diagram-highlight-styles'
110
+ style.textContent = `
111
+ .edge-highlighted path {
112
+ stroke-dasharray: 8, 4;
113
+ animation: dash-flow 2s linear infinite;
114
+ filter: drop-shadow(0 0 6px rgba(52, 152, 219, 0.8));
115
+ stroke-width: 3px !important;
116
+ }
117
+
118
+ @keyframes dash-flow {
119
+ 0% { stroke-dashoffset: 0; }
120
+ 100% { stroke-dashoffset: 24; }
121
+ }
122
+
123
+ /* Keep other styles from second version */
124
+ .edge-highlighted polygon {
125
+ fill: var(--highlight-color) !important;
126
+ stroke: var(--highlight-color) !important;
127
+ }
128
+
129
+ /* Simple column highlighting - no shadows or complex effects */
130
+ .column-highlighted {
131
+ fill: var(--highlight-color-bg) !important;
132
+ stroke: var(--highlight-color) !important;
133
+ stroke-width: 2px !important;
134
+ transition: all 0.2s ease !important;
135
+ }
136
+
137
+ /* Simple text highlighting - no shadows or pulsing */
138
+ .column-text-highlighted {
139
+ fill: var(--highlight-color) !important;
140
+ font-weight: bold !important;
141
+ transition: all 0.2s ease !important;
142
+ }
143
+ `
144
+ document.head.appendChild(style)
145
+ }
146
+ }
147
+
148
+ initializeEdgeListeners() {
149
+ const svg = this.diagramTarget.querySelector('svg')
150
+ if (!svg) return
151
+
152
+ // Clear any existing listeners to prevent duplicates
153
+ this.clearExistingEdgeListeners(svg)
154
+
155
+ // Find and attach edge listeners with improved precision
156
+ this.findAndAttachEdgeListeners(svg)
157
+ }
158
+
159
+ clearExistingEdgeListeners(svg) {
160
+ // Remove existing edge event listeners
161
+ const existingEdges = svg.querySelectorAll('[data-edge-id]')
162
+ existingEdges.forEach(edge => {
163
+ edge.removeAttribute('data-edge-id')
164
+ edge.style.cursor = ''
165
+ // Clone and replace to remove all event listeners
166
+ const newEdge = edge.cloneNode(true)
167
+ edge.parentNode.replaceChild(newEdge, edge)
168
+ })
169
+ }
170
+
171
+ findAndAttachEdgeListeners(svg) {
172
+ // Strategy 1: Look for elements with edge IDs from our relationship data
173
+ Object.entries(this.relationshipData).forEach(([edgeId, relationship]) => {
174
+ const edgeElement = svg.querySelector(`#${CSS.escape(edgeId)}`)
175
+ if (edgeElement) {
176
+ this.attachPreciseEdgeListeners(edgeElement, edgeId, relationship)
177
+ return
178
+ }
179
+
180
+ // Strategy 2: Try to find edge by relationship data
181
+ const foundEdge = this.findEdgeByRelationship(svg, relationship, edgeId)
182
+ if (foundEdge) {
183
+ this.attachPreciseEdgeListeners(foundEdge, edgeId, relationship)
184
+ }
185
+ })
186
+ }
187
+
188
+ findEdgeByRelationship(svg, relationship, edgeId) {
189
+ const { source_table, target_table } = relationship
190
+
191
+ // Look for edge groups that might represent this relationship
192
+ const edgeGroups = svg.querySelectorAll('g.edge, g[id*="edge"]')
193
+
194
+ for (const edgeGroup of edgeGroups) {
195
+ // Check if this edge connects our specific tables
196
+ if (this.edgeConnectsTables(edgeGroup, source_table, target_table)) {
197
+ return edgeGroup
198
+ }
199
+ }
200
+
201
+ // Fallback: look for individual path/polygon elements
202
+ const pathElements = svg.querySelectorAll('path, polygon')
203
+ for (const pathElement of pathElements) {
204
+ if (this.isRelationshipPath(pathElement, source_table, target_table)) {
205
+ return pathElement
206
+ }
207
+ }
208
+
209
+ return null
210
+ }
211
+
212
+ edgeConnectsTables(edgeGroup, sourceTable, targetTable) {
213
+ // Check title element for table names
214
+ const title = edgeGroup.querySelector('title')
215
+ if (title) {
216
+ const titleText = title.textContent.trim()
217
+ return titleText.includes(sourceTable) && titleText.includes(targetTable)
218
+ }
219
+
220
+ // Check if edge is geometrically positioned between the two tables
221
+ return this.isEdgeGeometricallyBetweenTables(edgeGroup, sourceTable, targetTable)
222
+ }
223
+
224
+ isEdgeGeometricallyBetweenTables(edgeElement, sourceTable, targetTable) {
225
+ const svg = this.diagramTarget.querySelector('svg')
226
+ if (!svg) return false
227
+
228
+ try {
229
+ const edgeBBox = edgeElement.getBBox()
230
+ const sourceTableEl = svg.querySelector(`#${CSS.escape(sourceTable)}`)
231
+ const targetTableEl = svg.querySelector(`#${CSS.escape(targetTable)}`)
232
+
233
+ if (!sourceTableEl || !targetTableEl) return false
234
+
235
+ const sourceBBox = sourceTableEl.getBBox()
236
+ const targetBBox = targetTableEl.getBBox()
237
+
238
+ // Check if edge is positioned between the two tables
239
+ const edgeCenterX = edgeBBox.x + edgeBBox.width / 2
240
+ const edgeCenterY = edgeBBox.y + edgeBBox.height / 2
241
+
242
+ const sourceCenterX = sourceBBox.x + sourceBBox.width / 2
243
+ const sourceCenterY = sourceBBox.y + sourceBBox.height / 2
244
+
245
+ const targetCenterX = targetBBox.x + targetBBox.width / 2
246
+ const targetCenterY = targetBBox.y + targetBBox.height / 2
247
+
248
+ // Calculate if edge is roughly on the line between source and target
249
+ const distanceToLine = this.pointToLineDistance(
250
+ edgeCenterX, edgeCenterY,
251
+ sourceCenterX, sourceCenterY,
252
+ targetCenterX, targetCenterY
253
+ )
254
+
255
+ // Allow some tolerance for curved lines
256
+ return distanceToLine < 100
257
+ } catch (e) {
258
+ return false
259
+ }
260
+ }
261
+
262
+ pointToLineDistance(px, py, x1, y1, x2, y2) {
263
+ const A = px - x1
264
+ const B = py - y1
265
+ const C = x2 - x1
266
+ const D = y2 - y1
267
+
268
+ const dot = A * C + B * D
269
+ const lenSq = C * C + D * D
270
+ let param = -1
271
+ if (lenSq !== 0) {
272
+ param = dot / lenSq
273
+ }
274
+
275
+ let xx, yy
276
+ if (param < 0) {
277
+ xx = x1
278
+ yy = y1
279
+ } else if (param > 1) {
280
+ xx = x2
281
+ yy = y2
282
+ } else {
283
+ xx = x1 + param * C
284
+ yy = y1 + param * D
285
+ }
286
+
287
+ const dx = px - xx
288
+ const dy = py - yy
289
+ return Math.sqrt(dx * dx + dy * dy)
290
+ }
291
+
292
+ attachPreciseEdgeListeners(element, edgeId, relationship) {
293
+ // Clear any existing data
294
+ element.removeAttribute('data-edge-id')
295
+
296
+ element.style.cursor = 'pointer'
297
+ element.setAttribute('data-edge-id', edgeId)
298
+ element.setAttribute('data-source-table', relationship.source_table)
299
+ element.setAttribute('data-target-table', relationship.target_table)
300
+ element.setAttribute('data-source-column', relationship.source_column)
301
+ element.setAttribute('data-target-column', relationship.target_column)
302
+
303
+ // Use event delegation with precise event handling
304
+ element.addEventListener('mouseenter', (e) => this.handlePreciseEdgeHover(e, edgeId, relationship))
305
+ element.addEventListener('mouseleave', (e) => this.handlePreciseEdgeLeave(e, edgeId))
306
+ element.addEventListener('click', (e) => e.stopPropagation()) // Prevent interference with pan
307
+ }
308
+
309
+ handlePreciseEdgeHover(event, edgeId, relationship) {
310
+ // Stop event propagation to prevent multiple edges from triggering
311
+ event.stopPropagation()
312
+
313
+ const edge = event.currentTarget
314
+
315
+ // Double-check that we're hovering the correct edge
316
+ const hoveredEdgeId = edge.getAttribute('data-edge-id')
317
+ if (hoveredEdgeId !== edgeId) {
318
+ return
319
+ }
320
+
321
+ // Clear any existing highlights first
322
+ this.clearAllHighlights()
323
+
324
+ // Get the group color for this relationship
325
+ const highlightColor = this.getRelationshipGroupColor(relationship)
326
+ const highlightColorBg = this.hexToRgba(highlightColor, 0.2)
327
+
328
+ // Set CSS custom properties for the highlight color
329
+ document.documentElement.style.setProperty('--highlight-color', highlightColor)
330
+ document.documentElement.style.setProperty('--highlight-color-bg', highlightColorBg)
331
+
332
+ // Highlight the specific edge with group color
333
+ edge.classList.add('edge-highlighted')
334
+
335
+ // Highlight only the specific columns for this relationship with group color
336
+ this.highlightSpecificRelationship(relationship, highlightColor)
337
+
338
+ // Store the currently highlighted edge
339
+ this.currentlyHighlightedEdge = edgeId
340
+ }
341
+
342
+ handlePreciseEdgeLeave(event, edgeId) {
343
+ event.stopPropagation()
344
+
345
+ const edge = event.currentTarget
346
+
347
+ // Only clear if we're leaving the currently highlighted edge
348
+ if (this.currentlyHighlightedEdge === edgeId) {
349
+ edge.classList.remove('edge-highlighted')
350
+ this.clearAllHighlights()
351
+ this.currentlyHighlightedEdge = null
352
+ }
353
+ }
354
+
355
+ getRelationshipGroupColor(relationship) {
356
+ // Get the group color from the target table (or source table as fallback)
357
+ const targetTableColor = this.groupColors[relationship.target_table]
358
+ const sourceTableColor = this.groupColors[relationship.source_table]
359
+
360
+ // Return the target table color, or source table color, or default color
361
+ return targetTableColor || sourceTableColor || '#ff6b35'
362
+ }
363
+
364
+ hexToRgba(hex, alpha) {
365
+ // Convert hex color to rgba
366
+ const r = parseInt(hex.slice(1, 3), 16)
367
+ const g = parseInt(hex.slice(3, 5), 16)
368
+ const b = parseInt(hex.slice(5, 7), 16)
369
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
370
+ }
371
+
372
+ highlightSpecificRelationship(relationship, color) {
373
+ const { source_table, source_column, target_table, target_column } = relationship
374
+
375
+ // Set the color for highlighting
376
+ document.documentElement.style.setProperty('--highlight-color', color)
377
+ document.documentElement.style.setProperty('--highlight-color-bg', this.hexToRgba(color, 0.2))
378
+
379
+ // Only highlight the exact columns involved in this specific relationship
380
+ this.highlightExactColumn(source_table, source_column, color)
381
+ this.highlightExactColumn(target_table, target_column, color)
382
+ }
383
+
384
+ highlightExactColumn(tableName, columnName, color) {
385
+ const svg = this.diagramTarget.querySelector('svg')
386
+ if (!svg) return
387
+
388
+ // Find the specific table element
389
+ const tableElement = svg.querySelector(`#${CSS.escape(tableName)}`)
390
+ if (!tableElement) return
391
+
392
+ // Get column data for validation
393
+ const tableColumnData = this.columnData[tableName]
394
+ if (!tableColumnData || !tableColumnData[columnName]) return
395
+
396
+ // Find and highlight only the exact column
397
+ const textElements = tableElement.querySelectorAll('text')
398
+ textElements.forEach(textEl => {
399
+ const textContent = textEl.textContent.trim()
400
+
401
+ // Use exact matching to prevent cross-contamination
402
+ if (this.isExactColumnMatch(textContent, columnName, Object.keys(tableColumnData))) {
403
+ textEl.classList.add('column-text-highlighted')
404
+
405
+ // Highlight associated cells
406
+ this.highlightAssociatedCells(textEl, tableElement, color)
407
+ }
408
+ })
409
+
410
+ // Also handle HTML table cells
411
+ const htmlCells = tableElement.querySelectorAll('td')
412
+ htmlCells.forEach(cell => {
413
+ if (this.isExactColumnMatch(cell.textContent.trim(), columnName, Object.keys(tableColumnData))) {
414
+ cell.classList.add('column-highlighted')
415
+ }
416
+ })
417
+ }
418
+
419
+ highlightAssociatedCells(textElement, tableElement, color) {
420
+ try {
421
+ const textBBox = textElement.getBBox()
422
+ const cells = tableElement.querySelectorAll('rect, polygon')
423
+
424
+ cells.forEach(cell => {
425
+ try {
426
+ const cellBBox = cell.getBBox()
427
+ // More precise vertical alignment check
428
+ const verticalOverlap = Math.abs(cellBBox.y - textBBox.y) < 15 &&
429
+ Math.abs((cellBBox.y + cellBBox.height) - (textBBox.y + textBBox.height)) < 15
430
+
431
+ if (verticalOverlap) {
432
+ cell.classList.add('column-highlighted')
433
+ }
434
+ } catch (e) {
435
+ // Skip problematic elements
436
+ }
437
+ })
438
+ } catch (e) {
439
+ // Skip if bounding box calculation fails
440
+ }
441
+ }
442
+
443
+ clearAllHighlights() {
444
+ const svg = this.diagramTarget.querySelector('svg')
445
+ if (!svg) return
446
+
447
+ // Remove all highlight classes
448
+ svg.querySelectorAll('.edge-highlighted').forEach(el => {
449
+ el.classList.remove('edge-highlighted')
450
+ })
451
+
452
+ svg.querySelectorAll('.animated').forEach(el => {
453
+ el.classList.remove('animated')
454
+ })
455
+
456
+ svg.querySelectorAll('.column-highlighted').forEach(el => {
457
+ el.classList.remove('column-highlighted')
458
+ })
459
+
460
+ svg.querySelectorAll('.column-text-highlighted').forEach(el => {
461
+ el.classList.remove('column-text-highlighted')
462
+ })
463
+
464
+ // Clear CSS custom properties
465
+ document.documentElement.style.removeProperty('--highlight-color')
466
+ document.documentElement.style.removeProperty('--highlight-color-bg')
467
+ }
468
+
469
+ isExactColumnMatch(textContent, targetColumnName, allColumns) {
470
+ // Remove common symbols and attributes from the text
471
+ const cleanText = textContent.replace(/[🔑🔗]/g, '').replace(/\s+NN\s*$/g, '').trim()
472
+
473
+ // Method 1: Exact match
474
+ if (cleanText === targetColumnName) {
475
+ return true
476
+ }
477
+
478
+ // Method 2: Check if the text starts with the column name followed by space or symbol
479
+ const columnWithSpace = targetColumnName + ' '
480
+ const columnWithSymbol = targetColumnName + ' 🔑'
481
+ const columnWithFK = targetColumnName + ' 🔗'
482
+ const columnWithNN = targetColumnName + ' NN'
483
+
484
+ if (textContent.startsWith(columnWithSpace) ||
485
+ textContent.startsWith(columnWithSymbol) ||
486
+ textContent.startsWith(columnWithFK) ||
487
+ textContent.startsWith(columnWithNN)) {
488
+ return true
489
+ }
490
+
491
+ // Method 3: Word boundary check
492
+ const wordBoundaryRegex = new RegExp(`\\b${this.escapeRegExp(targetColumnName)}\\b`)
493
+ if (wordBoundaryRegex.test(cleanText)) {
494
+ const isSubstringOfLongerColumn = allColumns.some(col =>
495
+ col !== targetColumnName &&
496
+ col.includes(targetColumnName) &&
497
+ cleanText.includes(col)
498
+ )
499
+
500
+ if (!isSubstringOfLongerColumn) {
501
+ return true
502
+ }
503
+ }
504
+
505
+ return false
506
+ }
507
+
508
+ escapeRegExp(string) {
509
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
510
+ }
511
+
512
+ clearColumnHighlights() {
513
+ const svg = this.diagramTarget.querySelector('svg')
514
+ if (!svg) return
515
+
516
+ // Remove column highlight classes
517
+ svg.querySelectorAll('.column-highlighted').forEach(el => {
518
+ el.classList.remove('column-highlighted')
519
+ })
520
+
521
+ svg.querySelectorAll('.column-text-highlighted').forEach(el => {
522
+ el.classList.remove('column-text-highlighted')
523
+ })
524
+ }
525
+
526
+ calculateDistance(bbox1, bbox2) {
527
+ const centerX1 = bbox1.x + bbox1.width / 2
528
+ const centerY1 = bbox1.y + bbox1.height / 2
529
+ const centerX2 = bbox2.x + bbox2.width / 2
530
+ const centerY2 = bbox2.y + bbox2.height / 2
531
+
532
+ return Math.sqrt(Math.pow(centerX2 - centerX1, 2) + Math.pow(centerY2 - centerY1, 2))
533
+ }
534
+
535
+ // IMPROVED TOOLTIP LISTENERS - This is the key fix for your issue
536
+ addTooltipListeners() {
537
+ const svg = this.diagramTarget.querySelector('svg');
538
+ if (!svg) return;
539
+
540
+ // Clear any existing tooltip listeners
541
+ this.clearExistingTooltipListeners(svg);
542
+
543
+ // Method 1: Try direct table-based approach first
544
+ this.addDirectTableTooltips(svg);
545
+
546
+ // Method 2: Fallback to proximity-based approach
547
+ this.addProximityBasedTooltips(svg);
548
+ }
549
+
550
+ clearExistingTooltipListeners(svg) {
551
+ // Remove existing tooltip attributes and listeners
552
+ const existingTooltipElements = svg.querySelectorAll('[data-tooltip-attached]');
553
+ existingTooltipElements.forEach(el => {
554
+ el.removeAttribute('data-tooltip-attached');
555
+ el.removeAttribute('data-table');
556
+ el.removeAttribute('data-column');
557
+ el.style.cursor = '';
558
+ // Clone and replace to remove event listeners
559
+ const newEl = el.cloneNode(true);
560
+ el.parentNode.replaceChild(newEl, el);
561
+ });
562
+ }
563
+
564
+ addDirectTableTooltips(svg) {
565
+ // For each table in our column data
566
+ Object.keys(this.columnData).forEach(tableName => {
567
+ const tableElement = svg.querySelector(`#${CSS.escape(tableName)}`);
568
+ if (!tableElement) return;
569
+
570
+ const tableColumnData = this.columnData[tableName];
571
+ const columnNames = Object.keys(tableColumnData);
572
+
573
+ // Find all text elements within this table
574
+ const textElements = tableElement.querySelectorAll('text');
575
+
576
+ textElements.forEach(textEl => {
577
+ const textContent = textEl.textContent.trim();
578
+
579
+ // Try to match this text to a column name
580
+ const matchedColumn = this.findBestColumnMatch(textContent, columnNames);
581
+
582
+ if (matchedColumn && !textEl.hasAttribute('data-tooltip-attached')) {
583
+ this.attachTooltipToElement(textEl, tableName, matchedColumn);
584
+
585
+ // Also try to find associated visual elements (rect, td) for the same row
586
+ this.attachTooltipToAssociatedElements(textEl, tableElement, tableName, matchedColumn);
587
+ }
588
+ });
589
+
590
+ // Also check HTML table cells if present
591
+ const htmlCells = tableElement.querySelectorAll('td');
592
+ htmlCells.forEach(cell => {
593
+ const cellContent = cell.textContent.trim();
594
+ const matchedColumn = this.findBestColumnMatch(cellContent, columnNames);
595
+
596
+ if (matchedColumn && !cell.hasAttribute('data-tooltip-attached')) {
597
+ this.attachTooltipToElement(cell, tableName, matchedColumn);
598
+ }
599
+ });
600
+ });
601
+ }
602
+
603
+ addProximityBasedTooltips(svg) {
604
+ // This is a fallback method for elements that weren't caught by the direct method
605
+ const allTextElements = svg.querySelectorAll('text:not([data-tooltip-attached])');
606
+
607
+ allTextElements.forEach(textEl => {
608
+ const textContent = textEl.textContent.trim();
609
+
610
+ // Skip if this looks like a table name or other non-column content
611
+ if (Object.keys(this.columnData).includes(textContent)) return;
612
+ if (textContent.length === 0) return;
613
+
614
+ // Find the closest table and try to match column
615
+ const closestTable = this.findClosestTable(textEl, svg);
616
+ if (!closestTable) return;
617
+
618
+ const tableColumnData = this.columnData[closestTable];
619
+ if (!tableColumnData) return;
620
+
621
+ const columnNames = Object.keys(tableColumnData);
622
+ const matchedColumn = this.findBestColumnMatch(textContent, columnNames);
623
+
624
+ if (matchedColumn) {
625
+ this.attachTooltipToElement(textEl, closestTable, matchedColumn);
626
+ }
627
+ });
628
+ }
629
+
630
+ findBestColumnMatch(textContent, columnNames) {
631
+ // Clean the text content
632
+ const cleanText = textContent.replace(/[🔑🔗]/g, '').replace(/\s+NN\s*$/g, '').trim();
633
+
634
+ // Method 1: Exact match
635
+ if (columnNames.includes(cleanText)) {
636
+ return cleanText;
637
+ }
638
+
639
+ // Method 2: Text starts with column name
640
+ for (const columnName of columnNames) {
641
+ if (textContent.startsWith(columnName + ' ') ||
642
+ textContent.startsWith(columnName + ' 🔑') ||
643
+ textContent.startsWith(columnName + ' 🔗') ||
644
+ textContent.startsWith(columnName + ' NN')) {
645
+ return columnName;
646
+ }
647
+ }
648
+
649
+ // Method 3: Column name is at the beginning of cleaned text
650
+ for (const columnName of columnNames) {
651
+ if (cleanText.startsWith(columnName)) {
652
+ // Make sure it's not a substring of a longer column name
653
+ const isWholeWord = cleanText === columnName ||
654
+ cleanText.charAt(columnName.length).match(/\s/);
655
+ if (isWholeWord) {
656
+ return columnName;
657
+ }
658
+ }
659
+ }
660
+
661
+ // Method 4: Fuzzy matching for minor differences
662
+ for (const columnName of columnNames) {
663
+ if (this.isFuzzyMatch(cleanText, columnName)) {
664
+ return columnName;
665
+ }
666
+ }
667
+
668
+ return null;
669
+ }
670
+
671
+ isFuzzyMatch(text1, text2) {
672
+ // Simple fuzzy matching - you can make this more sophisticated
673
+ const text1Lower = text1.toLowerCase();
674
+ const text2Lower = text2.toLowerCase();
675
+
676
+ // Check if one contains the other
677
+ if (text1Lower.includes(text2Lower) || text2Lower.includes(text1Lower)) {
678
+ return true;
679
+ }
680
+
681
+ // Check for common variations (underscores vs spaces, etc.)
682
+ const normalized1 = text1Lower.replace(/[_\s-]/g, '');
683
+ const normalized2 = text2Lower.replace(/[_\s-]/g, '');
684
+
685
+ return normalized1 === normalized2;
686
+ }
687
+
688
+ findClosestTable(element, svg) {
689
+ try {
690
+ const elementRect = element.getBoundingClientRect();
691
+ const elementX = elementRect.left + elementRect.width / 2;
692
+ const elementY = elementRect.top + elementRect.height / 2;
693
+
694
+ let closestTable = null;
695
+ let closestDistance = Infinity;
696
+
697
+ Object.keys(this.columnData).forEach(tableName => {
698
+ const tableElement = svg.querySelector(`#${CSS.escape(tableName)}`);
699
+ if (!tableElement) return;
700
+
701
+ try {
702
+ const tableRect = tableElement.getBoundingClientRect();
703
+ const tableX = tableRect.left + tableRect.width / 2;
704
+ const tableY = tableRect.top + tableRect.height / 2;
705
+
706
+ const distance = Math.sqrt(
707
+ Math.pow(elementX - tableX, 2) + Math.pow(elementY - tableY, 2)
708
+ );
709
+
710
+ // Prefer elements that are below the table header (positive Y difference)
711
+ const isBelow = elementY > (tableRect.top + 20); // 20px buffer for header
712
+ const adjustedDistance = isBelow ? distance : distance * 2;
713
+
714
+ if (adjustedDistance < closestDistance) {
715
+ closestDistance = adjustedDistance;
716
+ closestTable = tableName;
717
+ }
718
+ } catch (e) {
719
+ // Skip tables that can't provide bounding rect
720
+ }
721
+ });
722
+
723
+ return closestTable;
724
+ } catch (e) {
725
+ return null;
726
+ }
727
+ }
728
+
729
+ attachTooltipToElement(element, tableName, columnName) {
730
+ element.style.cursor = 'pointer';
731
+ element.setAttribute('data-table', tableName);
732
+ element.setAttribute('data-column', columnName);
733
+ element.setAttribute('data-tooltip-attached', 'true');
734
+
735
+ element.addEventListener('mouseenter', (e) => this.showColumnTooltip(e, tableName, columnName));
736
+ element.addEventListener('mouseleave', () => this.hideColumnTooltip());
737
+ element.addEventListener('mousemove', (e) => this.updateColumnTooltipPosition(e));
738
+ }
739
+
740
+ attachTooltipToAssociatedElements(textElement, tableElement, tableName, columnName) {
741
+ try {
742
+ const textBBox = textElement.getBBox();
743
+
744
+ // Find rect/polygon elements that are visually aligned with this text
745
+ const visualElements = tableElement.querySelectorAll('rect, polygon');
746
+
747
+ visualElements.forEach(visualEl => {
748
+ try {
749
+ const visualBBox = visualEl.getBBox();
750
+
751
+ // Check if they're on the same row (vertical overlap)
752
+ const verticalOverlap = Math.abs(visualBBox.y - textBBox.y) < 20;
753
+
754
+ if (verticalOverlap && !visualEl.hasAttribute('data-tooltip-attached')) {
755
+ this.attachTooltipToElement(visualEl, tableName, columnName);
756
+ }
757
+ } catch (e) {
758
+ // Skip elements that can't provide bounding box
759
+ }
760
+ });
761
+ } catch (e) {
762
+ // Skip if bounding box calculation fails
763
+ }
764
+ }
765
+
766
+ showColumnTooltip(event, tableName, columnName) {
767
+ const columnInfo = this.columnData[tableName][columnName]
768
+ if (!columnInfo) return
769
+
770
+ const attributes = []
771
+ if (columnInfo.is_primary_key) attributes.push('Primary Key')
772
+ if (columnInfo.is_foreign_key) attributes.push('Foreign Key')
773
+ if (columnInfo.not_null) attributes.push('Not Null')
774
+ if (columnInfo.is_unique_key) attributes.push('Unique Key')
775
+ if (columnInfo.is_candidate_key) attributes.push('Candidate Key')
776
+ if (columnInfo.is_indexed) attributes.push('Indexed')
777
+
778
+ const tooltipContent = `
779
+ <div class="fw-bold mb-1">${columnInfo.name}</div>
780
+ <div class="mb-1"><strong>Type:</strong> ${columnInfo.data_type}</div>
781
+ ${attributes.length > 0 ? `<div class="mb-1"><strong>Attributes:</strong> ${attributes.join(', ')}</div>` : ''}
782
+ <div><strong>Description:</strong></div>
783
+ <div class="fst-italic">${columnInfo.description || 'No description available'}</div>
784
+ `
785
+
786
+ this.tooltip.innerHTML = tooltipContent
787
+ this.tooltip.style.display = 'block'
788
+ this.updateColumnTooltipPosition(event)
789
+ }
790
+
791
+ hideColumnTooltip() {
792
+ this.tooltip.style.display = 'none'
793
+ }
794
+
795
+ updateColumnTooltipPosition(event) {
796
+ const tooltipRect = this.tooltip.getBoundingClientRect()
797
+ const windowWidth = window.innerWidth
798
+ const windowHeight = window.innerHeight
799
+
800
+ let left = event.clientX + 10
801
+ let top = event.clientY + 10
802
+
803
+ if (left + tooltipRect.width > windowWidth) {
804
+ left = event.clientX - tooltipRect.width - 10
805
+ }
806
+
807
+ if (top + tooltipRect.height > windowHeight) {
808
+ top = event.clientY - tooltipRect.height - 10
809
+ }
810
+
811
+ this.tooltip.style.left = left + 'px'
812
+ this.tooltip.style.top = top + 'px'
813
+ }
814
+
815
+ groupTextElementsByTable(svg, tableNames) {
816
+ const textElementsByTable = {}
817
+
818
+ tableNames.forEach(tableName => {
819
+ textElementsByTable[tableName] = []
820
+ })
821
+
822
+ // Try to find elements by ID first
823
+ tableNames.forEach(tableName => {
824
+ const tableElement = svg.querySelector(`#${CSS.escape(tableName)}`)
825
+ if (tableElement) {
826
+ const textElements = tableElement.querySelectorAll('text')
827
+ textElements.forEach(textEl => {
828
+ textElementsByTable[tableName].push(textEl)
829
+ })
830
+ }
831
+ })
832
+
833
+ // Fallback to proximity-based grouping if no elements found
834
+ if (Object.values(textElementsByTable).every(arr => arr.length === 0)) {
835
+ return this.groupTextElementsByProximity(svg, tableNames)
836
+ }
837
+
838
+ return textElementsByTable
839
+ }
840
+
841
+ groupTextElementsByProximity(svg, tableNames) {
842
+ const textElementsByTable = {}
843
+ const allTexts = svg.querySelectorAll('text')
844
+
845
+ tableNames.forEach(tableName => {
846
+ textElementsByTable[tableName] = []
847
+ })
848
+
849
+ const tablePositions = {}
850
+ allTexts.forEach(textElement => {
851
+ const text = textElement.textContent.trim()
852
+ if (tableNames.includes(text)) {
853
+ try {
854
+ const rect = textElement.getBoundingClientRect()
855
+ tablePositions[text] = {
856
+ element: textElement,
857
+ x: rect.left + rect.width / 2,
858
+ y: rect.top + rect.height / 2
859
+ }
860
+ } catch (e) {
861
+ // Skip elements that can't provide bounding rect
862
+ }
863
+ }
864
+ })
865
+
866
+ allTexts.forEach(textElement => {
867
+ const text = textElement.textContent.trim()
868
+
869
+ if (tableNames.includes(text)) {
870
+ textElementsByTable[text].push(textElement)
871
+ return
872
+ }
873
+
874
+ try {
875
+ const rect = textElement.getBoundingClientRect()
876
+ const elementX = rect.left + rect.width / 2
877
+ const elementY = rect.top + rect.height / 2
878
+
879
+ let closestTable = null
880
+ let closestDistance = Infinity
881
+
882
+ Object.entries(tablePositions).forEach(([tableName, pos]) => {
883
+ const distance = Math.sqrt(
884
+ Math.pow(elementX - pos.x, 2) + Math.pow(elementY - pos.y, 2)
885
+ )
886
+
887
+ const isBelow = elementY > pos.y
888
+ const adjustedDistance = isBelow ? distance : distance * 2
889
+
890
+ if (adjustedDistance < closestDistance) {
891
+ closestDistance = adjustedDistance
892
+ closestTable = tableName
893
+ }
894
+ })
895
+
896
+ if (closestTable) {
897
+ textElementsByTable[closestTable].push(textElement)
898
+ }
899
+ } catch (e) {
900
+ // Skip elements that can't provide bounding rect
901
+ }
902
+ })
903
+
904
+ return textElementsByTable
905
+ }
906
+
907
+ // Zoom and pan methods
908
+ applyZoom() {
909
+ const svg = this.diagramTarget.querySelector('svg')
910
+ if (svg) {
911
+ svg.style.transform = `scale(${this.zoom}) translate(${this.pan.x}px, ${this.pan.y}px)`
912
+ svg.style.transformOrigin = 'center center'
913
+ svg.style.transition = 'transform 0.1s ease-out'
914
+ }
915
+ }
916
+
917
+ zoomDiagram(delta) {
918
+ this.zoom = Math.max(0.1, Math.min(5, this.zoom + delta))
919
+ this.applyZoom()
920
+ }
921
+
922
+ resetView() {
923
+ this.zoom = 1
924
+ this.pan = { x: 0, y: 0 }
925
+ this.applyZoom()
926
+ this.fitSvgToContainer()
927
+ }
928
+
929
+ startPan(event) {
930
+ if (event.target.hasAttribute('data-column') || event.target.style.cursor === 'pointer') {
931
+ return
932
+ }
933
+
934
+ this.isDragging = true
935
+ this.lastMouse = { x: event.clientX, y: event.clientY }
936
+ this.diagramTarget.style.cursor = 'grabbing'
937
+ event.preventDefault()
938
+ }
939
+
940
+ handlePan(event) {
941
+ if (!this.isDragging) return
942
+
943
+ const dx = (event.clientX - this.lastMouse.x) / this.zoom
944
+ const dy = (event.clientY - this.lastMouse.y) / this.zoom
945
+
946
+ this.pan.x += dx
947
+ this.pan.y += dy
948
+
949
+ this.applyZoom()
950
+ this.lastMouse = { x: event.clientX, y: event.clientY }
951
+
952
+ event.preventDefault()
953
+ }
954
+
955
+ endPan() {
956
+ if (this.isDragging) {
957
+ this.isDragging = false
958
+ this.diagramTarget.style.cursor = 'grab'
959
+ }
960
+ }
961
+
962
+ disconnect() {
963
+ if (this.tooltip && this.tooltip.parentNode) {
964
+ this.tooltip.parentNode.removeChild(this.tooltip)
965
+ }
966
+ }
967
+ fitSvgToContainer() {
968
+ const svg = this.diagramTarget.querySelector('svg');
969
+ if (!svg) return;
970
+
971
+ // Remove width/height attributes so SVG scales to container
972
+ svg.removeAttribute('width');
973
+ svg.removeAttribute('height');
974
+
975
+ // If you want to be extra safe, set width to 100% and height to auto
976
+ svg.style.width = '100%';
977
+ svg.style.height = 'auto';
978
+
979
+ // Clear any inline overflow styles
980
+ svg.style.overflow = 'visible';
981
+
982
+ // If the SVG has a viewBox, it will automatically scale to fit
983
+ // If not, you can try to set it based on the graph's bounding box
984
+ // (see below for advanced options)
985
+ }
986
+ initializeTableHoverListeners() {
987
+ const svg = this.diagramTarget.querySelector('svg');
988
+ if (!svg) return;
989
+
990
+ svg.querySelectorAll('g.node').forEach(nodeGroup => {
991
+ // Get the table name from <title> or id (for fallback)
992
+ const title = nodeGroup.querySelector('title');
993
+ let tableName = title ? title.textContent.trim() : nodeGroup.id;
994
+ if (!tableName) return;
995
+
996
+ // Find the main shape (polygon or rect) for the table
997
+ const polygon = nodeGroup.querySelector('polygon, rect');
998
+ if (!polygon) return;
999
+
1000
+ // Find the text element with the table name
1001
+ const tableNameText = Array.from(nodeGroup.querySelectorAll('text'))
1002
+ .find(el => el.textContent.trim() === tableName);
1003
+ if (!tableNameText) return; // Skip if no matching text
1004
+
1005
+ // Function to trigger the highlight
1006
+ const highlightTable = () => {
1007
+ // Add bold border class to the node group
1008
+ nodeGroup.classList.add('table-hover-highlight');
1009
+
1010
+ // Highlight all connected edges and columns
1011
+ Object.entries(this.relationshipData).forEach(([edgeId, relationship]) => {
1012
+ if (relationship.source_table === tableName || relationship.target_table === tableName) {
1013
+ const edgeGroup = svg.querySelector(`#${CSS.escape(edgeId)}`);
1014
+ if (edgeGroup) {
1015
+ edgeGroup.classList.add('edge-highlighted', 'animated');
1016
+ const highlightColor = this.getRelationshipGroupColor(relationship);
1017
+ this.highlightSpecificRelationship(relationship, highlightColor);
1018
+ }
1019
+ }
1020
+ });
1021
+ };
1022
+
1023
+ // Function to clear the highlight
1024
+ const clearTableHighlight = () => {
1025
+ nodeGroup.classList.remove('table-hover-highlight');
1026
+ this.clearAllHighlights();
1027
+ };
1028
+
1029
+ // Attach listeners to the polygon
1030
+ polygon.style.cursor = 'pointer';
1031
+ polygon.addEventListener('mouseenter', highlightTable);
1032
+ polygon.addEventListener('mouseleave', clearTableHighlight);
1033
+
1034
+ // Attach listeners to the text
1035
+ tableNameText.style.cursor = 'pointer';
1036
+ tableNameText.addEventListener('mouseenter', highlightTable);
1037
+ tableNameText.addEventListener('mouseleave', clearTableHighlight);
1038
+ });
1039
+ }
1040
+
1041
+ downloadPNG() {
1042
+ const svgElement = this.diagramTarget.querySelector('svg');
1043
+ if (!svgElement) return;
1044
+
1045
+ const serializer = new XMLSerializer();
1046
+ let source = serializer.serializeToString(svgElement);
1047
+
1048
+ // Add namespaces if missing
1049
+ if (!source.match(/^<svg[^>]+xmlns="http:\/\/www\.w3\.org\/2000\/svg"/)) {
1050
+ source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"');
1051
+ }
1052
+ if (!source.match(/^<svg[^>]+xmlns:xlink="http:\/\/www\.w3\.org\/1999\/xlink"/)) {
1053
+ source = source.replace(/^<svg/, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
1054
+ }
1055
+
1056
+ const image = new Image();
1057
+ const svgBlob = new Blob([source], {type: 'image/svg+xml;charset=utf-8'});
1058
+ const url = URL.createObjectURL(svgBlob);
1059
+
1060
+ image.onload = () => {
1061
+ const canvas = document.createElement('canvas');
1062
+ canvas.width = image.width;
1063
+ canvas.height = image.height;
1064
+ const context = canvas.getContext('2d');
1065
+ context.fillStyle = 'white';
1066
+ context.fillRect(0, 0, canvas.width, canvas.height);
1067
+ context.drawImage(image, 0, 0);
1068
+ URL.revokeObjectURL(url);
1069
+ canvas.toBlob((blob) => {
1070
+ const a = document.createElement('a');
1071
+ a.href = URL.createObjectURL(blob);
1072
+ a.download = 'schema_diagram.png';
1073
+ document.body.appendChild(a);
1074
+ a.click();
1075
+ document.body.removeChild(a);
1076
+ }, 'image/png');
1077
+ };
1078
+ image.src = url;
1079
+ }
1080
+
1081
+ downloadPDF() {
1082
+ const svgElement = this.diagramTarget.querySelector('svg')
1083
+ if (!svgElement) return
1084
+
1085
+ const serializer = new XMLSerializer()
1086
+ let source = serializer.serializeToString(svgElement)
1087
+
1088
+ // Add namespaces if missing
1089
+ if (!source.match(/^<svg[^>]+xmlns="http:\/\/www\.w3\.org\/2000\/svg"/)) {
1090
+ source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"')
1091
+ }
1092
+ if (!source.match(/^<svg[^>]+xmlns:xlink="http:\/\/www\.w3\.org\/1999\/xlink"/)) {
1093
+ source = source.replace(/^<svg/, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"')
1094
+ }
1095
+
1096
+ const image = new Image()
1097
+ const svgBlob = new Blob([source], {type: 'image/svg+xml;charset=utf-8'})
1098
+ const url = URL.createObjectURL(svgBlob)
1099
+
1100
+ image.onload = () => {
1101
+ const canvas = document.createElement('canvas')
1102
+ canvas.width = image.width
1103
+ canvas.height = image.height
1104
+ const context = canvas.getContext('2d')
1105
+ context.fillStyle = 'white'
1106
+ context.fillRect(0, 0, canvas.width, canvas.height)
1107
+ context.drawImage(image, 0, 0)
1108
+ URL.revokeObjectURL(url)
1109
+
1110
+ import('https://cdn.jsdelivr.net/npm/jspdf@2.5.1/+esm').then(({ default: jsPDF }) => {
1111
+ const pdf = new jsPDF({
1112
+ orientation: canvas.width > canvas.height ? 'landscape' : 'portrait',
1113
+ unit: 'px',
1114
+ format: [canvas.width, canvas.height]
1115
+ })
1116
+ pdf.addImage(
1117
+ canvas.toDataURL('image/png'),
1118
+ 'PNG',
1119
+ 0,
1120
+ 0,
1121
+ canvas.width,
1122
+ canvas.height
1123
+ )
1124
+ pdf.save('schema_diagram.pdf')
1125
+ })
1126
+ }
1127
+ image.src = url
1128
+ }
1129
+ }