diff --git a/.gitignore b/.gitignore index 6b68b80ca..3f5f8c439 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/ certs/ diff --git a/ml_peg/app/build_app.py b/ml_peg/app/build_app.py index 90f7a2540..8d3f3a9d1 100644 --- a/ml_peg/app/build_app.py +++ b/ml_peg/app/build_app.py @@ -15,6 +15,7 @@ from ml_peg.analysis.utils.utils import calc_table_scores, get_table_style from ml_peg.app import APP_ROOT from ml_peg.app.utils.build_components import ( + build_download_controls, build_faqs, build_footer, build_weight_components, @@ -475,6 +476,7 @@ def build_category( header="Benchmark weights", table=summary_table, include_store=False, + include_download_controls=False, column_widths=getattr(summary_table, "column_widths", None), ) @@ -542,7 +544,12 @@ def build_category_page_layout( H1(category_title), H3(category_description), Div( - [Div(summary_table), Br(), weight_components], + [ + build_download_controls(summary_table.id, row=True), + Div(summary_table), + Br(), + weight_components, + ], style={"width": "fit-content"}, ), Div( @@ -1124,7 +1131,12 @@ def select_page( [ H1("Categories Summary"), Div( - [Div(summary_table), Br(), weight_components], + [ + build_download_controls(summary_table.id, row=True), + Div(summary_table), + Br(), + weight_components, + ], style={"width": "fit-content"}, ), build_faqs(), @@ -1175,6 +1187,7 @@ def build_full_app(full_app: Dash, category: str = "*") -> None: header="Category weights", table=summary_table, include_store=False, + include_download_controls=False, column_widths=summary_table.column_widths, ) # Build summary and category pages and navigation diff --git a/ml_peg/app/data/table_download/capture.js b/ml_peg/app/data/table_download/capture.js new file mode 100644 index 000000000..1c95e46b7 --- /dev/null +++ b/ml_peg/app/data/table_download/capture.js @@ -0,0 +1,111 @@ +/* + * Dash clientside callback for PNG/SVG table export. + * + * Python registers this as table_download.captureTable. The callback receives a + * request from register_download_callbacks, captures the actual Dash table already + * drawn in the browser, and returns a dcc.Download-compatible payload. + * + * This intentionally captures the browser's drawn table instead of rebuilding an + * image from table data. Rebuiling the table is much much easier, but results in an + * image that doenst actually look anything like our ml-peg tables. + * Dash has already applied style_data_conditional, + * tooltip/header changes, warning colours, column widths, and CSS assets, so + * html-to-image can export the same visual table the user sees. + */ +window.dash_clientside = Object.assign({}, window.dash_clientside, { + table_download: { + captureTable: function (request) { + const dash = window.dash_clientside; + const noUpdate = dash ? dash.no_update : null; + if (!request) { + return noUpdate; + } + + // register_download_callbacks passes the Dash DataTable component id. + // Capturing this existing page element preserves the current appearance, + // including conditional cell colours and any user-adjusted table state. + 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"; + + // Load html-to-image only when the user asks for an image export. Cache the + // promise so repeated downloads do not append duplicate script tags. + 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; + + // A higher PNG pixel ratio keeps text and colour blocks crisp in the export. + // SVG is vector output, so the browser pixel ratio is enough there. + 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"); + } + // html-to-image reads the table already drawn by the browser, including + // computed styles, so the export matches the live Dash table instead of + // just the raw table values. + if (fmt === "svg") { + return htmlToImage.toSvg(tableNode, options); + } + return htmlToImage.toPng(tableNode, options); + }) + .then((dataUrl) => { + // html-to-image returns a data URL. Dash downloads need the payload split + // into content plus metadata. + 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", + }; + } + + // PNG content remains base64 encoded so dcc.Download can write it as bytes. + return { + base64: true, + content: parts.slice(1).join(","), + filename: filename, + type: "image/png", + }; + }) + .catch((error) => { + console.error("Table export failed", error); + return noUpdate; + }); + }, + }, +}); 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..1b8308c75 --- /dev/null +++ b/ml_peg/app/data/table_download/controls.css @@ -0,0 +1,96 @@ +/* + * Dash loads this file automatically from the app assets folder. These rules + * support the shared table and plot download UI. + * + * Inline container styles only lay out the wrapper Div. These rules target + * Dropdown internals and button interaction states, which cannot be reached + * from the wrapper style. + */ + +/* Normalise Dash Dropdown internals so it aligns with the Download button. */ +.download-format { + box-sizing: border-box; + height: 30px; +} + +.download-format .Select { + box-sizing: border-box; + min-height: 30px; + height: 30px; +} + +.download-format .Select-control { + box-sizing: border-box; + min-height: 30px; + height: 30px; + border: 1px solid #8aa1b4; + border-radius: 6px; + box-shadow: none; +} + +.download-format .Select-placeholder, +.download-format .Select--single > .Select-control .Select-value { + line-height: 28px; + color: #243746; + padding-left: 10px; +} + +.download-format .Select-input { + height: 28px; +} + +.download-format .Select-input > input { + line-height: 28px; + padding-top: 0; + padding-bottom: 0; +} + +.download-format .Select-arrow-zone { + width: 24px; + padding-top: 0; + padding-bottom: 0; +} + +/* Small primary action used next to the format dropdown. */ +.download-button { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 30px; + min-height: 30px; + padding: 0 12px; + border: 1px solid #0b5cad; + border-radius: 6px; + background: #0b73d9; + color: #ffffff; + font-size: 12px; + font-weight: 600; + line-height: 1; + white-space: nowrap; + box-shadow: 0 1px 2px rgb(15 23 42 / 18%); + cursor: pointer; + vertical-align: top; + transition: + background-color 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease; +} + +/* Interaction states mirror the app's restrained blue action styling. */ +.download-button:hover { + background: #095fb5; + border-color: #084f96; + box-shadow: 0 2px 5px rgb(15 23 42 / 24%); +} + +.download-button:active { + background: #084f96; + box-shadow: inset 0 1px 2px rgb(15 23 42 / 28%); +} + +.download-button:focus-visible, +.download-format .Select.is-focused > .Select-control { + outline: 2px solid #8ec5ff; + outline-offset: 2px; +} diff --git a/ml_peg/app/data/table_download/plot_capture.js b/ml_peg/app/data/table_download/plot_capture.js new file mode 100644 index 000000000..320fea267 --- /dev/null +++ b/ml_peg/app/data/table_download/plot_capture.js @@ -0,0 +1,168 @@ +/* + * Dash clientside callback for plot export. + * + * Python registers this once as plot_download.downloadPlot. The callback reads + * the rendered Plotly graph in the browser and exports either the trace x/y + * data as CSV, the current figure as PNG/SVG, or an interactive HTML file. + */ +window.dash_clientside = Object.assign({}, window.dash_clientside, { + plot_download: { + downloadPlot: function (nClicks, downloadFormat, graphId) { + const dash = window.dash_clientside; + const noUpdate = dash ? dash.no_update : null; + if (!nClicks || !graphId) { + return noUpdate; + } + + const graphContainer = document.getElementById(graphId); + if (!graphContainer) { + return noUpdate; + } + + // dcc.Graph owns an outer container; Plotly draws the actual figure in the + // inner .js-plotly-plot element. + const plotNode = graphContainer.classList.contains("js-plotly-plot") + ? graphContainer + : graphContainer.querySelector(".js-plotly-plot"); + if (!plotNode) { + return noUpdate; + } + + const format = (downloadFormat || "csv").toLowerCase(); + const filenameBase = String(graphId).replace(/[\s_]+/g, "-"); + + const jsonScript = (value) => + JSON.stringify(value || null).replace(/<\/script/gi, "<\\/script"); + const escapeHtml = (value) => + String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + + if (format === "csv") { + const csvValue = (value) => { + if (value === undefined || value === null) { + return ""; + } + const text = + typeof value === "object" ? JSON.stringify(value) : String(value); + if (/[",\n\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; + }; + + const rows = [["trace", "point_index", "x", "y"]]; + (plotNode.data || []).forEach((trace, traceIndex) => { + const xValues = Array.isArray(trace.x) + ? trace.x + : trace.x == null + ? [] + : [trace.x]; + const yValues = Array.isArray(trace.y) + ? trace.y + : trace.y == null + ? [] + : [trace.y]; + const pointCount = Math.max(xValues.length, yValues.length); + const traceName = trace.name || `trace_${traceIndex}`; + for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) { + rows.push([ + traceName, + pointIndex, + pointIndex < xValues.length ? xValues[pointIndex] : "", + pointIndex < yValues.length ? yValues[pointIndex] : "", + ]); + } + }); + + return { + content: rows.map((row) => row.map(csvValue).join(",")).join("\n"), + filename: `${filenameBase}.csv`, + type: "text/csv", + }; + } + + if (format === "html") { + const plotlyVersion = window.Plotly && window.Plotly.version; + const plotlySource = plotlyVersion + ? `https://cdn.plot.ly/plotly-${plotlyVersion}.min.js` + : "https://cdn.plot.ly/plotly-latest.min.js"; + const title = + (plotNode.layout && plotNode.layout.title && plotNode.layout.title.text) || + filenameBase; + + return { + content: ` + + + + ${escapeHtml(title)} + + + + +
+ + +`, + filename: `${filenameBase}.html`, + type: "text/html", + }; + } + + if ( + !["png", "svg"].includes(format) || + !window.Plotly || + !window.Plotly.toImage + ) { + return noUpdate; + } + + const fullLayout = plotNode._fullLayout || {}; + const width = fullLayout.width || plotNode.clientWidth || 800; + const height = fullLayout.height || plotNode.clientHeight || 600; + + return window.Plotly.toImage(plotNode, { + format: format, + width: width, + height: height, + }) + .then((dataUrl) => { + const parts = String(dataUrl || "").split(","); + if (parts.length < 2) { + return noUpdate; + } + + const payload = parts.slice(1).join(","); + if (format === "png") { + return { + base64: true, + content: payload, + filename: `${filenameBase}.png`, + type: "image/png", + }; + } + + return { + content: decodeURIComponent(payload), + filename: `${filenameBase}.svg`, + type: "image/svg+xml", + }; + }) + .catch((error) => { + console.error("Plot export failed", error); + return noUpdate; + }); + }, + }, +}); diff --git a/ml_peg/app/utils/build_callbacks.py b/ml_peg/app/utils/build_callbacks.py index 5e1b76e8d..da51d03cd 100644 --- a/ml_peg/app/utils/build_callbacks.py +++ b/ml_peg/app/utils/build_callbacks.py @@ -23,9 +23,31 @@ PERIODIC_TABLE_POSITIONS, PERIODIC_TABLE_ROWS, ) +from ml_peg.app.utils.build_components import build_plot_download_controls +from ml_peg.app.utils.register_callbacks import register_plot_download_callbacks from ml_peg.app.utils.weas import generate_weas_html +def _plot_with_download_controls(graph: Graph) -> Div: + """ + Wrap a Plotly graph with CSV/PNG/SVG/HTML download controls. + + Parameters + ---------- + graph + Dash graph component. + + Returns + ------- + Div + Graph with download controls above it. + """ + graph_id = getattr(graph, "id", None) + if not isinstance(graph_id, str): + return Div(graph) + return Div([build_plot_download_controls(graph_id), graph]) + + def plot_from_table_column( table_id: str, plot_id: str, column_to_plot: dict[str, Graph] ) -> None: @@ -41,6 +63,7 @@ def plot_from_table_column( column_to_plot Dictionary relating table headers (keys) and plot to show (values). """ + register_plot_download_callbacks() @callback( Output(plot_id, "children"), @@ -66,7 +89,7 @@ def show_plot(active_cell) -> Div: column_id = active_cell.get("column_id", None) if column_id: if column_id in column_to_plot: - return Div(column_to_plot[column_id]), None + return _plot_with_download_controls(column_to_plot[column_id]), None raise PreventUpdate raise ValueError("Invalid column_id") @@ -92,6 +115,7 @@ def plot_from_table_cell( Optional table data to check for None/missing values. If provided, cells with None values will show "No data available" message. """ + register_plot_download_callbacks() @callback( Output(plot_id, "children"), @@ -131,7 +155,7 @@ def show_plot(active_cell, current_table_data) -> Div: pass # Fall through to normal handling if row_id in cell_to_plot and column_id in cell_to_plot[row_id]: - return Div(cell_to_plot[row_id][column_id]), None + return _plot_with_download_controls(cell_to_plot[row_id][column_id]), None return Div("Click on a metric to view plot."), None @@ -152,6 +176,7 @@ def plot_from_scatter( plots_list List of plots to show, in same order as scatter data. """ + register_plot_download_callbacks() @callback( Output(plot_id, "children", allow_duplicate=True), @@ -177,7 +202,7 @@ def show_plot(click_data) -> Div: idx = click_data["points"][0]["pointNumber"] if idx >= 0 and idx < len(plots_list): - return Div(plots_list[idx]) + return _plot_with_download_controls(plots_list[idx]) return Div("Click on a metric to view plot.") diff --git a/ml_peg/app/utils/build_components.py b/ml_peg/app/utils/build_components.py index 0bf867e34..31c9e5e40 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, @@ -132,6 +133,7 @@ def build_weight_components( table: DataTable, *, use_thresholds: bool = False, + include_download_controls: bool = True, include_store: bool = True, column_widths: dict[str, int] | None = None, thresholds: Thresholds | None = None, @@ -149,6 +151,8 @@ 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 in the Score column slot. include_store Whether to include this table's weight ``dcc.Store`` in the returned component. Set to ``False`` when that store is already created elsewhere, @@ -249,7 +253,9 @@ def build_weight_components( "border": "1px solid transparent", # #dee2e6 or transparent }, ), - Div( + build_download_controls(table.id) + if include_download_controls + else Div( "", style={ "width": "100%", @@ -320,9 +326,143 @@ 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, *, row: bool = False) -> Div: + """ + Build minimal table download controls. + + Parameters + ---------- + table_id + ID of the table to export. + row + When True, arrange the dropdown and button horizontally. + + Returns + ------- + Div + Download controls and target components. + """ + if row: + container_style = { + "display": "flex", + "flexDirection": "row", + "alignItems": "flex-end", + "gap": "8px", + "flexShrink": "0", + "marginBottom": "16px", + } + else: + container_style = { + "display": "flex", + "flexDirection": "column", + "alignItems": "center", + "gap": "6px", + "width": "100%", + "padding": "2px 0px", + } + + return Div( + [ + Dropdown( + id=f"{table_id}-download-format", + className="download-format table-download-format", + options=[ + {"label": "CSV", "value": "csv"}, + {"label": "PNG", "value": "png"}, + {"label": "SVG", "value": "svg"}, + ], + value="csv", + clearable=False, + searchable=False, + style={ + "width": "76px", + "height": "30px", + "fontSize": "12px", + }, + ), + Button( + "Download table", + id=f"{table_id}-download-button", + className="download-button table-download-button", + n_clicks=0, + style={ + "width": "118px", + }, + ), + Download(id=f"{table_id}-download"), + Store(id=f"{table_id}-download-request", storage_type="memory"), + ], + style=container_style, + ) + + +def build_plot_download_controls(graph_id: str) -> Div: + """ + Build plot download controls for CSV, PNG, SVG, and HTML exports. + + Parameters + ---------- + graph_id + ID of the Plotly graph to export. + + Returns + ------- + Div + Download controls and target components. + """ + return Div( + [ + Dropdown( + id={"type": "plot-download-format", "index": graph_id}, + className="download-format plot-download-format", + options=[ + {"label": "CSV", "value": "csv"}, + {"label": "PNG", "value": "png"}, + {"label": "SVG", "value": "svg"}, + {"label": "HTML", "value": "html"}, + ], + value="csv", + clearable=False, + searchable=False, + style={ + "width": "76px", + "height": "30px", + "fontSize": "12px", + }, + ), + Button( + "Download plot", + id={"type": "plot-download-button", "index": graph_id}, + className="download-button plot-download-button", + n_clicks=0, + style={ + "width": "112px", + }, + ), + Download(id={"type": "plot-download", "index": graph_id}), + Store( + id={"type": "plot-download-target", "index": graph_id}, + storage_type="memory", + data=graph_id, + ), + ], + style={ + "display": "flex", + "flexDirection": "row", + "alignItems": "flex-end", + "gap": "8px", + "flexShrink": "0", + "marginTop": "12px", + "marginBottom": "0px", + }, + ) + + def build_faqs() -> Div: """ Build FAQ section with collapsible dropdowns from YAML file. @@ -662,7 +802,7 @@ def build_test_layout( # "borderRadius": "5px", }, ), - Br(), + Div(style={"height": "4px"}), ] ) @@ -676,39 +816,40 @@ def build_test_layout( ) ) - if thresholds is None: - raise ValueError("Threshold metadata must be provided for benchmark layouts.") - - reserved = {"MLIP", "Score", "id"} - metric_columns = [ - col["id"] for col in table.columns if col.get("id") not in reserved - ] - layout_contents.append( - Store( - id=f"{table.id}-raw-data-store", - storage_type="session", - data=table.data, + # Inline normalization thresholds when metadata is supplied + threshold_controls = None + if thresholds is not None: + reserved = {"MLIP", "Score", "id"} + metric_columns = [ + col["id"] for col in table.columns if col.get("id") not in reserved + ] + layout_contents.append( + Store( + id=f"{table.id}-raw-data-store", + storage_type="session", + data=table.data, + ) ) - ) - layout_contents.append( - Store( - id=f"{table.id}-raw-tooltip-store", - storage_type="session", - data=table.tooltip_header, + layout_contents.append( + Store( + id=f"{table.id}-raw-tooltip-store", + storage_type="session", + data=table.tooltip_header, + ) + ) + threshold_controls = build_threshold_inputs( + table_columns=metric_columns, + thresholds=thresholds, + table_id=table.id, + column_widths=column_widths, ) - ) - threshold_controls = build_threshold_inputs( - table_columns=metric_columns, - thresholds=thresholds, - table_id=table.id, - column_widths=column_widths, - ) # Add metric-weight controls for every benchmark table metric_weights = build_weight_components( header="Metric Weights", table=table, - use_thresholds=True, + use_thresholds=thresholds is not None, + include_download_controls=False, column_widths=column_widths, thresholds=thresholds, ) @@ -716,23 +857,31 @@ def build_test_layout( # Build the controls element before the table wrapper so both can go into the # same fit-content div. The controls use width:100% of that wrapper, which # equals the table width, keeping the columns aligned. - controls_visual = Div( - [ - Div(threshold_controls, style={"marginBottom": "0px"}), - Div(metric_weights, style={"marginTop": "0"}), - ], - style={ - "backgroundColor": "#f8f9fa", - "border": "1px solid #dee2e6", - "borderRadius": "6px", - "padding": "0px 0px 0px 0px", # top right bottom left - "marginTop": "-5px", - "boxSizing": "border-box", - "width": "100%", - }, - ) + if thresholds is not None: + controls_visual = Div( + [ + Div(threshold_controls, style={"marginBottom": "0px"}), + Div(metric_weights, style={"marginTop": "0"}), + ], + style={ + "backgroundColor": "#f8f9fa", + "border": "1px solid #dee2e6", + "borderRadius": "6px", + "padding": "0px 0px 0px 0px", # top right bottom left + "marginTop": "-5px", + "boxSizing": "border-box", + "width": "100%", + }, + ) + else: + controls_visual = metric_weights - table_section = [Div(table), Br(), controls_visual] + table_section = [ + build_download_controls(table.id, row=True), + Div(table), + Br(), + controls_visual, + ] layout_contents.append(Div(table_section, style={"width": "fit-content"})) if extra_components: diff --git a/ml_peg/app/utils/register_callbacks.py b/ml_peg/app/utils/register_callbacks.py index 3f7267c2e..8080b8d4c 100644 --- a/ml_peg/app/utils/register_callbacks.py +++ b/ml_peg/app/utils/register_callbacks.py @@ -5,8 +5,20 @@ from copy import deepcopy from typing import Any -from dash import Input, Output, State, callback, ctx +from dash import ( + MATCH, + ClientsideFunction, + 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, @@ -24,6 +36,8 @@ get_scores, ) +_PLOT_DOWNLOAD_CALLBACK_REGISTERED = False + def apply_level_of_theory_warnings( rows: list[dict[str, Any]], @@ -794,3 +808,124 @@ def sync_threshold_inputs(thresholds, metric=metric): entry = cleaned_thresholds[metric] return entry.get("good"), entry.get("bad") raise PreventUpdate + + +def register_plot_download_callbacks() -> None: + """Register one generic plot download callback for CSV and SVG.""" + global _PLOT_DOWNLOAD_CALLBACK_REGISTERED + if _PLOT_DOWNLOAD_CALLBACK_REGISTERED: + return + _PLOT_DOWNLOAD_CALLBACK_REGISTERED = True + + clientside_callback( + ClientsideFunction( + namespace="plot_download", + function_name="downloadPlot", + ), + Output({"type": "plot-download", "index": MATCH}, "data"), + Input({"type": "plot-download-button", "index": MATCH}, "n_clicks"), + State({"type": "plot-download-format", "index": MATCH}, "value"), + State({"type": "plot-download-target", "index": MATCH}, "data"), + prevent_initial_call=True, + ) + + +def register_download_callbacks(table_id: str) -> None: + """ + Register minimal table download callbacks for CSV, PNG, and SVG. + + CSV exports are generated from the table's Dash data payload. PNG/SVG exports + are different: Python sends a small request to the browser, then the browser + captures the table exactly as it has already been drawn on the page. That is + what preserves conditional colours, warning styles, current column headers, and + the table's CSS layout. + + 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"}: + # Image exports need the already-rendered table, not a new table recreated + # from raw values. Send the target table id to the browser-side asset. + return ( + no_update, + { + "element_id": table_id, + "format": fmt, + "filename": f"{filename_base}.{fmt}", + }, + ) + + raise PreventUpdate + + clientside_callback( + ClientsideFunction( + namespace="table_download", + function_name="captureTable", + ), + Output(f"{table_id}-download", "data", allow_duplicate=True), + Input(f"{table_id}-download-request", "data"), + prevent_initial_call=True, + )