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");