comparison utils.py @ 10:b0d893d04d4c draft default tip

planemo upload for repository https://github.com/goeckslab/gleam.git commit 1594d503179f28987720594eb49b48a15486f073
author goeckslab
date Mon, 08 Sep 2025 22:38:35 +0000
parents 9e912fce264c
children
comparison
equal deleted inserted replaced
9:9e912fce264c 10:b0d893d04d4c
1 import base64 1 import base64
2 import json 2 import json
3 3
4 4
5 def get_html_template(): 5 def get_html_template():
6 """
7 Returns the opening HTML, <head> (with CSS/JS), and opens <body> + .container.
8 Includes:
9 - Base styling for layout and tables
10 - Sortable table headers with 3-state arrows (none ⇅, asc ↑, desc ↓)
11 - A scroll helper class (.scroll-rows-30) that approximates ~30 visible rows
12 - A guarded script so initializing runs only once even if injected twice
13 """
6 return """ 14 return """
7 <html> 15 <!DOCTYPE html>
8 <head> 16 <html>
9 <meta charset="UTF-8"> 17 <head>
10 <title>Galaxy-Ludwig Report</title> 18 <meta charset="UTF-8">
11 <style> 19 <title>Galaxy-Ludwig Report</title>
12 body { 20 <style>
13 font-family: Arial, sans-serif; 21 body {
14 margin: 0; 22 font-family: Arial, sans-serif;
15 padding: 20px; 23 margin: 0;
16 background-color: #f4f4f4; 24 padding: 20px;
17 } 25 background-color: #f4f4f4;
18 .container { 26 }
19 max-width: 800px; 27 .container {
20 margin: auto; 28 max-width: 1200px;
21 background: white; 29 margin: auto;
22 padding: 20px; 30 background: white;
23 box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 31 padding: 20px;
24 overflow-x: auto; 32 box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
25 } 33 overflow-x: auto;
26 h1 { 34 }
27 text-align: center; 35 h1 {
28 color: #333; 36 text-align: center;
29 } 37 color: #333;
30 h2 { 38 }
31 border-bottom: 2px solid #4CAF50; 39 h2 {
32 color: #4CAF50; 40 border-bottom: 2px solid #4CAF50;
33 padding-bottom: 5px; 41 color: #4CAF50;
34 } 42 padding-bottom: 5px;
35 /* baseline table setup */ 43 margin-top: 28px;
36 table { 44 }
37 border-collapse: collapse; 45
38 margin: 20px 0; 46 /* baseline table setup */
39 width: 100%; 47 table {
40 table-layout: fixed; 48 border-collapse: collapse;
41 } 49 margin: 20px 0;
42 table, th, td { 50 width: 100%;
43 border: 1px solid #ddd; 51 table-layout: fixed;
44 } 52 background: #fff;
45 th, td { 53 }
46 padding: 8px; 54 table, th, td {
47 text-align: center; 55 border: 1px solid #ddd;
48 vertical-align: middle; 56 }
49 word-wrap: break-word; 57 th, td {
50 } 58 padding: 10px;
51 th { 59 text-align: center;
52 background-color: #4CAF50; 60 vertical-align: middle;
53 color: white; 61 word-break: break-word;
54 } 62 white-space: normal;
55 .plot { 63 overflow-wrap: anywhere;
56 text-align: center; 64 }
57 margin: 20px 0; 65 th {
58 } 66 background-color: #4CAF50;
59 .plot img { 67 color: white;
60 max-width: 100%; 68 }
61 height: auto; 69
62 } 70 .plot {
63 71 text-align: center;
64 /* ------------------- 72 margin: 20px 0;
65 SORTABLE COLUMNS 73 }
66 ------------------- */ 74 .plot img {
67 table.performance-summary th.sortable { 75 max-width: 100%;
68 cursor: pointer; 76 height: auto;
69 position: relative; 77 border: 1px solid #ddd;
70 user-select: none; 78 }
71 } 79
72 /* hide arrows by default */ 80 /* -------------------
73 table.performance-summary th.sortable::after { 81 sortable columns (3-state: none ⇅, asc ↑, desc ↓)
74 content: ''; 82 ------------------- */
75 position: absolute; 83 table.performance-summary th.sortable {
76 right: 12px; 84 cursor: pointer;
77 top: 50%; 85 position: relative;
78 transform: translateY(-50%); 86 user-select: none;
79 font-size: 0.8em; 87 }
80 color: #666; 88 /* default icon space */
81 } 89 table.performance-summary th.sortable::after {
82 /* three states */ 90 content: '⇅';
83 table.performance-summary th.sortable.sorted-none::after { 91 position: absolute;
84 content: '⇅'; 92 right: 12px;
85 } 93 top: 50%;
86 table.performance-summary th.sortable.sorted-asc::after { 94 transform: translateY(-50%);
87 content: '↑'; 95 font-size: 0.8em;
88 } 96 color: #eaf5ea; /* light on green */
89 table.performance-summary th.sortable.sorted-desc::after { 97 text-shadow: 0 0 1px rgba(0,0,0,0.15);
90 content: '↓'; 98 }
91 } 99 /* three states override the default */
92 </style> 100 table.performance-summary th.sortable.sorted-none::after { content: '⇅'; color: #eaf5ea; }
93 101 table.performance-summary th.sortable.sorted-asc::after { content: '↑'; color: #ffffff; }
94 <!-- sorting script --> 102 table.performance-summary th.sortable.sorted-desc::after { content: '↓'; color: #ffffff; }
95 <script> 103
96 document.addEventListener('DOMContentLoaded', () => { 104 /* show ~30 rows with a scrollbar (tweak if you want) */
97 // 1) record each row's original position 105 .scroll-rows-30 {
98 document.querySelectorAll('table.performance-summary tbody').forEach(tbody => { 106 max-height: 900px; /* ~30 rows depending on row height */
99 Array.from(tbody.rows).forEach((row, i) => { 107 overflow-y: auto; /* vertical scrollbar (“sidebar”) */
100 row.dataset.originalOrder = i; 108 overflow-x: auto;
101 }); 109 }
110
111 /* Tabs + Help button (used by build_tabbed_html) */
112 .tabs {
113 display: flex;
114 align-items: center;
115 border-bottom: 2px solid #ccc;
116 margin-bottom: 1rem;
117 gap: 6px;
118 flex-wrap: wrap;
119 }
120 .tab {
121 padding: 10px 20px;
122 cursor: pointer;
123 border: 1px solid #ccc;
124 border-bottom: none;
125 background: #f9f9f9;
126 margin-right: 5px;
127 border-top-left-radius: 8px;
128 border-top-right-radius: 8px;
129 }
130 .tab.active {
131 background: white;
132 font-weight: bold;
133 }
134 .help-btn {
135 margin-left: auto;
136 padding: 6px 12px;
137 font-size: 0.9rem;
138 border: 1px solid #4CAF50;
139 border-radius: 4px;
140 background: #4CAF50;
141 color: white;
142 cursor: pointer;
143 }
144 .tab-content {
145 display: none;
146 padding: 20px;
147 border: 1px solid #ccc;
148 border-top: none;
149 background: #fff;
150 }
151 .tab-content.active {
152 display: block;
153 }
154
155 /* Modal (used by get_metrics_help_modal) */
156 .modal {
157 display: none;
158 position: fixed;
159 z-index: 9999;
160 left: 0; top: 0;
161 width: 100%; height: 100%;
162 overflow: auto;
163 background-color: rgba(0,0,0,0.4);
164 }
165 .modal-content {
166 background-color: #fefefe;
167 margin: 8% auto;
168 padding: 20px;
169 border: 1px solid #888;
170 width: 90%;
171 max-width: 900px;
172 border-radius: 8px;
173 }
174 .modal .close {
175 color: #777;
176 float: right;
177 font-size: 28px;
178 font-weight: bold;
179 line-height: 1;
180 margin-left: 8px;
181 }
182 .modal .close:hover,
183 .modal .close:focus {
184 color: black;
185 text-decoration: none;
186 cursor: pointer;
187 }
188 .metrics-guide h3 { margin-top: 20px; }
189 .metrics-guide p { margin: 6px 0; }
190 .metrics-guide ul { margin: 10px 0; padding-left: 20px; }
191 </style>
192
193 <script>
194 // Guard to avoid double-initialization if this block is included twice
195 (function(){
196 if (window.__perfSummarySortInit) return;
197 window.__perfSummarySortInit = true;
198
199 function initPerfSummarySorting() {
200 // Record original order for "back to original"
201 document.querySelectorAll('table.performance-summary tbody').forEach(tbody => {
202 Array.from(tbody.rows).forEach((row, i) => { row.dataset.originalOrder = i; });
203 });
204
205 const getText = td => (td?.innerText || '').trim();
206 const cmp = (idx, asc) => (a, b) => {
207 const v1 = getText(a.children[idx]);
208 const v2 = getText(b.children[idx]);
209 const n1 = parseFloat(v1), n2 = parseFloat(v2);
210 if (!isNaN(n1) && !isNaN(n2)) return asc ? n1 - n2 : n2 - n1; // numeric
211 return asc ? v1.localeCompare(v2) : v2.localeCompare(v1); // lexical
212 };
213
214 document.querySelectorAll('table.performance-summary th.sortable').forEach(th => {
215 // initialize to “none”
216 th.classList.remove('sorted-asc','sorted-desc');
217 th.classList.add('sorted-none');
218
219 th.addEventListener('click', () => {
220 const table = th.closest('table');
221 const headerRow = th.parentNode;
222 const allTh = headerRow.querySelectorAll('th.sortable');
223 const tbody = table.querySelector('tbody');
224
225 // Determine current state BEFORE clearing
226 const isAsc = th.classList.contains('sorted-asc');
227 const isDesc = th.classList.contains('sorted-desc');
228
229 // Reset all headers in this row
230 allTh.forEach(x => x.classList.remove('sorted-asc','sorted-desc','sorted-none'));
231
232 // Compute next state
233 let next;
234 if (!isAsc && !isDesc) {
235 next = 'asc';
236 } else if (isAsc) {
237 next = 'desc';
238 } else {
239 next = 'none';
240 }
241 th.classList.add('sorted-' + next);
242
243 // Sort rows according to the chosen state
244 const rows = Array.from(tbody.rows);
245 if (next === 'none') {
246 rows.sort((a, b) => (a.dataset.originalOrder - b.dataset.originalOrder));
247 } else {
248 const idx = Array.from(headerRow.children).indexOf(th);
249 rows.sort(cmp(idx, next === 'asc'));
250 }
251 rows.forEach(r => tbody.appendChild(r));
102 }); 252 });
103
104 const getText = cell => cell.innerText.trim();
105 const comparer = (idx, asc) => (a, b) => {
106 const v1 = getText(a.children[idx]);
107 const v2 = getText(b.children[idx]);
108 const n1 = parseFloat(v1), n2 = parseFloat(v2);
109 if (!isNaN(n1) && !isNaN(n2)) {
110 return asc ? n1 - n2 : n2 - n1;
111 }
112 return asc
113 ? v1.localeCompare(v2)
114 : v2.localeCompare(v1);
115 };
116
117 document
118 .querySelectorAll('table.performance-summary th.sortable')
119 .forEach(th => {
120 // initialize to "none" state
121 th.classList.add('sorted-none');
122 th.addEventListener('click', () => {
123 const table = th.closest('table');
124 const allTh = table.querySelectorAll('th.sortable');
125
126 // 1) determine current state BEFORE clearing classes
127 let curr = th.classList.contains('sorted-asc')
128 ? 'asc'
129 : th.classList.contains('sorted-desc')
130 ? 'desc'
131 : 'none';
132 // 2) cycle to next state
133 let next = curr === 'none'
134 ? 'asc'
135 : curr === 'asc'
136 ? 'desc'
137 : 'none';
138
139 // 3) clear all sort markers
140 allTh.forEach(h =>
141 h.classList.remove('sorted-none','sorted-asc','sorted-desc')
142 );
143 // 4) apply the new marker
144 th.classList.add(`sorted-${next}`);
145
146 // 5) sort or restore original order
147 const tbody = table.querySelector('tbody');
148 let rows = Array.from(tbody.rows);
149 if (next === 'none') {
150 rows.sort((a, b) =>
151 a.dataset.originalOrder - b.dataset.originalOrder
152 );
153 } else {
154 const idx = Array.from(th.parentNode.children).indexOf(th);
155 rows.sort(comparer(idx, next === 'asc'));
156 }
157 rows.forEach(r => tbody.appendChild(r));
158 });
159 });
160 }); 253 });
161 </script> 254 }
162 </head> 255
163 <body> 256 // Run after DOM is ready
164 <div class="container"> 257 if (document.readyState === 'loading') {
165 """ 258 document.addEventListener('DOMContentLoaded', initPerfSummarySorting);
259 } else {
260 initPerfSummarySorting();
261 }
262 })();
263 </script>
264 </head>
265 <body>
266 <div class="container">
267 """
166 268
167 269
168 def get_html_closing(): 270 def get_html_closing():
271 """Closes .container, body, and html."""
169 return """ 272 return """
170 </div> 273 </div>
171 </body> 274 </body>
172 </html> 275 </html>
173 """ 276 """
174 277
175 278
176 def encode_image_to_base64(image_path): 279 def encode_image_to_base64(image_path: str) -> str:
177 """Convert an image file to a base64 encoded string.""" 280 """Convert an image file to a base64 encoded string."""
178 with open(image_path, "rb") as img_file: 281 with open(image_path, "rb") as img_file:
179 return base64.b64encode(img_file.read()).decode("utf-8") 282 return base64.b64encode(img_file.read()).decode("utf-8")
180 283
181 284
182 def json_to_nested_html_table(json_data, depth=0): 285 def json_to_nested_html_table(json_data, depth: int = 0) -> str:
183 """ 286 """
184 Convert JSON object to an HTML nested table. 287 Convert a JSON-able object to an HTML nested table.
185 288 Renders dicts as two-column tables (key/value) and lists as index/value rows.
186 Parameters: 289 """
187 json_data (dict or list): The JSON data to convert. 290 # Base case: flat dict (no nested dict/list values)
188 depth (int): Current depth level for indentation.
189
190 Returns:
191 str: HTML string for the nested table.
192 """
193 # Base case: if JSON is a simple key-value pair dictionary
194 if isinstance(json_data, dict) and all( 291 if isinstance(json_data, dict) and all(
195 not isinstance(v, (dict, list)) for v in json_data.values() 292 not isinstance(v, (dict, list)) for v in json_data.values()
196 ): 293 ):
197 # Render a flat table
198 rows = [ 294 rows = [
199 f"<tr><th>{key}</th><td>{value}</td></tr>" 295 f"<tr><th>{key}</th><td>{value}</td></tr>"
200 for key, value in json_data.items() 296 for key, value in json_data.items()
201 ] 297 ]
202 return f"<table>{''.join(rows)}</table>" 298 return f"<table>{''.join(rows)}</table>"
203 299
204 # Base case: if JSON is a list of simple values 300 # Base case: list of simple values
205 if isinstance(json_data, list) and all( 301 if isinstance(json_data, list) and all(
206 not isinstance(v, (dict, list)) for v in json_data 302 not isinstance(v, (dict, list)) for v in json_data
207 ): 303 ):
208 rows = [ 304 rows = [
209 f"<tr><th>Index {i}</th><td>{value}</td></tr>" 305 f"<tr><th>Index {i}</th><td>{value}</td></tr>"
210 for i, value in enumerate(json_data) 306 for i, value in enumerate(json_data)
211 ] 307 ]
212 return f"<table>{''.join(rows)}</table>" 308 return f"<table>{''.join(rows)}</table>"
213 309
214 # Recursive case: if JSON contains nested structures 310 # Recursive cases
215 if isinstance(json_data, dict): 311 if isinstance(json_data, dict):
216 rows = [ 312 rows = [
217 f"<tr><th style='padding-left:{depth * 20}px;'>{key}</th>" 313 (
218 f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>" 314 f"<tr><th style='text-align:left;padding-left:{depth * 20}px;'>{key}</th>"
315 f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>"
316 )
219 for key, value in json_data.items() 317 for key, value in json_data.items()
220 ] 318 ]
221 return f"<table>{''.join(rows)}</table>" 319 return f"<table>{''.join(rows)}</table>"
222 320
223 if isinstance(json_data, list): 321 if isinstance(json_data, list):
224 rows = [ 322 rows = [
225 f"<tr><th style='padding-left:{depth * 20}px;'>[{i}]</th>" 323 (
226 f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>" 324 f"<tr><th style='text-align:left;padding-left:{depth * 20}px;'>[{i}]</th>"
325 f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>"
326 )
227 for i, value in enumerate(json_data) 327 for i, value in enumerate(json_data)
228 ] 328 ]
229 return f"<table>{''.join(rows)}</table>" 329 return f"<table>{''.join(rows)}</table>"
230 330
231 # Base case: simple value 331 # Primitive
232 return f"{json_data}" 332 return f"{json_data}"
233 333
234 334
235 def json_to_html_table(json_data): 335 def json_to_html_table(json_data) -> str:
236 """ 336 """
237 Convert JSON to a vertically oriented HTML table. 337 Convert JSON (dict or string) into a vertically oriented HTML table.
238
239 Parameters:
240 json_data (str or dict): JSON string or dictionary.
241
242 Returns:
243 str: HTML table representation.
244 """ 338 """
245 if isinstance(json_data, str): 339 if isinstance(json_data, str):
246 json_data = json.loads(json_data) 340 json_data = json.loads(json_data)
247 return json_to_nested_html_table(json_data) 341 return json_to_nested_html_table(json_data)
248 342
249 343
250 def build_tabbed_html(metrics_html: str, train_val_html: str, test_html: str) -> str: 344 def build_tabbed_html(metrics_html: str, train_val_html: str, test_html: str) -> str:
345 """
346 Build a 3-tab interface:
347 - Config and Results Summary
348 - Train/Validation Results
349 - Test Results
350 Includes a persistent "Help" button that toggles the metrics modal.
351 """
251 return f""" 352 return f"""
252 <style>
253 .tabs {{
254 display: flex;
255 align-items: center;
256 border-bottom: 2px solid #ccc;
257 margin-bottom: 1rem;
258 }}
259 .tab {{
260 padding: 10px 20px;
261 cursor: pointer;
262 border: 1px solid #ccc;
263 border-bottom: none;
264 background: #f9f9f9;
265 margin-right: 5px;
266 border-top-left-radius: 8px;
267 border-top-right-radius: 8px;
268 }}
269 .tab.active {{
270 background: white;
271 font-weight: bold;
272 }}
273 /* new help-button styling */
274 .help-btn {{
275 margin-left: auto;
276 padding: 6px 12px;
277 font-size: 0.9rem;
278 border: 1px solid #4CAF50;
279 border-radius: 4px;
280 background: #4CAF50;
281 color: white;
282 cursor: pointer;
283 }}
284 .tab-content {{
285 display: none;
286 padding: 20px;
287 border: 1px solid #ccc;
288 border-top: none;
289 }}
290 .tab-content.active {{
291 display: block;
292 }}
293 </style>
294
295 <div class="tabs"> 353 <div class="tabs">
296 <div class="tab active" onclick="showTab('metrics')">Config and Results Summary</div> 354 <div class="tab active" onclick="showTab('metrics')">Config and Results Summary</div>
297 <div class="tab" onclick="showTab('trainval')">Train/Validation Results</div> 355 <div class="tab" onclick="showTab('trainval')">Train/Validation Results</div>
298 <div class="tab" onclick="showTab('test')">Test Results</div> 356 <div class="tab" onclick="showTab('test')">Test Results</div>
299 <!-- always-visible help button --> 357 <button id="openMetricsHelp" class="help-btn" title="Open metrics help">Help</button>
300 <button id="openMetricsHelp" class="help-btn">Help</button>
301 </div> 358 </div>
302 359
303 <div id="metrics" class="tab-content active"> 360 <div id="metrics" class="tab-content active">
304 {metrics_html} 361 {metrics_html}
305 </div> 362 </div>
309 <div id="test" class="tab-content"> 366 <div id="test" class="tab-content">
310 {test_html} 367 {test_html}
311 </div> 368 </div>
312 369
313 <script> 370 <script>
314 function showTab(id) {{ 371 function showTab(id) {{
315 document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active')); 372 document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
316 document.querySelectorAll('.tab').forEach(el => el.classList.remove('active')); 373 document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
317 document.getElementById(id).classList.add('active'); 374 document.getElementById(id).classList.add('active');
318 document.querySelector(`.tab[onclick*="${{id}}"]`).classList.add('active'); 375 // find tab with matching onclick target
319 }} 376 document.querySelectorAll('.tab').forEach(t => {{
377 if (t.getAttribute('onclick') && t.getAttribute('onclick').includes(id)) {{
378 t.classList.add('active');
379 }}
380 }});
381 }}
320 </script> 382 </script>
321 """ 383 """
322 384
323 385
324 def get_metrics_help_modal() -> str: 386 def get_metrics_help_modal() -> str:
387 """
388 Returns a ready-to-use modal with a comprehensive metrics guide and
389 the small script that wires the "Help" button to open/close the modal.
390 """
325 modal_html = ( 391 modal_html = (
326 '<div id="metricsHelpModal" class="modal">' 392 '<div id="metricsHelpModal" class="modal">'
327 ' <div class="modal-content">' 393 ' <div class="modal-content">'
328 ' <span class="close">×</span>' 394 ' <span class="close">×</span>'
329 " <h2>Model Evaluation Metrics — Help Guide</h2>" 395 " <h2>Model Evaluation Metrics — Help Guide</h2>"
440 " </ul>" 506 " </ul>"
441 " </div>" 507 " </div>"
442 " </div>" 508 " </div>"
443 "</div>" 509 "</div>"
444 ) 510 )
445 modal_css = ( 511
446 "<style>"
447 ".modal {"
448 " display: none;"
449 " position: fixed;"
450 " z-index: 1;"
451 " left: 0;"
452 " top: 0;"
453 " width: 100%;"
454 " height: 100%;"
455 " overflow: auto;"
456 " background-color: rgba(0,0,0,0.4);"
457 "}"
458 ".modal-content {"
459 " background-color: #fefefe;"
460 " margin: 15% auto;"
461 " padding: 20px;"
462 " border: 1px solid #888;"
463 " width: 80%;"
464 " max-width: 800px;"
465 "}"
466 ".close {"
467 " color: #aaa;"
468 " float: right;"
469 " font-size: 28px;"
470 " font-weight: bold;"
471 "}"
472 ".close:hover,"
473 ".close:focus {"
474 " color: black;"
475 " text-decoration: none;"
476 " cursor: pointer;"
477 "}"
478 ".metrics-guide h3 {"
479 " margin-top: 20px;"
480 "}"
481 ".metrics-guide p {"
482 " margin: 5px 0;"
483 "}"
484 ".metrics-guide ul {"
485 " margin: 10px 0;"
486 " padding-left: 20px;"
487 "}"
488 "</style>"
489 )
490 modal_js = ( 512 modal_js = (
491 "<script>" 513 "<script>"
492 'document.addEventListener("DOMContentLoaded", function() {' 514 "document.addEventListener('DOMContentLoaded', function() {"
493 ' var modal = document.getElementById("metricsHelpModal");' 515 " var modal = document.getElementById('metricsHelpModal');"
494 ' var openBtn = document.getElementById("openMetricsHelp");' 516 " var openBtn = document.getElementById('openMetricsHelp');"
495 ' var span = document.getElementsByClassName("close")[0];' 517 " var closeBtn = modal ? modal.querySelector('.close') : null;"
496 " if (openBtn && modal) {" 518 " if (openBtn && modal) {"
497 " openBtn.onclick = function() {" 519 " openBtn.addEventListener('click', function(){ modal.style.display = 'block'; });"
498 ' modal.style.display = "block";'
499 " };"
500 " }" 520 " }"
501 " if (span && modal) {" 521 " if (closeBtn && modal) {"
502 " span.onclick = function() {" 522 " closeBtn.addEventListener('click', function(){ modal.style.display = 'none'; });"
503 ' modal.style.display = "none";'
504 " };"
505 " }" 523 " }"
506 " window.onclick = function(event) {" 524 " window.addEventListener('click', function(ev){"
507 " if (event.target == modal) {" 525 " if (ev.target === modal) { modal.style.display = 'none'; }"
508 ' modal.style.display = "none";' 526 " });"
509 " }"
510 " }"
511 "});" 527 "});"
512 "</script>" 528 "</script>"
513 ) 529 )
514 return modal_css + modal_html + modal_js 530 return modal_html + modal_js