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,
+ )