Mercurial > repos > goeckslab > image_learner
changeset 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 | |
files | image_learner_cli.py utils.py |
diffstat | 2 files changed, 433 insertions(+), 348 deletions(-) [+] |
line wrap: on
line diff
--- a/image_learner_cli.py Wed Aug 27 21:02:48 2025 +0000 +++ b/image_learner_cli.py Mon Sep 08 22:38:35 2025 +0000 @@ -69,7 +69,6 @@ ] rows = [] - for key in display_keys: val = config.get(key, None) if key == "threshold": @@ -136,15 +135,15 @@ val_str = val else: val_str = val if val is not None else "N/A" - if val_str == "N/A" and key not in [ - "task_type" - ]: # Skip if N/A for non-essential + if val_str == "N/A" and key not in ["task_type"]: continue rows.append( f"<tr>" - f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left;'>" + f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left; " + f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>" f"{key.replace('_', ' ').title()}</td>" - f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center;'>" + f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center; " + f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>" f"{val_str}</td>" f"</tr>" ) @@ -153,13 +152,17 @@ types = [str(a.get("type", "")) for a in aug_cfg] aug_val = ", ".join(types) rows.append( - f"<tr><td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left;'>Augmentation</td>" - f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center;'>{aug_val}</td></tr>" + f"<tr><td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left; " + f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>Augmentation</td>" + f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center; " + f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>{aug_val}</td></tr>" ) if split_info: rows.append( - f"<tr><td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left;'>Data Split</td>" - f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center;'>{split_info}</td></tr>" + f"<tr><td style='padding: 6px 12px; border: 1px solid #ccc; text-align: left; " + f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>Data Split</td>" + f"<td style='padding: 6px 12px; border: 1px solid #ccc; text-align: center; " + f"white-space: normal; word-break: break-word; overflow-wrap: anywhere;'>{split_info}</td></tr>" ) html = f""" <h2 style="text-align: center;">Model and Training Summary</h2> @@ -946,6 +949,66 @@ test_viz_dir = base_viz_dir / "test" html = get_html_template() + + # Extra CSS & JS: center Plotly and enable CSV download for predictions table + html += """ +<style> + /* Center Plotly figures (both wrapper and native classes) */ + .plotly-center { display: flex; justify-content: center; } + .plotly-center .plotly-graph-div, .plotly-center .js-plotly-plot { margin: 0 auto !important; } + .js-plotly-plot, .plotly-graph-div { margin-left: auto !important; margin-right: auto !important; } + + /* Download button for predictions table */ + .download-btn { + padding: 8px 12px; + border: 1px solid #4CAF50; + background: #4CAF50; + color: white; + border-radius: 6px; + cursor: pointer; + } + .download-btn:hover { filter: brightness(0.95); } + .preds-controls { + display: flex; + justify-content: flex-end; + gap: 8px; + margin: 8px 0; + } +</style> +<script> + function tableToCSV(table){ + const rows = Array.from(table.querySelectorAll('tr')); + return rows.map(row => + Array.from(row.querySelectorAll('th,td')).map(cell => { + let text = cell.innerText.replace(/\\r?\\n|\\r/g,' ').trim(); + if (text.includes('"') || text.includes(',')) { + text = '"' + text.replace(/"/g,'""') + '"'; + } + return text; + }).join(',') + ).join('\\n'); + } + document.addEventListener('DOMContentLoaded', function(){ + const btn = document.getElementById('downloadPredsCsv'); + if(btn){ + btn.addEventListener('click', function(){ + const tbl = document.querySelector('.predictions-table'); + if(!tbl){ alert('Predictions table not found.'); return; } + const csv = tableToCSV(tbl); + const blob = new Blob([csv], {type: 'text/csv;charset=utf-8;'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'ground_truth_vs_predictions.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }); + } + }); +</script> +""" html += f"<h1>{title}</h1>" metrics_html = "" @@ -983,31 +1046,38 @@ except Exception as e: logger.warning(f"Could not load config for HTML report: {e}") + # ---------- image rendering with exclusions ---------- def render_img_section( - title: str, dir_path: Path, output_type: str = None + title: str, + dir_path: Path, + output_type: str = None, + exclude_names: Optional[set] = None, ) -> str: if not dir_path.exists(): return f"<h2>{title}</h2><p><em>Directory not found.</em></p>" - # collect every PNG + + exclude_names = exclude_names or set() + imgs = list(dir_path.glob("*.png")) - # --- EXCLUDE Ludwig's base confusion matrix and any top-N confusion_matrix files --- + + default_exclude = {"confusion_matrix.png", "roc_curves.png"} + imgs = [ img for img in imgs - if not ( - img.name == "confusion_matrix.png" - or img.name.startswith("confusion_matrix__label_top") - or img.name == "roc_curves.png" - ) + if img.name not in default_exclude + and img.name not in exclude_names + and not img.name.startswith("confusion_matrix__label_top") ] + if not imgs: return f"<h2>{title}</h2><p><em>No plots found.</em></p>" + if output_type == "binary": order = [ "roc_curves_from_prediction_statistics.png", "compare_performance_label.png", "confusion_matrix_entropy__label_top2.png", - # ...you can tweak ordering as needed ] img_names = {img.name: img for img in imgs} ordered = [img_names[n] for n in order if n in img_names] @@ -1019,14 +1089,13 @@ "compare_classifiers_multiclass_multimetric__label_top10.png", "compare_classifiers_multiclass_multimetric__label_worst10.png", } + valid_imgs = [img for img in imgs if img.name not in unwanted] display_order = [ "roc_curves.png", "compare_performance_label.png", "compare_classifiers_performance_from_prob.png", "confusion_matrix_entropy__label_top10.png", ] - # filter and order - valid_imgs = [img for img in imgs if img.name not in unwanted] img_map = {img.name: img for img in valid_imgs} ordered = [img_map[n] for n in display_order if n in img_map] others = sorted( @@ -1034,27 +1103,36 @@ ) imgs = ordered + others else: - # regression: just sort whatever's left imgs = sorted(imgs) - # render each remaining PNG - html = "" + + html_section = "" for img in imgs: b64 = encode_image_to_base64(str(img)) img_title = img.stem.replace("_", " ").title() - html += ( + html_section += ( f"<h2 style='text-align: center;'>{img_title}</h2>" f'<div class="plot" style="margin-bottom:20px;text-align:center;">' f'<img src="data:image/png;base64,{b64}" ' f'style="max-width:90%;max-height:600px;border:1px solid #ddd;" />' f"</div>" ) - return html + return html_section tab1_content = config_html + metrics_html + tab2_content = train_val_metrics_html + render_img_section( - "Training and Validation Visualizations", train_viz_dir + "Training and Validation Visualizations", + train_viz_dir, + output_type, + exclude_names={ + "compare_classifiers_performance_from_prob.png", + "roc_curves_from_prediction_statistics.png", + "precision_recall_curves_from_prediction_statistics.png", + "precision_recall_curve.png", + }, ) - # --- Predictions vs Ground Truth table --- + + # --- Predictions vs Ground Truth table (REGRESSION ONLY) --- preds_section = "" parquet_path = exp_dir / PREDICTIONS_PARQUET_FILE_NAME if output_type == "regression" and parquet_path.exists(): @@ -1081,13 +1159,19 @@ preds_html = df_table.to_html(index=False, classes="predictions-table") preds_section = ( "<h2 style='text-align: center;'>Ground Truth vs. Predictions</h2>" - "<div style='overflow-y:auto; max-height:400px; overflow-x:auto; margin-bottom:20px;'>" + "<div class='preds-controls'>" + "<button id='downloadPredsCsv' class='download-btn'>Download CSV</button>" + "</div>" + "<div class='scroll-rows-30' style='overflow-x:auto; overflow-y:auto; max-height:900px; margin-bottom:20px;'>" + preds_html + "</div>" ) except Exception as e: logger.warning(f"Could not build Predictions vs GT table: {e}") + tab3_content = test_metrics_html + preds_section + + # Classification-only interactive Plotly panels (centered) if output_type in ("binary", "category"): training_stats_path = exp_dir / "training_statistics.json" interactive_plots = build_classification_plots( @@ -1095,31 +1179,16 @@ str(training_stats_path), ) for plot in interactive_plots: - # 2) inject the static "roc_curves_from_prediction_statistics.png" - if plot["title"] == "ROC-AUC": - static_img = ( - test_viz_dir / "roc_curves_from_prediction_statistics.png" - ) - if static_img.exists(): - b64 = encode_image_to_base64(str(static_img)) - tab3_content += ( - "<h2 style='text-align: center;'>" - "Roc Curves From Prediction Statistics" - "</h2>" - f'<div class="plot" style="margin-bottom:20px;text-align:center;">' - f'<img src="data:image/png;base64,{b64}" ' - f'style="max-width:90%;max-height:600px;border:1px solid #ddd;" />' - "</div>" - ) - # always render the plotly panels exactly as before tab3_content += ( f"<h2 style='text-align: center;'>{plot['title']}</h2>" - + plot["html"] + f"<div class='plotly-center'>{plot['html']}</div>" ) - tab3_content += render_img_section( - "Test Visualizations", test_viz_dir, output_type - ) - # assemble the tabs and help modal + + # Add static TEST PNGs (with default dedupe/exclusions) + tab3_content += render_img_section( + "Test Visualizations", test_viz_dir, output_type + ) + tabbed_html = build_tabbed_html(tab1_content, tab2_content, tab3_content) modal_html = get_metrics_help_modal() html += tabbed_html + modal_html + get_html_closing()
--- a/utils.py Wed Aug 27 21:02:48 2025 +0000 +++ b/utils.py Mon Sep 08 22:38:35 2025 +0000 @@ -3,205 +3,301 @@ def get_html_template(): + """ + Returns the opening HTML, <head> (with CSS/JS), and opens <body> + .container. + Includes: + - Base styling for layout and tables + - Sortable table headers with 3-state arrows (none ⇅, asc ↑, desc ↓) + - A scroll helper class (.scroll-rows-30) that approximates ~30 visible rows + - A guarded script so initializing runs only once even if injected twice + """ return """ - <html> - <head> - <meta charset="UTF-8"> - <title>Galaxy-Ludwig Report</title> - <style> - body { - font-family: Arial, sans-serif; - margin: 0; - padding: 20px; - background-color: #f4f4f4; - } - .container { - max-width: 800px; - margin: auto; - background: white; - padding: 20px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); - overflow-x: auto; - } - h1 { - text-align: center; - color: #333; - } - h2 { - border-bottom: 2px solid #4CAF50; - color: #4CAF50; - padding-bottom: 5px; - } - /* baseline table setup */ - table { - border-collapse: collapse; - margin: 20px 0; - width: 100%; - table-layout: fixed; - } - table, th, td { - border: 1px solid #ddd; - } - th, td { - padding: 8px; - text-align: center; - vertical-align: middle; - word-wrap: break-word; - } - th { - background-color: #4CAF50; - color: white; - } - .plot { - text-align: center; - margin: 20px 0; - } - .plot img { - max-width: 100%; - height: auto; - } +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <title>Galaxy-Ludwig Report</title> + <style> + body { + font-family: Arial, sans-serif; + margin: 0; + padding: 20px; + background-color: #f4f4f4; + } + .container { + max-width: 1200px; + margin: auto; + background: white; + padding: 20px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + overflow-x: auto; + } + h1 { + text-align: center; + color: #333; + } + h2 { + border-bottom: 2px solid #4CAF50; + color: #4CAF50; + padding-bottom: 5px; + margin-top: 28px; + } + + /* baseline table setup */ + table { + border-collapse: collapse; + margin: 20px 0; + width: 100%; + table-layout: fixed; + background: #fff; + } + table, th, td { + border: 1px solid #ddd; + } + th, td { + padding: 10px; + text-align: center; + vertical-align: middle; + word-break: break-word; + white-space: normal; + overflow-wrap: anywhere; + } + th { + background-color: #4CAF50; + color: white; + } + + .plot { + text-align: center; + margin: 20px 0; + } + .plot img { + max-width: 100%; + height: auto; + border: 1px solid #ddd; + } + + /* ------------------- + sortable columns (3-state: none ⇅, asc ↑, desc ↓) + ------------------- */ + table.performance-summary th.sortable { + cursor: pointer; + position: relative; + user-select: none; + } + /* default icon space */ + table.performance-summary th.sortable::after { + content: '⇅'; + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + font-size: 0.8em; + color: #eaf5ea; /* light on green */ + text-shadow: 0 0 1px rgba(0,0,0,0.15); + } + /* three states override the default */ + table.performance-summary th.sortable.sorted-none::after { content: '⇅'; color: #eaf5ea; } + table.performance-summary th.sortable.sorted-asc::after { content: '↑'; color: #ffffff; } + table.performance-summary th.sortable.sorted-desc::after { content: '↓'; color: #ffffff; } + + /* show ~30 rows with a scrollbar (tweak if you want) */ + .scroll-rows-30 { + max-height: 900px; /* ~30 rows depending on row height */ + overflow-y: auto; /* vertical scrollbar (“sidebar”) */ + overflow-x: auto; + } - /* ------------------- - SORTABLE COLUMNS - ------------------- */ - table.performance-summary th.sortable { - cursor: pointer; - position: relative; - user-select: none; - } - /* hide arrows by default */ - table.performance-summary th.sortable::after { - content: ''; - position: absolute; - right: 12px; - top: 50%; - transform: translateY(-50%); - font-size: 0.8em; - color: #666; - } - /* three states */ - table.performance-summary th.sortable.sorted-none::after { - content: '⇅'; - } - table.performance-summary th.sortable.sorted-asc::after { - content: '↑'; - } - table.performance-summary th.sortable.sorted-desc::after { - content: '↓'; - } - </style> + /* Tabs + Help button (used by build_tabbed_html) */ + .tabs { + display: flex; + align-items: center; + border-bottom: 2px solid #ccc; + margin-bottom: 1rem; + gap: 6px; + flex-wrap: wrap; + } + .tab { + padding: 10px 20px; + cursor: pointer; + border: 1px solid #ccc; + border-bottom: none; + background: #f9f9f9; + margin-right: 5px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + } + .tab.active { + background: white; + font-weight: bold; + } + .help-btn { + margin-left: auto; + padding: 6px 12px; + font-size: 0.9rem; + border: 1px solid #4CAF50; + border-radius: 4px; + background: #4CAF50; + color: white; + cursor: pointer; + } + .tab-content { + display: none; + padding: 20px; + border: 1px solid #ccc; + border-top: none; + background: #fff; + } + .tab-content.active { + display: block; + } - <!-- sorting script --> - <script> - document.addEventListener('DOMContentLoaded', () => { - // 1) record each row's original position - document.querySelectorAll('table.performance-summary tbody').forEach(tbody => { - Array.from(tbody.rows).forEach((row, i) => { - row.dataset.originalOrder = i; - }); - }); + /* Modal (used by get_metrics_help_modal) */ + .modal { + display: none; + position: fixed; + z-index: 9999; + left: 0; top: 0; + width: 100%; height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.4); + } + .modal-content { + background-color: #fefefe; + margin: 8% auto; + padding: 20px; + border: 1px solid #888; + width: 90%; + max-width: 900px; + border-radius: 8px; + } + .modal .close { + color: #777; + float: right; + font-size: 28px; + font-weight: bold; + line-height: 1; + margin-left: 8px; + } + .modal .close:hover, + .modal .close:focus { + color: black; + text-decoration: none; + cursor: pointer; + } + .metrics-guide h3 { margin-top: 20px; } + .metrics-guide p { margin: 6px 0; } + .metrics-guide ul { margin: 10px 0; padding-left: 20px; } + </style> - const getText = cell => cell.innerText.trim(); - const comparer = (idx, asc) => (a, b) => { - const v1 = getText(a.children[idx]); - const v2 = getText(b.children[idx]); - const n1 = parseFloat(v1), n2 = parseFloat(v2); - if (!isNaN(n1) && !isNaN(n2)) { - return asc ? n1 - n2 : n2 - n1; + <script> + // Guard to avoid double-initialization if this block is included twice + (function(){ + if (window.__perfSummarySortInit) return; + window.__perfSummarySortInit = true; + + function initPerfSummarySorting() { + // Record original order for "back to original" + document.querySelectorAll('table.performance-summary tbody').forEach(tbody => { + Array.from(tbody.rows).forEach((row, i) => { row.dataset.originalOrder = i; }); + }); + + const getText = td => (td?.innerText || '').trim(); + const cmp = (idx, asc) => (a, b) => { + const v1 = getText(a.children[idx]); + const v2 = getText(b.children[idx]); + const n1 = parseFloat(v1), n2 = parseFloat(v2); + if (!isNaN(n1) && !isNaN(n2)) return asc ? n1 - n2 : n2 - n1; // numeric + return asc ? v1.localeCompare(v2) : v2.localeCompare(v1); // lexical + }; + + document.querySelectorAll('table.performance-summary th.sortable').forEach(th => { + // initialize to “none” + th.classList.remove('sorted-asc','sorted-desc'); + th.classList.add('sorted-none'); + + th.addEventListener('click', () => { + const table = th.closest('table'); + const headerRow = th.parentNode; + const allTh = headerRow.querySelectorAll('th.sortable'); + const tbody = table.querySelector('tbody'); + + // Determine current state BEFORE clearing + const isAsc = th.classList.contains('sorted-asc'); + const isDesc = th.classList.contains('sorted-desc'); + + // Reset all headers in this row + allTh.forEach(x => x.classList.remove('sorted-asc','sorted-desc','sorted-none')); + + // Compute next state + let next; + if (!isAsc && !isDesc) { + next = 'asc'; + } else if (isAsc) { + next = 'desc'; + } else { + next = 'none'; } - return asc - ? v1.localeCompare(v2) - : v2.localeCompare(v1); - }; - - document - .querySelectorAll('table.performance-summary th.sortable') - .forEach(th => { - // initialize to "none" state - th.classList.add('sorted-none'); - th.addEventListener('click', () => { - const table = th.closest('table'); - const allTh = table.querySelectorAll('th.sortable'); - - // 1) determine current state BEFORE clearing classes - let curr = th.classList.contains('sorted-asc') - ? 'asc' - : th.classList.contains('sorted-desc') - ? 'desc' - : 'none'; - // 2) cycle to next state - let next = curr === 'none' - ? 'asc' - : curr === 'asc' - ? 'desc' - : 'none'; + th.classList.add('sorted-' + next); - // 3) clear all sort markers - allTh.forEach(h => - h.classList.remove('sorted-none','sorted-asc','sorted-desc') - ); - // 4) apply the new marker - th.classList.add(`sorted-${next}`); + // Sort rows according to the chosen state + const rows = Array.from(tbody.rows); + if (next === 'none') { + rows.sort((a, b) => (a.dataset.originalOrder - b.dataset.originalOrder)); + } else { + const idx = Array.from(headerRow.children).indexOf(th); + rows.sort(cmp(idx, next === 'asc')); + } + rows.forEach(r => tbody.appendChild(r)); + }); + }); + } - // 5) sort or restore original order - const tbody = table.querySelector('tbody'); - let rows = Array.from(tbody.rows); - if (next === 'none') { - rows.sort((a, b) => - a.dataset.originalOrder - b.dataset.originalOrder - ); - } else { - const idx = Array.from(th.parentNode.children).indexOf(th); - rows.sort(comparer(idx, next === 'asc')); - } - rows.forEach(r => tbody.appendChild(r)); - }); - }); - }); - </script> - </head> - <body> - <div class="container"> - """ + // Run after DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initPerfSummarySorting); + } else { + initPerfSummarySorting(); + } + })(); + </script> +</head> +<body> + <div class="container"> +""" def get_html_closing(): + """Closes .container, body, and html.""" return """ - </div> - </body> - </html> - """ + </div> +</body> +</html> +""" -def encode_image_to_base64(image_path): +def encode_image_to_base64(image_path: str) -> str: """Convert an image file to a base64 encoded string.""" with open(image_path, "rb") as img_file: return base64.b64encode(img_file.read()).decode("utf-8") -def json_to_nested_html_table(json_data, depth=0): +def json_to_nested_html_table(json_data, depth: int = 0) -> str: """ - Convert JSON object to an HTML nested table. - - Parameters: - json_data (dict or list): The JSON data to convert. - depth (int): Current depth level for indentation. - - Returns: - str: HTML string for the nested table. + Convert a JSON-able object to an HTML nested table. + Renders dicts as two-column tables (key/value) and lists as index/value rows. """ - # Base case: if JSON is a simple key-value pair dictionary + # Base case: flat dict (no nested dict/list values) if isinstance(json_data, dict) and all( not isinstance(v, (dict, list)) for v in json_data.values() ): - # Render a flat table rows = [ f"<tr><th>{key}</th><td>{value}</td></tr>" for key, value in json_data.items() ] return f"<table>{''.join(rows)}</table>" - # Base case: if JSON is a list of simple values + # Base case: list of simple values if isinstance(json_data, list) and all( not isinstance(v, (dict, list)) for v in json_data ): @@ -211,36 +307,34 @@ ] return f"<table>{''.join(rows)}</table>" - # Recursive case: if JSON contains nested structures + # Recursive cases if isinstance(json_data, dict): rows = [ - f"<tr><th style='padding-left:{depth * 20}px;'>{key}</th>" - f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>" + ( + f"<tr><th style='text-align:left;padding-left:{depth * 20}px;'>{key}</th>" + f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>" + ) for key, value in json_data.items() ] return f"<table>{''.join(rows)}</table>" if isinstance(json_data, list): rows = [ - f"<tr><th style='padding-left:{depth * 20}px;'>[{i}]</th>" - f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>" + ( + f"<tr><th style='text-align:left;padding-left:{depth * 20}px;'>[{i}]</th>" + f"<td>{json_to_nested_html_table(value, depth + 1)}</td></tr>" + ) for i, value in enumerate(json_data) ] return f"<table>{''.join(rows)}</table>" - # Base case: simple value + # Primitive return f"{json_data}" -def json_to_html_table(json_data): +def json_to_html_table(json_data) -> str: """ - Convert JSON to a vertically oriented HTML table. - - Parameters: - json_data (str or dict): JSON string or dictionary. - - Returns: - str: HTML table representation. + Convert JSON (dict or string) into a vertically oriented HTML table. """ if isinstance(json_data, str): json_data = json.loads(json_data) @@ -248,56 +342,19 @@ def build_tabbed_html(metrics_html: str, train_val_html: str, test_html: str) -> str: + """ + Build a 3-tab interface: + - Config and Results Summary + - Train/Validation Results + - Test Results + Includes a persistent "Help" button that toggles the metrics modal. + """ return f""" -<style> - .tabs {{ - display: flex; - align-items: center; - border-bottom: 2px solid #ccc; - margin-bottom: 1rem; - }} - .tab {{ - padding: 10px 20px; - cursor: pointer; - border: 1px solid #ccc; - border-bottom: none; - background: #f9f9f9; - margin-right: 5px; - border-top-left-radius: 8px; - border-top-right-radius: 8px; - }} - .tab.active {{ - background: white; - font-weight: bold; - }} - /* new help-button styling */ - .help-btn {{ - margin-left: auto; - padding: 6px 12px; - font-size: 0.9rem; - border: 1px solid #4CAF50; - border-radius: 4px; - background: #4CAF50; - color: white; - cursor: pointer; - }} - .tab-content {{ - display: none; - padding: 20px; - border: 1px solid #ccc; - border-top: none; - }} - .tab-content.active {{ - display: block; - }} -</style> - <div class="tabs"> <div class="tab active" onclick="showTab('metrics')">Config and Results Summary</div> <div class="tab" onclick="showTab('trainval')">Train/Validation Results</div> <div class="tab" onclick="showTab('test')">Test Results</div> - <!-- always-visible help button --> - <button id="openMetricsHelp" class="help-btn">Help</button> + <button id="openMetricsHelp" class="help-btn" title="Open metrics help">Help</button> </div> <div id="metrics" class="tab-content active"> @@ -311,17 +368,26 @@ </div> <script> -function showTab(id) {{ - document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active')); - document.querySelectorAll('.tab').forEach(el => el.classList.remove('active')); - document.getElementById(id).classList.add('active'); - document.querySelector(`.tab[onclick*="${{id}}"]`).classList.add('active'); -}} + function showTab(id) {{ + document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active')); + document.querySelectorAll('.tab').forEach(el => el.classList.remove('active')); + document.getElementById(id).classList.add('active'); + // find tab with matching onclick target + document.querySelectorAll('.tab').forEach(t => {{ + if (t.getAttribute('onclick') && t.getAttribute('onclick').includes(id)) {{ + t.classList.add('active'); + }} + }}); + }} </script> """ def get_metrics_help_modal() -> str: + """ + Returns a ready-to-use modal with a comprehensive metrics guide and + the small script that wires the "Help" button to open/close the modal. + """ modal_html = ( '<div id="metricsHelpModal" class="modal">' ' <div class="modal-content">' @@ -442,73 +508,23 @@ " </div>" "</div>" ) - modal_css = ( - "<style>" - ".modal {" - " display: none;" - " position: fixed;" - " z-index: 1;" - " left: 0;" - " top: 0;" - " width: 100%;" - " height: 100%;" - " overflow: auto;" - " background-color: rgba(0,0,0,0.4);" - "}" - ".modal-content {" - " background-color: #fefefe;" - " margin: 15% auto;" - " padding: 20px;" - " border: 1px solid #888;" - " width: 80%;" - " max-width: 800px;" - "}" - ".close {" - " color: #aaa;" - " float: right;" - " font-size: 28px;" - " font-weight: bold;" - "}" - ".close:hover," - ".close:focus {" - " color: black;" - " text-decoration: none;" - " cursor: pointer;" - "}" - ".metrics-guide h3 {" - " margin-top: 20px;" - "}" - ".metrics-guide p {" - " margin: 5px 0;" - "}" - ".metrics-guide ul {" - " margin: 10px 0;" - " padding-left: 20px;" - "}" - "</style>" - ) + modal_js = ( "<script>" - 'document.addEventListener("DOMContentLoaded", function() {' - ' var modal = document.getElementById("metricsHelpModal");' - ' var openBtn = document.getElementById("openMetricsHelp");' - ' var span = document.getElementsByClassName("close")[0];' + "document.addEventListener('DOMContentLoaded', function() {" + " var modal = document.getElementById('metricsHelpModal');" + " var openBtn = document.getElementById('openMetricsHelp');" + " var closeBtn = modal ? modal.querySelector('.close') : null;" " if (openBtn && modal) {" - " openBtn.onclick = function() {" - ' modal.style.display = "block";' - " };" + " openBtn.addEventListener('click', function(){ modal.style.display = 'block'; });" " }" - " if (span && modal) {" - " span.onclick = function() {" - ' modal.style.display = "none";' - " };" + " if (closeBtn && modal) {" + " closeBtn.addEventListener('click', function(){ modal.style.display = 'none'; });" " }" - " window.onclick = function(event) {" - " if (event.target == modal) {" - ' modal.style.display = "none";' - " }" - " }" + " window.addEventListener('click', function(ev){" + " if (ev.target === modal) { modal.style.display = 'none'; }" + " });" "});" "</script>" ) - return modal_css + modal_html + modal_js + return modal_html + modal_js