From ed50ec052ede8d2e2deb313961f6fe50af3e0baa Mon Sep 17 00:00:00 2001 From: joehart2001 Date: Tue, 10 Mar 2026 21:04:11 +0000 Subject: [PATCH] download button for csv, png, svg --- .gitignore | 1 + ml_peg/app/data/table_download_controls.css | 57 ++++++ ml_peg/app/utils/build_components.py | 107 ++++++++++- ml_peg/app/utils/register_callbacks.py | 185 +++++++++++++++++++- 4 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 ml_peg/app/data/table_download_controls.css diff --git a/.gitignore b/.gitignore index 6b68b80ca..2373e1f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ pip-wheel-metadata/ __pycache__/ ml_peg/app/data/* !ml_peg/app/data/onboarding/ +!ml_peg/app/data/table_download_controls.css certs/ diff --git a/ml_peg/app/data/table_download_controls.css b/ml_peg/app/data/table_download_controls.css new file mode 100644 index 000000000..f61990d06 --- /dev/null +++ b/ml_peg/app/data/table_download_controls.css @@ -0,0 +1,57 @@ +#all-tabs, +#all-tabs[role="tablist"], +#all-tabs [role="tablist"] { + height: auto; + overflow: visible; +} + +#all-tabs[role="tablist"], +#all-tabs [role="tablist"] { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + align-items: stretch; +} + +#all-tabs[role="tab"], +#all-tabs [role="tab"], +#all-tabs .tab, +#all-tabs .tab--selected { + white-space: normal; + line-height: 1.2; + height: auto; + min-height: 36px; + padding: 8px 12px; + overflow-wrap: anywhere; + word-break: break-word; +} + +.table-download-format .Select { + min-height: 22px; + height: 22px; +} + +.table-download-format .Select-control { + min-height: 22px; + height: 22px; +} + +.table-download-format .Select-placeholder, +.table-download-format .Select--single > .Select-control .Select-value { + line-height: 20px; +} + +.table-download-format .Select-input { + height: 20px; +} + +.table-download-format .Select-input > input { + line-height: 20px; + padding-top: 0; + padding-bottom: 0; +} + +.table-download-format .Select-arrow-zone { + padding-top: 0; + padding-bottom: 0; +} diff --git a/ml_peg/app/utils/build_components.py b/ml_peg/app/utils/build_components.py index d622aa74d..58eb07838 100644 --- a/ml_peg/app/utils/build_components.py +++ b/ml_peg/app/utils/build_components.py @@ -8,7 +8,7 @@ from dash import html from dash.dash_table import DataTable -from dash.dcc import Checklist, Store +from dash.dcc import Checklist, Download, Dropdown, Store from dash.dcc import Input as DCC_Input from dash.development.base_component import Component from dash.html import H2, H3, Br, Button, Details, Div, Label, Summary @@ -17,6 +17,7 @@ from ml_peg.analysis.utils.utils import Thresholds from ml_peg.app.utils.register_callbacks import ( register_category_table_callbacks, + register_download_callbacks, register_normalization_callbacks, register_summary_table_callbacks, register_weight_callbacks, @@ -135,6 +136,8 @@ def build_weight_components( table: DataTable, *, use_thresholds: bool = False, + include_download_controls: bool = True, + download_anchor: str = "center", column_widths: dict[str, int] | None = None, thresholds: Thresholds | None = None, ) -> Div: @@ -151,6 +154,10 @@ def build_weight_components( Whether this table also exposes normalization thresholds. When True, weight callbacks will reuse the raw-data store and normalization store to recompute Scores consistently. + include_download_controls + Whether to render download controls inside this component. + download_anchor + Position of download controls when rendered. One of `"center"` or `"top"`. column_widths Optional mapping of table column IDs to pixel widths used to align the inputs with the rendered table. @@ -277,6 +284,7 @@ def build_weight_components( "rowGap": "4px", "marginTop": "-5px", "padding": "2px 4px", + "paddingRight": "130px" if include_download_controls else "4px", "backgroundColor": "#f8f9fa", "border": "1px solid transparent" if header == "Metric Weights" @@ -288,9 +296,21 @@ def build_weight_components( }, ) + panel_children: list[Component] = [container] + if include_download_controls: + panel_children.insert( + 0, + build_download_controls(table.id, anchor=download_anchor), + ) + + panel = Div( + panel_children, + style={"position": "relative"} if include_download_controls else None, + ) + layout = [ Br(), - container, + panel, Store( id=f"{table.id}-weight-store", storage_type="session", @@ -327,9 +347,86 @@ def build_weight_components( default_weights=getattr(table, "weights", None), ) + register_download_callbacks(table.id) + return Div(layout) +def build_download_controls(table_id: str, anchor: str = "center") -> Div: + """ + Build minimal table download controls. + + Parameters + ---------- + table_id + ID of the table to export. + anchor + Vertical anchor for controls: `"center"` or `"top"`. + + Returns + ------- + Div + Download controls and target components. + """ + if anchor == "top": + top_style = { + "top": "10px", + "transform": "none", + } + else: + top_style = { + "top": "50%", + "transform": "translateY(-50%)", + } + + return Div( + [ + Dropdown( + id=f"{table_id}-download-format", + className="table-download-format", + options=[ + {"label": "CSV", "value": "csv"}, + {"label": "PNG", "value": "png"}, + {"label": "SVG", "value": "svg"}, + ], + value="csv", + clearable=False, + searchable=False, + style={ + "width": "72px", + "fontSize": "11px", + }, + ), + Button( + "Download", + id=f"{table_id}-download-button", + n_clicks=0, + style={ + "width": "72px", + "padding": "5px 8px", + "fontSize": "11px", + "borderRadius": "4px", + "border": "1px solid #6c757d", + "backgroundColor": "#ffffff", + "cursor": "pointer", + }, + ), + Download(id=f"{table_id}-download"), + Store(id=f"{table_id}-download-request", storage_type="memory"), + ], + style={ + "position": "absolute", + "right": "12px", + "zIndex": "10", + "display": "flex", + "flexDirection": "column", + "alignItems": "flex-end", + "gap": "6px", + **top_style, + }, + ) + + def build_faqs() -> Div: """ Build FAQ section with collapsible dropdowns from YAML file. @@ -601,6 +698,7 @@ def build_test_layout( ) # Inline normalization thresholds when metadata is supplied + threshold_controls = None if thresholds is not None: reserved = {"MLIP", "Score", "id"} metric_columns = [ @@ -632,6 +730,8 @@ def build_test_layout( header="Metric Weights", table=table, use_thresholds=True, + include_download_controls=thresholds is None, + download_anchor="top", column_widths=column_widths, thresholds=thresholds, ) @@ -653,14 +753,17 @@ def build_test_layout( layout_contents.append( Div( [ + build_download_controls(table.id, anchor="top"), Div(threshold_controls, style={"marginBottom": "0px"}), Div(compact_weights, style={"marginTop": "0"}), ], style={ + "position": "relative", "backgroundColor": "#f8f9fa", "border": "1px solid #dee2e6", "borderRadius": "6px", "padding": "0px 0px 0px 0px", # top right bottom left + "paddingRight": "130px", "marginTop": "-5px", "boxSizing": "border-box", "width": "100%", diff --git a/ml_peg/app/utils/register_callbacks.py b/ml_peg/app/utils/register_callbacks.py index 7a1adc675..2e28b7e7e 100644 --- a/ml_peg/app/utils/register_callbacks.py +++ b/ml_peg/app/utils/register_callbacks.py @@ -5,8 +5,18 @@ from copy import deepcopy from typing import Any -from dash import Input, Output, State, callback, ctx +from dash import ( + Input, + Output, + State, + callback, + clientside_callback, + ctx, + dcc, + no_update, +) from dash.exceptions import PreventUpdate +import pandas as pd from ml_peg.analysis.utils.utils import ( calc_metric_scores, @@ -644,3 +654,176 @@ def sync_threshold_inputs(thresholds, metric=metric): entry = cleaned_thresholds[metric] return entry.get("good"), entry.get("bad") raise PreventUpdate + + +def register_download_callbacks(table_id: str) -> None: + """ + Register minimal table download callbacks for CSV, PNG, and SVG. + + Parameters + ---------- + table_id + ID of table to export. + """ + + @callback( + Output(f"{table_id}-download", "data", allow_duplicate=True), + Output(f"{table_id}-download-request", "data"), + Input(f"{table_id}-download-button", "n_clicks"), + State(f"{table_id}-download-format", "value"), + State(table_id, "data"), + State(table_id, "columns"), + prevent_initial_call=True, + ) + def download_table( + n_clicks: int, + download_format: str, + table_data: list[dict] | None, + columns: list[dict] | None, + ) -> tuple[dict | Any, dict | Any]: + """ + Dispatch table download request. + + Parameters + ---------- + n_clicks + Number of clicks on the download button. + download_format + Requested format, one of ``csv``, ``png``, or ``svg``. + table_data + Currently visible table rows. + columns + Current table column metadata. + + Returns + ------- + tuple[dict | Any, dict | Any] + Pair of payloads for ``download`` and ``download-request`` stores. + For CSV, the first item is a Dash download payload and the second is + ``no_update``. For PNG/SVG, the first item is ``no_update`` and the + second item is the client-side capture request. + """ + if not n_clicks or not columns: + raise PreventUpdate + + fmt = (download_format or "csv").lower() + filename_base = table_id.replace("_", "-") + column_ids = [col["id"] for col in columns if isinstance(col.get("id"), str)] + export_cols = [col for col in column_ids if col != "id"] + + if fmt == "csv": + if table_data: + frame = pd.DataFrame(table_data) + frame = frame.reindex(columns=export_cols) + else: + frame = pd.DataFrame(columns=export_cols) + return ( + dcc.send_data_frame( + frame.to_csv, + filename=f"{filename_base}.csv", + index=False, + ), + no_update, + ) + + if fmt in {"png", "svg"}: + return ( + no_update, + { + "element_id": table_id, + "format": fmt, + "filename": f"{filename_base}.{fmt}", + }, + ) + + raise PreventUpdate + + clientside_callback( + """ + function(request) { + const dash = window.dash_clientside; + const noUpdate = dash ? dash.no_update : null; + if (!request) { + return noUpdate; + } + + const tableNode = document.getElementById(request.element_id); + if (!tableNode) { + return noUpdate; + } + + const source = + "https://cdn.jsdelivr.net/npm/html-to-image@1.11.11/dist/html-to-image.min.js"; + const ensureLib = () => { + if (window.htmlToImage) { + return Promise.resolve(window.htmlToImage); + } + if (window._mlpegHtmlToImagePromise) { + return window._mlpegHtmlToImagePromise; + } + window._mlpegHtmlToImagePromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = source; + script.async = true; + script.onload = () => resolve(window.htmlToImage); + script.onerror = () => + reject(new Error("Failed to load html-to-image")); + document.head.appendChild(script); + }); + return window._mlpegHtmlToImagePromise; + }; + + const fmt = (request.format || "png").toLowerCase(); + const filename = request.filename || `table.${fmt}`; + const basePixelRatio = window.devicePixelRatio || 1; + const options = { + cacheBust: true, + pixelRatio: + fmt === "png" + ? Math.max(3, basePixelRatio * 1.5) + : basePixelRatio, + backgroundColor: "#ffffff", + }; + + return ensureLib() + .then((htmlToImage) => { + if (!htmlToImage) { + throw new Error("html-to-image unavailable"); + } + if (fmt === "svg") { + return htmlToImage.toSvg(tableNode, options); + } + return htmlToImage.toPng(tableNode, options); + }) + .then((dataUrl) => { + const parts = String(dataUrl || "").split(","); + if (parts.length < 2) { + return noUpdate; + } + + if (fmt === "svg") { + const content = decodeURIComponent(parts.slice(1).join(",")); + return { + content: content, + filename: filename, + type: "image/svg+xml", + }; + } + + return { + base64: true, + content: parts.slice(1).join(","), + filename: filename, + type: "image/png", + }; + }) + .catch((error) => { + console.error("Table export failed", error); + return noUpdate; + }); + } + """, + Output(f"{table_id}-download", "data", allow_duplicate=True), + Input(f"{table_id}-download-request", "data"), + prevent_initial_call=True, + )