diff --git a/src/app/results/ResultsTabbedPanel.tsx b/src/app/results/ResultsTabbedPanel.tsx
index ce3852c..1cca568 100644
--- a/src/app/results/ResultsTabbedPanel.tsx
+++ b/src/app/results/ResultsTabbedPanel.tsx
@@ -16,6 +16,8 @@ import SteadyState3DPanel from "./SteadyState3DPanel";
import DownloadPlotButton from "./downloadButtons/DownloadPlotButton";
import DownloadTableButton from "./downloadButtons/DownloadTableButton";
+import DownloadPlot3DButton from "./downloadButtons/DownloadPlot3DButton";
+import DownloadSteadyState3DButton from "./downloadButtons/DownloadSteadyState3DButton";
import { simulationResultAtom } from "@/globals/simulation";
import IconButton from "@/components/IconButton";
@@ -50,6 +52,7 @@ const ResultTabbedPanel = ({ onClose }: ResultTabbedPanelProps) => {
+
>
),
},
@@ -91,6 +94,7 @@ const ResultTabbedPanel = ({ onClose }: ResultTabbedPanelProps) => {
+
>
),
},
diff --git a/src/app/results/SteadyState3DPanel.tsx b/src/app/results/SteadyState3DPanel.tsx
index 1bc2e64..87cb9ee 100644
--- a/src/app/results/SteadyState3DPanel.tsx
+++ b/src/app/results/SteadyState3DPanel.tsx
@@ -1,12 +1,17 @@
-import { useState } from "react";
-import { useAtomValue } from "jotai";
+import { useAtomValue, useAtom } from "jotai";
import styles from "./results.module.css";
import SelectProperty from "@/components/property-list/SelectProperty";
+import BooleanProperty from "@/components/property-list/BooleanProperty";
+import NumericProperty from "@/components/property-list/NumericProperty";
+import PropertyList from "@/components/property-list/PropertyList";
import ArrayBarChart3D from "./visuals/ArrayBarChart3D";
+import { Allotment } from "allotment";
import { simulationResultAtom } from "@/globals/simulation";
+import { graphSettingsAtom, steadyState3DItemAtom } from "@/globals/settings";
+import { PALETTES } from "@/features/colors";
const ITEMS = [
"Jacobian",
@@ -24,12 +29,25 @@ const AXES: { [name: string]: { x: string; y: string; z: string } } = {
const ITEM_OPTIONS = Object.fromEntries(ITEMS.map((i) => [i, i]));
-type Item = (typeof ITEMS)[number];
-
const SteadyState3DPanel = () => {
const result = useAtomValue(simulationResultAtom);
+ const [graphSettings, setGraphSettings] = useAtom(graphSettingsAtom);
+ const [item, setItem] = useAtom(steadyState3DItemAtom);
+
+ const handleZAxisChangeFor = (
+ setting: keyof Pick<
+ typeof graphSettings,
+ "isAutoscaledZ" | "minZ" | "maxZ" | "colorScheme3D"
+ >,
+ ): ((newValue: unknown) => void) => {
+ return (newValue) => {
+ setGraphSettings({ ...graphSettings, [setting]: newValue });
+ };
+ };
- const [item, setItem] = useState- ("Jacobian");
+ const colorSchemeOptions = Object.fromEntries(
+ Object.keys(PALETTES).map((name) => [name, name]),
+ );
if (result?.type !== "steadyState") {
return null;
@@ -44,22 +62,66 @@ const SteadyState3DPanel = () => {
return (
-
-
+
+
+
+
setItem(newValue as typeof item)}
+ />
+
+
+
+
-
-
+
+
+
+
+
+ {!graphSettings.isAutoscaledZ && (
+ newValue < graphSettings.maxZ}
+ />
+ )}
+ {!graphSettings.isAutoscaledZ && (
+ newValue > graphSettings.minZ}
+ />
+ )}
+ void}
+ />
+
+
+
+
+
);
};
diff --git a/src/app/results/downloadButtons/DownloadPlot3DButton.tsx b/src/app/results/downloadButtons/DownloadPlot3DButton.tsx
new file mode 100644
index 0000000..90f3e66
--- /dev/null
+++ b/src/app/results/downloadButtons/DownloadPlot3DButton.tsx
@@ -0,0 +1,395 @@
+import { useAtomValue } from "jotai";
+import * as echarts from "echarts/core";
+
+import DownloadIcon from "@/assets/icons/DownloadIcon.svg?react";
+
+import IconButton from "@/components/IconButton";
+import {
+ DropdownMenuItem,
+ DropdownMenuRoot,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/DropdownMenu";
+
+import { simulationResultAtom } from "@/globals/simulation";
+import {
+ variableSettingssAtom,
+ independentVariableAtom,
+ nameAtom,
+ graphSettingsAtom,
+} from "@/globals/settings";
+import { useScanIndependentVariable } from "@/features/simulation/useScanIndependentVariable";
+import { getColumnsFromResult } from "../getColumnsFromResult";
+import { getDefaultParameterScanColor } from "@/features/colors";
+import { getParameterScanTitle } from "../getParameterScanTitle";
+import { DASH_ARRAYS } from "@/features/lineStyle";
+import { promptDownloadString, promptDownloadUrl } from "@/features/download";
+
+const WIDTH = 800;
+const HEIGHT = 800;
+const MAX_TITLES_TO_SHOW = 12;
+
+const DownloadPlot3DButton = () => {
+ const result = useAtomValue(simulationResultAtom);
+ const variableSettingss = useAtomValue(variableSettingssAtom);
+ const scanIndependentVariable = useScanIndependentVariable();
+ const timeCourseIndependentVariable = useAtomValue(independentVariableAtom);
+ const workspaceName = useAtomValue(nameAtom);
+ const graphSettings = useAtomValue(graphSettingsAtom);
+
+ const downloadName = `3D Plot of ${workspaceName}`;
+
+ const getPlotOptions = () => {
+ if (!result || result.type === "steadyState") return;
+
+ const [columns, independentVariableName] = getColumnsFromResult(
+ result,
+ timeCourseIndependentVariable,
+ scanIndependentVariable,
+ );
+ const independentVariableColumn = columns.find(
+ (c) => c.variableName === independentVariableName,
+ );
+ const parameterSettings =
+ result.type === "parameterScan"
+ ? variableSettingss[result.parameter]
+ : null;
+ if (!independentVariableColumn) return;
+
+ const series = [];
+ const titles = [];
+
+ for (const {
+ variableName,
+ values,
+ parameterValue,
+ scanPercent,
+ } of columns) {
+ if (variableName === independentVariableName) continue;
+
+ const settings = variableSettingss[variableName];
+ if (!settings.visible) continue;
+ let finalColor: string = settings.color;
+ if (result.type === "parameterScan" && result.mode === "timeCourse") {
+ finalColor = getDefaultParameterScanColor(settings.color, scanPercent!);
+ }
+
+ const title =
+ parameterValue !== undefined
+ ? getParameterScanTitle(
+ settings.displayName,
+ parameterSettings!.displayName,
+ parameterValue,
+ )
+ : settings.displayName;
+
+ series.push({
+ name: title,
+ data: values.map((v, i) => [
+ independentVariableColumn.values[i],
+ title,
+ v,
+ ]),
+ type: "line3D",
+ lineStyle: {
+ width: 4 * settings.width,
+ color: finalColor,
+ type: DASH_ARRAYS[settings.lineStyle],
+ },
+ itemStyle: {
+ color: finalColor,
+ opacity: 0,
+ },
+ });
+
+ titles.push(title);
+ }
+
+ return {
+ backgroundColor: graphSettings.backgroundColor,
+ title: {
+ text: "Transition of substances in chemical reaction",
+ left: "center",
+ textStyle: {
+ fontSize: 20,
+ fontWeight: "normal",
+ },
+ },
+ tooltip: {},
+ animation: false,
+ xAxis3D: {
+ name: independentVariableName,
+ type: "value",
+ nameLocation: "end",
+ nameGap: 20,
+ nameTextStyle: {
+ fontSize: 14,
+ color: "#000",
+ },
+ nameRotate: 0,
+ axisPointer: {
+ show: false,
+ },
+ axisLabel: {
+ show: true,
+ color: "#000",
+ fontSize: 12,
+ },
+ },
+ yAxis3D: {
+ name: "Variable",
+ type: "category",
+ data: titles,
+ nameLocation: "middle",
+ nameGap: 20,
+ nameTextStyle: {
+ fontSize: 14,
+ color: "#000",
+ },
+ nameRotate: 90,
+ axisPointer: {
+ show: false,
+ },
+ axisLabel: {
+ show: titles.length <= MAX_TITLES_TO_SHOW,
+ interval: 0,
+ color: "#000",
+ fontSize: 12,
+ },
+ },
+ zAxis3D: {
+ name: "Concentrations",
+ type: "value",
+ nameLocation: "end",
+ nameGap: 15,
+ nameTextStyle: {
+ fontSize: 14,
+ color: "#000",
+ },
+ axisPointer: {
+ show: false,
+ },
+ axisLabel: {
+ show: true,
+ color: "#000",
+ fontSize: 12,
+ },
+ },
+ grid3D: {
+ viewControl: {
+ projection: "orthogonal",
+ distance: 250,
+ alpha: 30,
+ beta: 40,
+ rotateSensitivity: 1,
+ zoomSensitivity: 1,
+ panSensitivity: 1,
+ autoRotate: false,
+ },
+ environment: "#ffffff",
+ axisLine: {
+ lineStyle: {
+ color: "#333",
+ },
+ },
+ axisLabel: {
+ textStyle: {
+ color: "#000",
+ },
+ },
+ },
+ series: series,
+ };
+ };
+
+ const handlePngDownload = () => {
+ const plotOptions = getPlotOptions();
+ if (!plotOptions) return;
+
+ // Create container and add to DOM (hidden) - WebGL needs DOM element
+ const container = document.createElement("div");
+ container.style.position = "absolute";
+ container.style.left = "-9999px";
+ container.style.top = "-9999px";
+ container.style.width = `${WIDTH}px`;
+ container.style.height = `${HEIGHT}px`;
+ document.body.appendChild(container);
+
+ const chart = echarts.init(container, null, {
+ renderer: "canvas",
+ width: WIDTH,
+ height: HEIGHT,
+ });
+ chart.setOption(plotOptions);
+ // Force resize to ensure everything renders
+ chart.resize();
+
+ // Wait for chart to finish rendering - need extra delay for 3D axis titles
+ const capturePng = () => {
+ if (!container.parentNode) return;
+ // Force another resize to ensure axis titles render
+ chart.resize();
+ // eslint-disable-next-line testing-library/render-result-naming-convention
+ const canvas = chart.getRenderedCanvas();
+ if (canvas) {
+ canvas.toBlob((blob) => {
+ if (!blob) {
+ document.body.removeChild(container);
+ chart.dispose();
+ return;
+ }
+
+ const url = URL.createObjectURL(blob);
+ promptDownloadUrl(downloadName, url);
+ URL.revokeObjectURL(url);
+ document.body.removeChild(container);
+ chart.dispose();
+ });
+ } else {
+ document.body.removeChild(container);
+ chart.dispose();
+ }
+ };
+
+ chart.on("finished", () => {
+ // Extra delay to ensure axis titles are fully rendered
+ setTimeout(capturePng, 500);
+ });
+
+ // Fallback timeout - longer for 3D rendering with axis titles
+ setTimeout(capturePng, 2000);
+ };
+
+ const handleSvgDownload = () => {
+ // 3D charts use WebGL which cannot be exported as SVG
+ // Convert the canvas to a data URL and embed it in SVG instead
+ const plotOptions = getPlotOptions();
+ if (!plotOptions) return;
+
+ // Create container and add to DOM (hidden) - WebGL needs DOM element
+ const container = document.createElement("div");
+ container.style.position = "absolute";
+ container.style.left = "-9999px";
+ container.style.top = "-9999px";
+ container.style.width = `${WIDTH}px`;
+ container.style.height = `${HEIGHT}px`;
+ document.body.appendChild(container);
+
+ const chart = echarts.init(container, null, {
+ renderer: "canvas",
+ width: WIDTH,
+ height: HEIGHT,
+ });
+ chart.setOption(plotOptions);
+ // Force resize to ensure everything renders
+ chart.resize();
+
+ // Wait for chart to finish rendering - need extra delay for 3D axis titles
+ const captureSvg = () => {
+ if (!container.parentNode) return;
+ // Force another resize to ensure axis titles render
+ chart.resize();
+ // eslint-disable-next-line testing-library/render-result-naming-convention
+ const canvas = chart.getRenderedCanvas();
+ if (canvas) {
+ const dataUrl = canvas.toDataURL("image/png");
+ const svg = `
+`;
+ promptDownloadString(downloadName, svg, "image/svg+xml");
+ }
+ if (container.parentNode) {
+ document.body.removeChild(container);
+ }
+ chart.dispose();
+ };
+
+ chart.on("finished", () => {
+ // Extra delay to ensure axis titles are fully rendered
+ setTimeout(captureSvg, 500);
+ });
+
+ // Fallback timeout - longer for 3D rendering with axis titles
+ setTimeout(captureSvg, 2000);
+ };
+
+ const handlePdfDownload = async () => {
+ const plotOptions = getPlotOptions();
+ if (!plotOptions) return;
+
+ // lazy import so it doesn't get downloaded at start
+ const { jsPDF } = await import("jspdf");
+
+ // Create container and add to DOM (hidden) - WebGL needs DOM element
+ const container = document.createElement("div");
+ container.style.position = "absolute";
+ container.style.left = "-9999px";
+ container.style.top = "-9999px";
+ container.style.width = `${WIDTH}px`;
+ container.style.height = `${HEIGHT}px`;
+ document.body.appendChild(container);
+
+ const chart = echarts.init(container, null, {
+ renderer: "canvas",
+ width: WIDTH,
+ height: HEIGHT,
+ });
+ chart.setOption(plotOptions);
+ // Force resize to ensure everything renders
+ chart.resize();
+
+ // Wait for chart to finish rendering - need extra delay for 3D axis titles
+ const captureAndSave = () => {
+ if (!container.parentNode) return;
+ // Force another resize to ensure axis titles render
+ chart.resize();
+ // eslint-disable-next-line testing-library/render-result-naming-convention
+ const canvas = chart.getRenderedCanvas();
+ if (canvas) {
+ const pdf = new jsPDF({
+ orientation: "landscape",
+ unit: "px",
+ format: [WIDTH, HEIGHT],
+ });
+ pdf.addImage(canvas, "png", 0, 0, WIDTH, HEIGHT);
+ pdf.save(`${downloadName}.pdf`);
+ }
+ if (container.parentNode) {
+ document.body.removeChild(container);
+ }
+ chart.dispose();
+ };
+
+ chart.on("finished", () => {
+ // Extra delay to ensure axis titles are fully rendered
+ setTimeout(captureAndSave, 500);
+ });
+
+ // Fallback timeout - longer for 3D rendering with axis titles
+ setTimeout(captureAndSave, 2000);
+ };
+
+ if (!result || result.type === "steadyState") {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DownloadPlot3DButton;
+
diff --git a/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx b/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx
new file mode 100644
index 0000000..c54dfef
--- /dev/null
+++ b/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx
@@ -0,0 +1,387 @@
+import { useAtomValue } from "jotai";
+import * as echarts from "echarts/core";
+
+import DownloadIcon from "@/assets/icons/DownloadIcon.svg?react";
+
+import IconButton from "@/components/IconButton";
+import {
+ DropdownMenuItem,
+ DropdownMenuRoot,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/DropdownMenu";
+
+import { simulationResultAtom } from "@/globals/simulation";
+import { graphSettingsAtom, nameAtom, steadyState3DItemAtom } from "@/globals/settings";
+import { getPaletteGradient } from "@/features/colors";
+import { promptDownloadString, promptDownloadUrl } from "@/features/download";
+
+const WIDTH = 800;
+const HEIGHT = 800;
+const MAX_DECIMALS = 6;
+
+const formatWithMaxDecimals = (n: number, maxDecimals: number): string => {
+ return (Math.floor(n * 10 ** maxDecimals) / 10 ** maxDecimals).toString();
+};
+
+const DownloadSteadyState3DButton = () => {
+ const result = useAtomValue(simulationResultAtom);
+ const graphSettings = useAtomValue(graphSettingsAtom);
+ const workspaceName = useAtomValue(nameAtom);
+ const item = useAtomValue(steadyState3DItemAtom);
+
+ const downloadName = `3D Plot of ${workspaceName}`;
+
+ const AXES: { [name: string]: { x: string; y: string; z: string } } = {
+ Jacobian: { x: "X", y: "Y", z: "Z" },
+ "Flux Control": { x: "Reaction", y: "Flux", z: "Coefficient" },
+ "Concentration Control": { x: "Reaction", y: "Species", z: "Coefficient" },
+ Elasticities: { x: "Species", y: "Reaction", z: "Elasticity" },
+ };
+
+ const getPlotOptions = () => {
+ if (result?.type !== "steadyState") return;
+
+ // prettier-ignore
+ const data =
+ item === "Jacobian" ? result.jacobian :
+ item === "Flux Control" ? result.fluxControl :
+ item === "Concentration Control" ? result.concentrationControl :
+ result.elasticities;
+
+ const name = item;
+ const { x, y, z } = AXES[item];
+
+ const allValues = data.values.flat();
+ const dataMin = Math.min(...allValues);
+ const dataMax = Math.max(...allValues);
+
+ const min = graphSettings.isAutoscaledZ
+ ? dataMin
+ : graphSettings.minZ ?? dataMin;
+ const max = graphSettings.isAutoscaledZ
+ ? dataMax
+ : graphSettings.maxZ ?? dataMax;
+
+ const colorGradient = getPaletteGradient(graphSettings.colorScheme3D);
+
+ return {
+ backgroundColor: graphSettings.backgroundColor,
+ title: {
+ text: name,
+ left: "center",
+ textStyle: {
+ fontSize: 20,
+ fontWeight: "normal",
+ },
+ },
+ animation: false,
+ xAxis3D: {
+ name: x,
+ type: "category",
+ data: data.columns,
+ nameLocation: "end",
+ nameGap: 20,
+ nameTextStyle: {
+ fontSize: 14,
+ color: "#000",
+ },
+ nameRotate: 0,
+ axisPointer: {
+ show: false,
+ },
+ axisLabel: {
+ show: true,
+ color: "#000",
+ fontSize: 12,
+ },
+ },
+ yAxis3D: {
+ name: y,
+ type: "category",
+ data: data.rows,
+ nameLocation: "middle",
+ nameGap: 20,
+ nameTextStyle: {
+ fontSize: 14,
+ color: "#000",
+ },
+ nameRotate: 90,
+ axisPointer: {
+ show: false,
+ },
+ axisLabel: {
+ show: true,
+ color: "#000",
+ fontSize: 12,
+ },
+ },
+ zAxis3D: {
+ name: z,
+ type: "value",
+ min: min,
+ max: max,
+ nameLocation: "end",
+ nameGap: 15,
+ nameTextStyle: {
+ fontSize: 14,
+ color: "#000",
+ },
+ axisPointer: {
+ show: false,
+ },
+ axisLabel: {
+ show: true,
+ color: "#000",
+ fontSize: 12,
+ },
+ },
+ tooltip: {
+ formatter: (params: {
+ seriesName: string;
+ value: [number, number, number];
+ }) =>
+ `(${data.columns[params.value[0]]}, ${data.rows[params.value[1]]}): ${formatWithMaxDecimals(params.value[2], MAX_DECIMALS)}`,
+ },
+ visualMap: {
+ min,
+ max,
+ show: false,
+ inRange: {
+ color: colorGradient,
+ },
+ },
+ grid3D: {
+ boxWidth: 80,
+ boxDepth: 80,
+ viewControl: {
+ distance: 250,
+ alpha: 30,
+ beta: 40,
+ rotateSensitivity: 1,
+ zoomSensitivity: 1,
+ panSensitivity: 1,
+ autoRotate: false,
+ },
+ environment: "#ffffff",
+ light: {
+ main: {
+ intensity: 1.2,
+ shadow: false,
+ },
+ ambient: {
+ intensity: 0.3,
+ },
+ },
+ },
+ series: [
+ {
+ type: "bar3D",
+ shading: "lambert",
+ data: data.values.flatMap((row, y) =>
+ row.map((value, x) => [x, y, value]),
+ ),
+ emphasis: {
+ label: {
+ show: false,
+ },
+ itemStyle: {
+ color: "#080",
+ },
+ },
+ },
+ ],
+ };
+ };
+
+ const handlePngDownload = () => {
+ const plotOptions = getPlotOptions();
+ if (!plotOptions) return;
+
+ // Create container and add to DOM (hidden) - WebGL needs DOM element
+ const container = document.createElement("div");
+ container.style.position = "absolute";
+ container.style.left = "-9999px";
+ container.style.top = "-9999px";
+ container.style.width = `${WIDTH}px`;
+ container.style.height = `${HEIGHT}px`;
+ document.body.appendChild(container);
+
+ const chart = echarts.init(container, null, {
+ renderer: "canvas",
+ width: WIDTH,
+ height: HEIGHT,
+ });
+ chart.setOption(plotOptions);
+ // Force resize to ensure everything renders
+ chart.resize();
+
+ // Wait for chart to finish rendering - need extra delay for 3D axis titles
+ const capturePng = () => {
+ if (!container.parentNode) return;
+ // Force another resize to ensure axis titles render
+ chart.resize();
+ // eslint-disable-next-line testing-library/render-result-naming-convention
+ const canvas = chart.getRenderedCanvas();
+ if (canvas) {
+ canvas.toBlob((blob) => {
+ if (!blob) {
+ document.body.removeChild(container);
+ chart.dispose();
+ return;
+ }
+
+ const url = URL.createObjectURL(blob);
+ promptDownloadUrl(downloadName, url);
+ URL.revokeObjectURL(url);
+ document.body.removeChild(container);
+ chart.dispose();
+ });
+ } else {
+ document.body.removeChild(container);
+ chart.dispose();
+ }
+ };
+
+ chart.on("finished", () => {
+ // Extra delay to ensure axis titles are fully rendered
+ setTimeout(capturePng, 500);
+ });
+
+ // Fallback timeout - longer for 3D rendering with axis titles
+ setTimeout(capturePng, 2000);
+ };
+
+ const handleSvgDownload = () => {
+ // 3D charts use WebGL which cannot be exported as SVG
+ // Convert the canvas to a data URL and embed it in SVG instead
+ const plotOptions = getPlotOptions();
+ if (!plotOptions) return;
+
+ // Create container and add to DOM (hidden) - WebGL needs DOM element
+ const container = document.createElement("div");
+ container.style.position = "absolute";
+ container.style.left = "-9999px";
+ container.style.top = "-9999px";
+ container.style.width = `${WIDTH}px`;
+ container.style.height = `${HEIGHT}px`;
+ document.body.appendChild(container);
+
+ const chart = echarts.init(container, null, {
+ renderer: "canvas",
+ width: WIDTH,
+ height: HEIGHT,
+ });
+ chart.setOption(plotOptions);
+ // Force resize to ensure everything renders
+ chart.resize();
+
+ // Wait for chart to finish rendering - need extra delay for 3D axis titles
+ const captureSvg = () => {
+ if (!container.parentNode) return;
+ // Force another resize to ensure axis titles render
+ chart.resize();
+ // eslint-disable-next-line testing-library/render-result-naming-convention
+ const canvas = chart.getRenderedCanvas();
+ if (canvas) {
+ const dataUrl = canvas.toDataURL("image/png");
+ const svg = `
+`;
+ promptDownloadString(downloadName, svg, "image/svg+xml");
+ }
+ if (container.parentNode) {
+ document.body.removeChild(container);
+ }
+ chart.dispose();
+ };
+
+ chart.on("finished", () => {
+ // Extra delay to ensure axis titles are fully rendered
+ setTimeout(captureSvg, 500);
+ });
+
+ // Fallback timeout - longer for 3D rendering with axis titles
+ setTimeout(captureSvg, 2000);
+ };
+
+ const handlePdfDownload = async () => {
+ const plotOptions = getPlotOptions();
+ if (!plotOptions) return;
+
+ // lazy import so it doesn't get downloaded at start
+ const { jsPDF } = await import("jspdf");
+
+ // Create container and add to DOM (hidden) - WebGL needs DOM element
+ const container = document.createElement("div");
+ container.style.position = "absolute";
+ container.style.left = "-9999px";
+ container.style.top = "-9999px";
+ container.style.width = `${WIDTH}px`;
+ container.style.height = `${HEIGHT}px`;
+ document.body.appendChild(container);
+
+ const chart = echarts.init(container, null, {
+ renderer: "canvas",
+ width: WIDTH,
+ height: HEIGHT,
+ });
+ chart.setOption(plotOptions);
+ // Force resize to ensure everything renders
+ chart.resize();
+
+ // Wait for chart to finish rendering - need extra delay for 3D axis titles
+ const captureAndSave = () => {
+ if (!container.parentNode) return;
+ // Force another resize to ensure axis titles render
+ chart.resize();
+ // eslint-disable-next-line testing-library/render-result-naming-convention
+ const canvas = chart.getRenderedCanvas();
+ if (canvas) {
+ const pdf = new jsPDF({
+ orientation: "landscape",
+ unit: "px",
+ format: [WIDTH, HEIGHT],
+ });
+ pdf.addImage(canvas, "png", 0, 0, WIDTH, HEIGHT);
+ pdf.save(`${downloadName}.pdf`);
+ }
+ if (container.parentNode) {
+ document.body.removeChild(container);
+ }
+ chart.dispose();
+ };
+
+ chart.on("finished", () => {
+ // Extra delay to ensure axis titles are fully rendered
+ setTimeout(captureAndSave, 500);
+ });
+
+ // Fallback timeout - longer for 3D rendering with axis titles
+ setTimeout(captureAndSave, 2000);
+ };
+
+ if (result?.type !== "steadyState") {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DownloadSteadyState3DButton;
+
diff --git a/src/app/results/visuals/ArrayBarChart3D.tsx b/src/app/results/visuals/ArrayBarChart3D.tsx
index 548dc42..771ed1d 100644
--- a/src/app/results/visuals/ArrayBarChart3D.tsx
+++ b/src/app/results/visuals/ArrayBarChart3D.tsx
@@ -9,6 +9,8 @@ import { type ECharts } from "echarts/core";
import styles from "./visuals.module.css";
import { type SteadyStateResultItem } from "@/features/simulation/Simulator";
+import { getPaletteGradient } from "@/features/colors";
+import type { Palette } from "@/features/colors";
const MAX_DECIMALS = 6;
const HOVER_COLOR = "#080";
@@ -23,9 +25,23 @@ export interface ArrayBarChart3DProps {
x: string;
y: string;
z: string;
+ isAutoscaledZ?: boolean;
+ minZ?: number;
+ maxZ?: number;
+ colorScheme?: Exclude;
}
-const ArrayBarChart3D = ({ name, data, x, y, z }: ArrayBarChart3DProps) => {
+const ArrayBarChart3D = ({
+ name,
+ data,
+ x,
+ y,
+ z,
+ isAutoscaledZ = true,
+ minZ,
+ maxZ,
+ colorScheme = "BlueRed",
+}: ArrayBarChart3DProps) => {
const containerRef = useRef(null);
const chartRef = useRef(null);
@@ -59,8 +75,14 @@ const ArrayBarChart3D = ({ name, data, x, y, z }: ArrayBarChart3DProps) => {
}
const allValues = data.values.flat();
- const min = Math.min(...allValues);
- const max = Math.max(...allValues);
+ const dataMin = Math.min(...allValues);
+ const dataMax = Math.max(...allValues);
+
+ // Use provided min/max or data min/max based on autoscale setting
+ const min = isAutoscaledZ ? dataMin : (minZ ?? dataMin);
+ const max = isAutoscaledZ ? dataMax : (maxZ ?? dataMax);
+
+ const colorGradient = getPaletteGradient(colorScheme);
chartRef.current?.setOption(
{
@@ -92,6 +114,8 @@ const ArrayBarChart3D = ({ name, data, x, y, z }: ArrayBarChart3DProps) => {
zAxis3D: {
name: z,
type: "value",
+ min: min,
+ max: max,
axisPointer: {
show: false,
},
@@ -108,19 +132,7 @@ const ArrayBarChart3D = ({ name, data, x, y, z }: ArrayBarChart3DProps) => {
max,
show: false,
inRange: {
- color: [
- "#313695",
- "#4575b4",
- "#74add1",
- "#abd9e9",
- "#e0f3f8",
- "#ffffbf",
- "#fee090",
- "#fdae61",
- "#f46d43",
- "#d73027",
- "#a50026",
- ],
+ color: colorGradient,
},
},
grid3D: {
@@ -156,7 +168,7 @@ const ArrayBarChart3D = ({ name, data, x, y, z }: ArrayBarChart3DProps) => {
},
false,
);
- }, [name, data, x, y, z]);
+ }, [name, data, x, y, z, isAutoscaledZ, minZ, maxZ, colorScheme]);
return ;
};
diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx
index 24e5575..82cb441 100644
--- a/src/components/DropdownMenu.tsx
+++ b/src/components/DropdownMenu.tsx
@@ -43,7 +43,13 @@ export interface DropdownMenuItemProps {
export const DropdownMenuItem = ({ name, onSelect }: DropdownMenuItemProps) => {
return (
-
+ {
+ event.preventDefault();
+ onSelect();
+ }}
+ >
{name}
);
diff --git a/src/features/colors.ts b/src/features/colors.ts
index 816883d..957e667 100644
--- a/src/features/colors.ts
+++ b/src/features/colors.ts
@@ -58,3 +58,19 @@ export const getPaletteColor = (
.mix(PALETTES[palette].start, PALETTES[palette].end, percent, "oklab")
.hex("rgb");
};
+
+/**
+ * Generate a gradient array of colors from a palette for use in echarts visualMap.
+ * @param palette - The palette to use (excluding "Custom")
+ * @param steps - Number of color steps in the gradient (default: 11)
+ * @returns Array of hex color strings
+ */
+export const getPaletteGradient = (
+ palette: Exclude,
+ steps: number = 11,
+): string[] => {
+ const scale = chroma.scale([PALETTES[palette].start, PALETTES[palette].end]);
+ return Array.from({ length: steps }, (_, i) =>
+ scale(i / (steps - 1)).hex("rgb"),
+ );
+};
diff --git a/src/globals/model.ts b/src/globals/model.ts
index 6573851..6c81eb8 100644
--- a/src/globals/model.ts
+++ b/src/globals/model.ts
@@ -85,7 +85,7 @@ export const patchVariablesSettings = (
variable.category === "ODEs",
color: getDefaultColorForIndex(added),
lineStyle: "solid",
- width: 2,
+ width: 2.5,
};
added += 1;
}
diff --git a/src/globals/settings.ts b/src/globals/settings.ts
index 2b7824a..b04da97 100644
--- a/src/globals/settings.ts
+++ b/src/globals/settings.ts
@@ -86,6 +86,12 @@ export interface GraphSettings {
minY: number;
maxY: number;
+ isAutoscaledZ: boolean;
+ minZ: number;
+ maxZ: number;
+
+ colorScheme3D: Exclude;
+
margin: number;
xAxis: AxisSettings;
@@ -162,6 +168,12 @@ export const defaultGraphSettings: GraphSettings = {
minY: 0,
maxY: 10,
+ isAutoscaledZ: true,
+ minZ: 0,
+ maxZ: 10,
+
+ colorScheme3D: "BlueRed",
+
margin: 70,
xAxis: {
@@ -210,3 +222,6 @@ export const defaultGraphSettings: GraphSettings = {
};
export const graphSettingsAtom = atom(defaultGraphSettings);
+
+export type SteadyState3DItem = "Jacobian" | "Flux Control" | "Concentration Control" | "Elasticities";
+export const steadyState3DItemAtom = atom("Jacobian");