Mercurial > repos > goeckslab > image_learner
comparison utils.py @ 10:b0d893d04d4c draft
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 | c5150cceab47 |
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 |
