From eee7ff9ec3e9cb57035d840e1480cb40babf23f4 Mon Sep 17 00:00:00 2001 From: Sophia Zhang Date: Tue, 21 Oct 2025 00:34:20 -0700 Subject: [PATCH 1/5] add autoscale z check box for 3d bar graph --- src/app/results/SteadyState3DPanel.tsx | 88 +++++++++++++++++---- src/app/results/visuals/ArrayBarChart3D.tsx | 26 +++++- src/globals/settings.ts | 8 ++ 3 files changed, 101 insertions(+), 21 deletions(-) diff --git a/src/app/results/SteadyState3DPanel.tsx b/src/app/results/SteadyState3DPanel.tsx index 1bc2e64..ce737e0 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 } from "@/globals/settings"; const ITEMS = [ "Jacobian", @@ -28,9 +33,21 @@ type Item = (typeof ITEMS)[number]; const SteadyState3DPanel = () => { const result = useAtomValue(simulationResultAtom); + const [graphSettings, setGraphSettings] = useAtom(graphSettingsAtom); const [item, setItem] = useState("Jacobian"); + const handleZAxisChangeFor = ( + setting: keyof Pick< + typeof graphSettings, + "isAutoscaledZ" | "minZ" | "maxZ" + >, + ): ((newValue: unknown) => void) => { + return (newValue) => { + setGraphSettings({ ...graphSettings, [setting]: newValue }); + }; + }; + if (result?.type !== "steadyState") { return null; } @@ -44,22 +61,59 @@ const SteadyState3DPanel = () => { return (
-
- - - -
+ +
+
+ + + +
+
+ + +
+
+ + + {!graphSettings.isAutoscaledZ && ( + newValue < graphSettings.maxZ} + /> + )} + {!graphSettings.isAutoscaledZ && ( + newValue > graphSettings.minZ} + /> + )} + +
+
+
+
); }; diff --git a/src/app/results/visuals/ArrayBarChart3D.tsx b/src/app/results/visuals/ArrayBarChart3D.tsx index 548dc42..ab00151 100644 --- a/src/app/results/visuals/ArrayBarChart3D.tsx +++ b/src/app/results/visuals/ArrayBarChart3D.tsx @@ -23,9 +23,21 @@ export interface ArrayBarChart3DProps { x: string; y: string; z: string; + isAutoscaledZ?: boolean; + minZ?: number; + maxZ?: number; } -const ArrayBarChart3D = ({ name, data, x, y, z }: ArrayBarChart3DProps) => { +const ArrayBarChart3D = ({ + name, + data, + x, + y, + z, + isAutoscaledZ = true, + minZ, + maxZ, +}: ArrayBarChart3DProps) => { const containerRef = useRef(null); const chartRef = useRef(null); @@ -59,8 +71,12 @@ 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); chartRef.current?.setOption( { @@ -92,6 +108,8 @@ const ArrayBarChart3D = ({ name, data, x, y, z }: ArrayBarChart3DProps) => { zAxis3D: { name: z, type: "value", + min: min, + max: max, axisPointer: { show: false, }, @@ -156,7 +174,7 @@ const ArrayBarChart3D = ({ name, data, x, y, z }: ArrayBarChart3DProps) => { }, false, ); - }, [name, data, x, y, z]); + }, [name, data, x, y, z, isAutoscaledZ, minZ, maxZ]); return
; }; diff --git a/src/globals/settings.ts b/src/globals/settings.ts index 2b7824a..080d762 100644 --- a/src/globals/settings.ts +++ b/src/globals/settings.ts @@ -86,6 +86,10 @@ export interface GraphSettings { minY: number; maxY: number; + isAutoscaledZ: boolean; + minZ: number; + maxZ: number; + margin: number; xAxis: AxisSettings; @@ -162,6 +166,10 @@ export const defaultGraphSettings: GraphSettings = { minY: 0, maxY: 10, + isAutoscaledZ: true, + minZ: 0, + maxZ: 10, + margin: 70, xAxis: { From 42cfaae9bebeab898590bbc4d551ad0ea28b23c8 Mon Sep 17 00:00:00 2001 From: Sophia Zhang Date: Tue, 4 Nov 2025 22:16:32 -0800 Subject: [PATCH 2/5] added color schemes and thickened 2d plot line --- src/app/results/SteadyState3DPanel.tsx | 14 ++++++++++++- src/app/results/visuals/ArrayBarChart3D.tsx | 22 ++++++++------------- src/features/colors.ts | 16 +++++++++++++++ src/globals/model.ts | 2 +- src/globals/settings.ts | 4 ++++ 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/app/results/SteadyState3DPanel.tsx b/src/app/results/SteadyState3DPanel.tsx index ce737e0..b9cbfd6 100644 --- a/src/app/results/SteadyState3DPanel.tsx +++ b/src/app/results/SteadyState3DPanel.tsx @@ -12,6 +12,7 @@ import { Allotment } from "allotment"; import { simulationResultAtom } from "@/globals/simulation"; import { graphSettingsAtom } from "@/globals/settings"; +import { PALETTES } from "@/features/colors"; const ITEMS = [ "Jacobian", @@ -40,7 +41,7 @@ const SteadyState3DPanel = () => { const handleZAxisChangeFor = ( setting: keyof Pick< typeof graphSettings, - "isAutoscaledZ" | "minZ" | "maxZ" + "isAutoscaledZ" | "minZ" | "maxZ" | "colorScheme3D" >, ): ((newValue: unknown) => void) => { return (newValue) => { @@ -48,6 +49,10 @@ const SteadyState3DPanel = () => { }; }; + const colorSchemeOptions = Object.fromEntries( + Object.keys(PALETTES).map((name) => [name, name]), + ); + if (result?.type !== "steadyState") { return null; } @@ -80,6 +85,7 @@ const SteadyState3DPanel = () => { isAutoscaledZ={graphSettings.isAutoscaledZ} minZ={graphSettings.minZ} maxZ={graphSettings.maxZ} + colorScheme={graphSettings.colorScheme3D} />
@@ -109,6 +115,12 @@ const SteadyState3DPanel = () => { validator={(newValue) => newValue > graphSettings.minZ} /> )} + void} + /> diff --git a/src/app/results/visuals/ArrayBarChart3D.tsx b/src/app/results/visuals/ArrayBarChart3D.tsx index ab00151..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"; @@ -26,6 +28,7 @@ export interface ArrayBarChart3DProps { isAutoscaledZ?: boolean; minZ?: number; maxZ?: number; + colorScheme?: Exclude; } const ArrayBarChart3D = ({ @@ -37,6 +40,7 @@ const ArrayBarChart3D = ({ isAutoscaledZ = true, minZ, maxZ, + colorScheme = "BlueRed", }: ArrayBarChart3DProps) => { const containerRef = useRef(null); const chartRef = useRef(null); @@ -78,6 +82,8 @@ const ArrayBarChart3D = ({ const min = isAutoscaledZ ? dataMin : (minZ ?? dataMin); const max = isAutoscaledZ ? dataMax : (maxZ ?? dataMax); + const colorGradient = getPaletteGradient(colorScheme); + chartRef.current?.setOption( { title: { @@ -126,19 +132,7 @@ const ArrayBarChart3D = ({ max, show: false, inRange: { - color: [ - "#313695", - "#4575b4", - "#74add1", - "#abd9e9", - "#e0f3f8", - "#ffffbf", - "#fee090", - "#fdae61", - "#f46d43", - "#d73027", - "#a50026", - ], + color: colorGradient, }, }, grid3D: { @@ -174,7 +168,7 @@ const ArrayBarChart3D = ({ }, false, ); - }, [name, data, x, y, z, isAutoscaledZ, minZ, maxZ]); + }, [name, data, x, y, z, isAutoscaledZ, minZ, maxZ, colorScheme]); return
; }; 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 080d762..fed636a 100644 --- a/src/globals/settings.ts +++ b/src/globals/settings.ts @@ -90,6 +90,8 @@ export interface GraphSettings { minZ: number; maxZ: number; + colorScheme3D: Exclude; + margin: number; xAxis: AxisSettings; @@ -170,6 +172,8 @@ export const defaultGraphSettings: GraphSettings = { minZ: 0, maxZ: 10, + colorScheme3D: "BlueRed", + margin: 70, xAxis: { From 0c2f2767af4a1c1c8cb733640cf8e168f73dc6b5 Mon Sep 17 00:00:00 2001 From: Sophia Zhang Date: Tue, 18 Nov 2025 19:58:13 -0800 Subject: [PATCH 3/5] add download for 3d model --- src/app/results/ResultsTabbedPanel.tsx | 4 + src/app/results/SteadyState3DPanel.tsx | 10 +- .../downloadButtons/DownloadPlot3DButton.tsx | 387 +++++++++++++++++ .../DownloadSteadyState3DButton.tsx | 389 ++++++++++++++++++ src/components/DropdownMenu.tsx | 8 +- src/globals/settings.ts | 3 + 6 files changed, 793 insertions(+), 8 deletions(-) create mode 100644 src/app/results/downloadButtons/DownloadPlot3DButton.tsx create mode 100644 src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx 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 b9cbfd6..87cb9ee 100644 --- a/src/app/results/SteadyState3DPanel.tsx +++ b/src/app/results/SteadyState3DPanel.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { useAtomValue, useAtom } from "jotai"; import styles from "./results.module.css"; @@ -11,7 +10,7 @@ import ArrayBarChart3D from "./visuals/ArrayBarChart3D"; import { Allotment } from "allotment"; import { simulationResultAtom } from "@/globals/simulation"; -import { graphSettingsAtom } from "@/globals/settings"; +import { graphSettingsAtom, steadyState3DItemAtom } from "@/globals/settings"; import { PALETTES } from "@/features/colors"; const ITEMS = [ @@ -30,13 +29,10 @@ 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] = useState("Jacobian"); + const [item, setItem] = useAtom(steadyState3DItemAtom); const handleZAxisChangeFor = ( setting: keyof Pick< @@ -73,7 +69,7 @@ const SteadyState3DPanel = () => { name="Item" value={item} options={ITEM_OPTIONS} - onChange={setItem} + onChange={(newValue) => setItem(newValue as typeof item)} /> { + const result = useAtomValue(simulationResultAtom); + const variableSettingss = useAtomValue(variableSettingssAtom); + const scanIndependentVariable = useScanIndependentVariable(); + const timeCourseIndependentVariable = useAtomValue(independentVariableAtom); + const workspaceName = useAtomValue(nameAtom); + const graphSettings = useAtomValue(graphSettingsAtom); + + const { toast } = useToast(); + + 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: "middle", + nameGap: 30, + nameTextStyle: { + fontSize: 14, + color: "#000", + }, + axisPointer: { + show: false, + }, + axisLabel: { + show: true, + color: "#000", + fontSize: 12, + }, + }, + yAxis3D: { + name: "Variable", + type: "category", + data: titles, + nameLocation: "middle", + nameGap: 30, + nameTextStyle: { + fontSize: 14, + color: "#000", + }, + axisPointer: { + show: false, + }, + axisLabel: { + show: titles.length <= MAX_TITLES_TO_SHOW, + interval: 0, + color: "#000", + fontSize: 12, + }, + }, + zAxis3D: { + name: "Concentrations", + type: "value", + nameLocation: "middle", + nameGap: 30, + 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", + }, + 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); + + // Wait for chart to finish rendering + chart.on("finished", () => { + 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(); + } + }); + + // Fallback timeout in case 'finished' event doesn't fire + setTimeout(() => { + if (container.parentNode) { + 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(); + } + } + }, 1000); + }; + + 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); + + // Wait for chart to finish rendering - need extra delay for 3D labels + const captureSvg = () => { + if (!container.parentNode) return; + 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 labels are fully rendered + setTimeout(captureSvg, 300); + }); + + // Fallback timeout - longer for 3D rendering + setTimeout(captureSvg, 1500); + }; + + 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); + + // Wait for chart to finish rendering - need extra delay for 3D labels + const captureAndSave = () => { + if (!container.parentNode) return; + 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 labels are fully rendered + setTimeout(captureAndSave, 300); + }); + + // Fallback timeout - longer for 3D rendering + setTimeout(captureAndSave, 1500); + }; + + 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..d4640b6 --- /dev/null +++ b/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx @@ -0,0 +1,389 @@ +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 { useToast } from "@/components/Toast"; + +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 { toast } = useToast(); + + 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: "middle", + nameGap: 30, + nameTextStyle: { + fontSize: 14, + color: "#000", + }, + axisPointer: { + show: false, + }, + axisLabel: { + show: true, + color: "#000", + fontSize: 12, + }, + }, + yAxis3D: { + name: y, + type: "category", + data: data.rows, + nameLocation: "middle", + nameGap: 30, + nameTextStyle: { + fontSize: 14, + color: "#000", + }, + axisPointer: { + show: false, + }, + axisLabel: { + show: true, + color: "#000", + fontSize: 12, + }, + }, + zAxis3D: { + name: z, + type: "value", + min: min, + max: max, + nameLocation: "middle", + nameGap: 30, + 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); + + // Wait for chart to finish rendering + chart.on("finished", () => { + 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(); + } + }); + + // Fallback timeout in case 'finished' event doesn't fire + setTimeout(() => { + if (container.parentNode) { + 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(); + } + } + }, 1000); + }; + + 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); + + // Wait for chart to finish rendering - need extra delay for 3D labels + const captureSvg = () => { + if (!container.parentNode) return; + 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 labels are fully rendered + setTimeout(captureSvg, 300); + }); + + // Fallback timeout - longer for 3D rendering + setTimeout(captureSvg, 1500); + }; + + 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); + + // Wait for chart to finish rendering - need extra delay for 3D labels + const captureAndSave = () => { + if (!container.parentNode) return; + 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 labels are fully rendered + setTimeout(captureAndSave, 300); + }); + + // Fallback timeout - longer for 3D rendering + setTimeout(captureAndSave, 1500); + }; + + if (result?.type !== "steadyState") { + return null; + } + + return ( + + + + + + + + + + + + + + ); +}; + +export default DownloadSteadyState3DButton; + 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/globals/settings.ts b/src/globals/settings.ts index fed636a..b04da97 100644 --- a/src/globals/settings.ts +++ b/src/globals/settings.ts @@ -222,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"); From a07d980817e412be18f1310cf18cd53f4c0e11e5 Mon Sep 17 00:00:00 2001 From: Sophia Zhang Date: Tue, 2 Dec 2025 00:34:18 -0800 Subject: [PATCH 4/5] adjusted distnaces for 3d axis labels --- .../downloadButtons/DownloadPlot3DButton.tsx | 93 ++++++++++--------- .../DownloadSteadyState3DButton.tsx | 83 ++++++++--------- 2 files changed, 88 insertions(+), 88 deletions(-) diff --git a/src/app/results/downloadButtons/DownloadPlot3DButton.tsx b/src/app/results/downloadButtons/DownloadPlot3DButton.tsx index c197174..ecf8c8b 100644 --- a/src/app/results/downloadButtons/DownloadPlot3DButton.tsx +++ b/src/app/results/downloadButtons/DownloadPlot3DButton.tsx @@ -10,7 +10,6 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@/components/DropdownMenu"; -import { useToast } from "@/components/Toast"; import { simulationResultAtom } from "@/globals/simulation"; import { @@ -38,8 +37,6 @@ const DownloadPlot3DButton = () => { const workspaceName = useAtomValue(nameAtom); const graphSettings = useAtomValue(graphSettingsAtom); - const { toast } = useToast(); - const downloadName = `3D Plot of ${workspaceName}`; const getPlotOptions = () => { @@ -123,12 +120,13 @@ const DownloadPlot3DButton = () => { xAxis3D: { name: independentVariableName, type: "value", - nameLocation: "middle", - nameGap: 30, + nameLocation: "end", + nameGap: 20, nameTextStyle: { fontSize: 14, color: "#000", }, + nameRotate: 0, axisPointer: { show: false, }, @@ -143,11 +141,12 @@ const DownloadPlot3DButton = () => { type: "category", data: titles, nameLocation: "middle", - nameGap: 30, + nameGap: 20, nameTextStyle: { fontSize: 14, color: "#000", }, + nameRotate: 90, axisPointer: { show: false, }, @@ -161,8 +160,8 @@ const DownloadPlot3DButton = () => { zAxis3D: { name: "Concentrations", type: "value", - nameLocation: "middle", - nameGap: 30, + nameLocation: "end", + nameGap: 15, nameTextStyle: { fontSize: 14, color: "#000", @@ -188,6 +187,16 @@ const DownloadPlot3DButton = () => { autoRotate: false, }, environment: "#ffffff", + axisLine: { + lineStyle: { + color: "#333", + }, + }, + axisLabel: { + textStyle: { + color: "#000", + }, + }, }, series: series, }; @@ -212,9 +221,14 @@ const DownloadPlot3DButton = () => { height: HEIGHT, }); chart.setOption(plotOptions); + // Force resize to ensure everything renders + chart.resize(); - // Wait for chart to finish rendering - chart.on("finished", () => { + // 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(); const canvas = chart.getRenderedCanvas(); if (canvas) { canvas.toBlob((blob) => { @@ -234,32 +248,15 @@ const DownloadPlot3DButton = () => { document.body.removeChild(container); chart.dispose(); } + }; + + chart.on("finished", () => { + // Extra delay to ensure axis titles are fully rendered + setTimeout(capturePng, 500); }); - // Fallback timeout in case 'finished' event doesn't fire - setTimeout(() => { - if (container.parentNode) { - 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(); - } - } - }, 1000); + // Fallback timeout - longer for 3D rendering with axis titles + setTimeout(capturePng, 2000); }; const handleSvgDownload = () => { @@ -283,10 +280,14 @@ const DownloadPlot3DButton = () => { height: HEIGHT, }); chart.setOption(plotOptions); + // Force resize to ensure everything renders + chart.resize(); - // Wait for chart to finish rendering - need extra delay for 3D labels + // 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(); const canvas = chart.getRenderedCanvas(); if (canvas) { const dataUrl = canvas.toDataURL("image/png"); @@ -303,12 +304,12 @@ const DownloadPlot3DButton = () => { }; chart.on("finished", () => { - // Extra delay to ensure axis labels are fully rendered - setTimeout(captureSvg, 300); + // Extra delay to ensure axis titles are fully rendered + setTimeout(captureSvg, 500); }); - // Fallback timeout - longer for 3D rendering - setTimeout(captureSvg, 1500); + // Fallback timeout - longer for 3D rendering with axis titles + setTimeout(captureSvg, 2000); }; const handlePdfDownload = async () => { @@ -333,10 +334,14 @@ const DownloadPlot3DButton = () => { height: HEIGHT, }); chart.setOption(plotOptions); + // Force resize to ensure everything renders + chart.resize(); - // Wait for chart to finish rendering - need extra delay for 3D labels + // 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(); const canvas = chart.getRenderedCanvas(); if (canvas) { const pdf = new jsPDF({ @@ -354,12 +359,12 @@ const DownloadPlot3DButton = () => { }; chart.on("finished", () => { - // Extra delay to ensure axis labels are fully rendered - setTimeout(captureAndSave, 300); + // Extra delay to ensure axis titles are fully rendered + setTimeout(captureAndSave, 500); }); - // Fallback timeout - longer for 3D rendering - setTimeout(captureAndSave, 1500); + // Fallback timeout - longer for 3D rendering with axis titles + setTimeout(captureAndSave, 2000); }; if (!result || result.type === "steadyState") { diff --git a/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx b/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx index d4640b6..1c1bbc1 100644 --- a/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx +++ b/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx @@ -10,7 +10,6 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@/components/DropdownMenu"; -import { useToast } from "@/components/Toast"; import { simulationResultAtom } from "@/globals/simulation"; import { graphSettingsAtom, nameAtom, steadyState3DItemAtom } from "@/globals/settings"; @@ -31,8 +30,6 @@ const DownloadSteadyState3DButton = () => { const workspaceName = useAtomValue(nameAtom); const item = useAtomValue(steadyState3DItemAtom); - const { toast } = useToast(); - const downloadName = `3D Plot of ${workspaceName}`; const AXES: { [name: string]: { x: string; y: string; z: string } } = { @@ -83,12 +80,13 @@ const DownloadSteadyState3DButton = () => { name: x, type: "category", data: data.columns, - nameLocation: "middle", - nameGap: 30, + nameLocation: "end", + nameGap: 20, nameTextStyle: { fontSize: 14, color: "#000", }, + nameRotate: 0, axisPointer: { show: false, }, @@ -103,11 +101,12 @@ const DownloadSteadyState3DButton = () => { type: "category", data: data.rows, nameLocation: "middle", - nameGap: 30, + nameGap: 20, nameTextStyle: { fontSize: 14, color: "#000", }, + nameRotate: 90, axisPointer: { show: false, }, @@ -122,8 +121,8 @@ const DownloadSteadyState3DButton = () => { type: "value", min: min, max: max, - nameLocation: "middle", - nameGap: 30, + nameLocation: "end", + nameGap: 15, nameTextStyle: { fontSize: 14, color: "#000", @@ -214,9 +213,14 @@ const DownloadSteadyState3DButton = () => { height: HEIGHT, }); chart.setOption(plotOptions); + // Force resize to ensure everything renders + chart.resize(); - // Wait for chart to finish rendering - chart.on("finished", () => { + // 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(); const canvas = chart.getRenderedCanvas(); if (canvas) { canvas.toBlob((blob) => { @@ -236,32 +240,15 @@ const DownloadSteadyState3DButton = () => { document.body.removeChild(container); chart.dispose(); } + }; + + chart.on("finished", () => { + // Extra delay to ensure axis titles are fully rendered + setTimeout(capturePng, 500); }); - // Fallback timeout in case 'finished' event doesn't fire - setTimeout(() => { - if (container.parentNode) { - 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(); - } - } - }, 1000); + // Fallback timeout - longer for 3D rendering with axis titles + setTimeout(capturePng, 2000); }; const handleSvgDownload = () => { @@ -285,10 +272,14 @@ const DownloadSteadyState3DButton = () => { height: HEIGHT, }); chart.setOption(plotOptions); + // Force resize to ensure everything renders + chart.resize(); - // Wait for chart to finish rendering - need extra delay for 3D labels + // 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(); const canvas = chart.getRenderedCanvas(); if (canvas) { const dataUrl = canvas.toDataURL("image/png"); @@ -305,12 +296,12 @@ const DownloadSteadyState3DButton = () => { }; chart.on("finished", () => { - // Extra delay to ensure axis labels are fully rendered - setTimeout(captureSvg, 300); + // Extra delay to ensure axis titles are fully rendered + setTimeout(captureSvg, 500); }); - // Fallback timeout - longer for 3D rendering - setTimeout(captureSvg, 1500); + // Fallback timeout - longer for 3D rendering with axis titles + setTimeout(captureSvg, 2000); }; const handlePdfDownload = async () => { @@ -335,10 +326,14 @@ const DownloadSteadyState3DButton = () => { height: HEIGHT, }); chart.setOption(plotOptions); + // Force resize to ensure everything renders + chart.resize(); - // Wait for chart to finish rendering - need extra delay for 3D labels + // 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(); const canvas = chart.getRenderedCanvas(); if (canvas) { const pdf = new jsPDF({ @@ -356,12 +351,12 @@ const DownloadSteadyState3DButton = () => { }; chart.on("finished", () => { - // Extra delay to ensure axis labels are fully rendered - setTimeout(captureAndSave, 300); + // Extra delay to ensure axis titles are fully rendered + setTimeout(captureAndSave, 500); }); - // Fallback timeout - longer for 3D rendering - setTimeout(captureAndSave, 1500); + // Fallback timeout - longer for 3D rendering with axis titles + setTimeout(captureAndSave, 2000); }; if (result?.type !== "steadyState") { From 467c24276c96030723064dda0769a6fdabfe5cf2 Mon Sep 17 00:00:00 2001 From: Sophia Zhang Date: Tue, 2 Dec 2025 01:22:08 -0800 Subject: [PATCH 5/5] linting fix --- src/app/results/downloadButtons/DownloadPlot3DButton.tsx | 3 +++ .../results/downloadButtons/DownloadSteadyState3DButton.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/app/results/downloadButtons/DownloadPlot3DButton.tsx b/src/app/results/downloadButtons/DownloadPlot3DButton.tsx index ecf8c8b..90f3e66 100644 --- a/src/app/results/downloadButtons/DownloadPlot3DButton.tsx +++ b/src/app/results/downloadButtons/DownloadPlot3DButton.tsx @@ -229,6 +229,7 @@ const DownloadPlot3DButton = () => { 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) => { @@ -288,6 +289,7 @@ const DownloadPlot3DButton = () => { 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"); @@ -342,6 +344,7 @@ const DownloadPlot3DButton = () => { 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({ diff --git a/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx b/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx index 1c1bbc1..c54dfef 100644 --- a/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx +++ b/src/app/results/downloadButtons/DownloadSteadyState3DButton.tsx @@ -221,6 +221,7 @@ const DownloadSteadyState3DButton = () => { 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) => { @@ -280,6 +281,7 @@ const DownloadSteadyState3DButton = () => { 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"); @@ -334,6 +336,7 @@ const DownloadSteadyState3DButton = () => { 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({