diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 8ac6f291..528d805f 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -1,11 +1,36 @@ -import { For, Match, Show, Switch, createMemo, createUniqueId } from "solid-js"; +import { BmiClass } from "@classmodel/class/bmi"; +import { + type Accessor, + For, + Match, + type Setter, + Show, + Switch, + createMemo, + createSignal, + createUniqueId, +} from "solid-js"; import { getThermodynamicProfiles, getVerticalProfiles } from "~/lib/profiles"; -import { type Analysis, deleteAnalysis, experiments } from "~/lib/store"; +import { + type Analysis, + type ProfilesAnalysis, + type TimeseriesAnalysis, + deleteAnalysis, + experiments, + updateAnalysis, +} from "~/lib/store"; import { MdiCog, MdiContentCopy, MdiDelete, MdiDownload } from "./icons"; import LinePlot from "./plots/LinePlot"; import { SkewTPlot } from "./plots/skewTlogP"; import { Button } from "./ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; /** https://github.com/d3/d3-scale-chromatic/blob/main/src/categorical/Tableau10.js */ const colors = [ @@ -27,7 +52,11 @@ const linestyles = ["none", "5,5", "10,10", "15,5,5,5", "20,10,5,5,5,10"]; /** Very rudimentary plot showing time series of each experiment globally available * It only works if the time axes are equal */ -export function TimeSeriesPlot() { +export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { + const xVariableOptions = ["t"]; // TODO: separate plot types for timeseries and x-vs-y? Use time axis? + // TODO: add nice description from config as title and dropdown option for the variable picker. + const yVariableOptions = new BmiClass().get_output_var_names(); + const chartData = createMemo(() => { return experiments .filter((e) => e.running === false) // Skip running experiments @@ -41,9 +70,13 @@ export function TimeSeriesPlot() { color: colors[(j + 1) % 10], linestyle: linestyles[i % 5], data: - perm.output?.t.map((tVal, i) => ({ - x: tVal, - y: perm.output?.h[i] || Number.NaN, + perm.output?.t.map((tVal, ti) => ({ + x: perm.output + ? perm.output[analysis.xVariable][ti] + : Number.NaN, + y: perm.output + ? perm.output[analysis.yVariable][ti] + : Number.NaN, })) || [], }; }); @@ -53,9 +86,13 @@ export function TimeSeriesPlot() { color: colors[0], linestyle: linestyles[i], data: - experimentOutput?.t.map((tVal, i) => ({ - x: tVal, - y: experimentOutput?.h[i] || Number.NaN, + experimentOutput?.t.map((tVal, ti) => ({ + x: experimentOutput + ? experimentOutput[analysis.xVariable][ti] + : Number.NaN, + y: experimentOutput + ? experimentOutput[analysis.yVariable][ti] + : Number.NaN, })) || [], }, ...permutationRuns, @@ -64,17 +101,50 @@ export function TimeSeriesPlot() { }); return ( - + <> + {/* TODO: get label for yVariable from model config */} + "Time [s]"} + ylabel={() => analysis.yVariable} + /> +
+ analysis.xVariable} + setValue={(v) => updateAnalysis(analysis, { xVariable: v })} + options={xVariableOptions} + label="x-axis" + /> + analysis.yVariable} + setValue={(v) => updateAnalysis(analysis, { yVariable: v })} + options={yVariableOptions} + label="y-axis" + /> +
+ ); } -export function VerticalProfilePlot() { - const variable = "theta"; - const time = -1; +export function VerticalProfilePlot({ + analysis, +}: { analysis: ProfilesAnalysis }) { + const [variable, setVariable] = createSignal("Potential temperature [K]"); + + // TODO also check time of permutations. + const timeOptions = experiments + .filter((e) => e.running === false) + .flatMap((e) => (e.reference.output ? e.reference.output.t : [])); + const variableOptions = { + "Potential temperature [K]": "theta", + "Specific humidity [kg/kg]": "q", + }; + const classVariable = () => + variableOptions[analysis.variable as keyof typeof variableOptions]; + + // TODO: refactor this? We could have a function that creates shared ChartData + // props (linestyle, color, label) generic for each plot type, and custom data + // formatting as required by specific chart const profileData = createMemo(() => { return experiments .filter((e) => e.running === false) // Skip running experiments @@ -86,7 +156,12 @@ export function VerticalProfilePlot() { color: colors[(j + 1) % 10], linestyle: linestyles[i % 5], label: `${e.name}/${p.name}`, - data: getVerticalProfiles(p.output, p.config, variable, time), + data: getVerticalProfiles( + p.output, + p.config, + classVariable(), + analysis.time, + ), }; }); @@ -103,8 +178,8 @@ export function VerticalProfilePlot() { dtheta: [], }, e.reference.config, - variable, - time, + classVariable(), + analysis.time, ), }, ...permutations, @@ -112,11 +187,50 @@ export function VerticalProfilePlot() { }); }); return ( - + <> + "Height [m]"} + /> + analysis.variable} + setValue={(v) => updateAnalysis(analysis, { variable: v })} + options={Object.keys(variableOptions)} + label="variable: " + /> + + ); +} + +type PickerProps = { + value: Accessor; + setValue: Setter; + options: string[]; + label?: string; +}; + +function Picker(props: PickerProps) { + return ( +
+

{props.label}

+ +
); } @@ -228,10 +342,10 @@ export function AnalysisCard(analysis: Analysis) { - + - + diff --git a/apps/class-solid/src/components/plots/Axes.tsx b/apps/class-solid/src/components/plots/Axes.tsx index 5a2c39e3..cc354f21 100644 --- a/apps/class-solid/src/components/plots/Axes.tsx +++ b/apps/class-solid/src/components/plots/Axes.tsx @@ -1,7 +1,7 @@ // Code generated by AI and checked/modified for correctness import * as d3 from "d3"; -import { For } from "solid-js"; +import { For, createEffect } from "solid-js"; import { useChartContext } from "./ChartContainer"; type AxisProps = { @@ -14,25 +14,22 @@ type AxisProps = { export const AxisBottom = (props: AxisProps) => { const [chart, updateChart] = useChartContext(); - props.domain && chart.scaleX.domain(props.domain()); + createEffect(() => { + props.domain && updateChart("scalePropsX", { domain: props.domain() }); + props.type && updateChart("scalePropsX", { type: props.type }); + }); - if (props.type === "log") { - const range = chart.scaleX.range(); - const domain = chart.scaleX.range(); - updateChart("scaleX", d3.scaleLog().domain(domain).range(range)); - } - - const format = props.tickFormat ? props.tickFormat : d3.format(".3g"); - const ticks = props.tickValues || generateTicks(chart.scaleX.domain()); + const format = () => (props.tickFormat ? props.tickFormat : d3.format(".3g")); + const ticks = () => props.tickValues || generateTicks(chart.scaleX.domain()); return ( - + {(tick) => ( - {format(tick)} + {format()(tick)} )} @@ -46,16 +43,13 @@ export const AxisBottom = (props: AxisProps) => { export const AxisLeft = (props: AxisProps) => { const [chart, updateChart] = useChartContext(); - props.domain && chart.scaleY.domain(props.domain()); - - if (props.type === "log") { - const range = chart.scaleY.range(); - const domain = chart.scaleY.domain(); - updateChart("scaleY", () => d3.scaleLog().range(range).domain(domain)); - } + createEffect(() => { + props.domain && updateChart("scalePropsY", { domain: props.domain() }); + props.type && updateChart("scalePropsY", { type: props.type }); + }); - const ticks = props.tickValues || generateTicks(chart.scaleY.domain()); - const format = props.tickFormat ? props.tickFormat : d3.format(".0f"); + const ticks = () => props.tickValues || generateTicks(chart.scaleY.domain()); + const format = () => (props.tickFormat ? props.tickFormat : d3.format(".0f")); return ( { y2={chart.scaleY.range()[1]} stroke="currentColor" /> - + {(tick) => ( - {format(tick)} + {format()(tick)} )} diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index 724a9400..eddea6a7 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -1,16 +1,32 @@ import * as d3 from "d3"; import type { JSX } from "solid-js"; -import { createContext, useContext } from "solid-js"; +import { createContext, createEffect, useContext } from "solid-js"; import { type SetStoreFunction, createStore } from "solid-js/store"; +type SupportedScaleTypes = + | d3.ScaleLinear + | d3.ScaleLogarithmic; +const supportedScales = { + linear: d3.scaleLinear, + log: d3.scaleLog, +}; + +type ScaleProps = { + domain: [number, number]; + range: [number, number]; + type: keyof typeof supportedScales; +}; + interface Chart { width: number; height: number; margin: [number, number, number, number]; innerWidth: number; innerHeight: number; - scaleX: d3.ScaleLinear | d3.ScaleLogarithmic; - scaleY: d3.ScaleLinear | d3.ScaleLogarithmic; + scalePropsX: ScaleProps; + scalePropsY: ScaleProps; + scaleX: SupportedScaleTypes; + scaleY: SupportedScaleTypes; } type SetChart = SetStoreFunction; const ChartContext = createContext<[Chart, SetChart]>(); @@ -28,14 +44,34 @@ export function ChartContainer(props: { const [marginTop, marginRight, marginBottom, marginLeft] = margin; const innerHeight = height - marginTop - marginBottom; const innerWidth = width - marginRight - marginLeft; + const initialScale = d3.scaleLinear(); const [chart, updateChart] = createStore({ width, height, margin, innerHeight, innerWidth, - scaleX: d3.scaleLinear().range([0, innerWidth]), - scaleY: d3.scaleLinear().range([innerHeight, 0]), + scalePropsX: { type: "linear", domain: [0, 1], range: [0, innerWidth] }, + scalePropsY: { type: "linear", domain: [0, 1], range: [innerHeight, 0] }, + scaleX: initialScale, + scaleY: initialScale, + }); + createEffect(() => { + // Update scaleXInstance when scaleX props change + const scaleX = supportedScales[chart.scalePropsX.type]() + .range(chart.scalePropsX.range) + .domain(chart.scalePropsX.domain); + // .nice(); // TODO: could use this instead of getNiceAxisLimits + updateChart("scaleX", () => scaleX); + }); + + createEffect(() => { + // Update scaleYInstance when scaleY props change + const scaleY = supportedScales[chart.scalePropsY.type]() + .range(chart.scalePropsY.range) + .domain(chart.scalePropsY.domain); + // .nice(); + updateChart("scaleY", () => scaleY); }); return ( diff --git a/apps/class-solid/src/components/plots/LinePlot.tsx b/apps/class-solid/src/components/plots/LinePlot.tsx index cb816298..012c1a27 100644 --- a/apps/class-solid/src/components/plots/LinePlot.tsx +++ b/apps/class-solid/src/components/plots/LinePlot.tsx @@ -37,7 +37,11 @@ export default function LinePlot({ data, xlabel, ylabel, -}: { data: () => ChartData[]; xlabel?: string; ylabel?: string }) { +}: { + data: () => ChartData[]; + xlabel?: () => string; + ylabel?: () => string; +}) { const xLim = () => getNiceAxisLimits(data().flatMap((d) => d.data.flatMap((d) => d.x))); const yLim = () => @@ -46,8 +50,8 @@ export default function LinePlot({ - - + + {(d) => Line(d)} diff --git a/apps/class-solid/src/components/plots/skewTlogP.tsx b/apps/class-solid/src/components/plots/skewTlogP.tsx index fe09debf..f6aca45e 100644 --- a/apps/class-solid/src/components/plots/skewTlogP.tsx +++ b/apps/class-solid/src/components/plots/skewTlogP.tsx @@ -29,8 +29,8 @@ function ClipPath() { function SkewTGridLine(temperature: number) { const [chart, updateChart] = useChartContext(); - const x = chart.scaleX; - const y = chart.scaleY; + const x = (temp: number) => chart.scaleX(temp); + const y = (pres: number) => chart.scaleY(pres); return ( chart.scaleX(temp); + const y = (pres: number) => chart.scaleY(pres); return ( chart.scaleX(temp); + const y = (pres: number) => chart.scaleY(pres); const dryline = d3 .line() @@ -92,8 +92,8 @@ function Sounding(data: ChartData) { const [hovered, setHovered] = createSignal(false); // Scales and axes. Note the inverted domain for the y-scale: bigger is up! - const x = chart.scaleX; - const y = chart.scaleY; + const x = (temp: number) => chart.scaleX(temp); + const y = (pres: number) => chart.scaleY(pres); const temperatureLine = d3 .line() diff --git a/apps/class-solid/src/components/ui/select.tsx b/apps/class-solid/src/components/ui/select.tsx new file mode 100644 index 00000000..8f59ad98 --- /dev/null +++ b/apps/class-solid/src/components/ui/select.tsx @@ -0,0 +1,124 @@ +import type { JSX, ValidComponent } from "solid-js"; +import { splitProps } from "solid-js"; + +import type { PolymorphicProps } from "@kobalte/core/polymorphic"; +import * as SelectPrimitive from "@kobalte/core/select"; + +import { cn } from "~/lib/utils"; + +const Select = SelectPrimitive.Root; +const SelectValue = SelectPrimitive.Value; +const SelectHiddenSelect = SelectPrimitive.HiddenSelect; + +type SelectTriggerProps = + SelectPrimitive.SelectTriggerProps & { + class?: string | undefined; + children?: JSX.Element; + }; + +const SelectTrigger = ( + props: PolymorphicProps>, +) => { + const [local, others] = splitProps(props as SelectTriggerProps, [ + "class", + "children", + ]); + return ( + + {local.children} + + + + + + ); +}; + +type SelectContentProps = + SelectPrimitive.SelectContentProps & { class?: string | undefined }; + +const SelectContent = ( + props: PolymorphicProps>, +) => { + const [local, others] = splitProps(props as SelectContentProps, ["class"]); + return ( + + + + + + ); +}; + +type SelectItemProps = + SelectPrimitive.SelectItemProps & { + class?: string | undefined; + children?: JSX.Element; + }; + +const SelectItem = ( + props: PolymorphicProps>, +) => { + const [local, others] = splitProps(props as SelectItemProps, [ + "class", + "children", + ]); + return ( + + + {/* biome-ignore lint/a11y/noSvgWithoutTitle: generated code */} + + + + + + {local.children} + + ); +}; + +export { + Select, + SelectValue, + SelectHiddenSelect, + SelectTrigger, + SelectContent, + SelectItem, +}; diff --git a/apps/class-solid/src/lib/profiles.ts b/apps/class-solid/src/lib/profiles.ts index 6519c56c..2bc10fc1 100644 --- a/apps/class-solid/src/lib/profiles.ts +++ b/apps/class-solid/src/lib/profiles.ts @@ -6,7 +6,7 @@ import type { Point } from "~/components/plots/LinePlot"; export function getVerticalProfiles( output: ClassOutput | undefined, config: PartialConfig, - variable: "theta" | "q" = "theta", + variable = "theta", t = -1, ): Point[] { // Guard against undefined output diff --git a/apps/class-solid/src/lib/runner.ts b/apps/class-solid/src/lib/runner.ts index dec59e69..be66133b 100644 --- a/apps/class-solid/src/lib/runner.ts +++ b/apps/class-solid/src/lib/runner.ts @@ -1,4 +1,4 @@ -import type { BmiClass } from "@classmodel/class/bmi"; +import { BmiClass } from "@classmodel/class/bmi"; import type { Config } from "@classmodel/class/config"; import type { ClassOutput } from "@classmodel/class/runner"; import { type PartialConfig, parse } from "@classmodel/class/validate"; @@ -15,7 +15,7 @@ export async function runClass(config: PartialConfig): Promise { const model = await new AsyncBmiClass(); await model.initialize(parsedConfig); const output = await model.run({ - var_names: ["h", "theta", "q", "dtheta", "dq"], + var_names: new BmiClass().get_output_var_names(), }); return output; } catch (error) { diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index 94709c11..c940dabf 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -80,8 +80,8 @@ export async function runExperiment(id: number) { // If no analyis are set then add all of them if (analyses.length === 0) { - for (const key of Object.keys(analysisNames) as AnalysisType[]) { - addAnalysis(key); + for (const name of analysisNames) { + addAnalysis(name); } } } @@ -260,33 +260,88 @@ export async function loadStateFromString(rawState: string): Promise { await Promise.all(loadedExperiments.map((_, i) => runExperiment(i))); } -export const analysisNames = { - profiles: "Vertical profiles", - timeseries: "Timeseries", - skewT: "Thermodynamic diagram", - // finalheight: "Final height", // keep for development but not in production -} as const; -export type AnalysisType = keyof typeof analysisNames; - export interface Analysis { - name: string; - description: string; id: string; - experiments: Experiment[] | undefined; - type: AnalysisType; + description: string; + type: string; + name: string; } -export function addAnalysis(type: AnalysisType) { - const name = analysisNames[type]; +export type TimeseriesAnalysis = Analysis & { + xVariable: string; + yVariable: string; +}; + +export type ProfilesAnalysis = Analysis & { + variable: string; + time: number; +}; + +export type SkewTAnalysis = Analysis & { + time: number; +}; + +export type AnalysisType = + | TimeseriesAnalysis + | ProfilesAnalysis + | SkewTAnalysis; +export const analysisNames = [ + "Vertical profiles", + "Timeseries", + "Thermodynamic diagram", +]; + +export function addAnalysis(name: string) { + let newAnalysis: Analysis; + + switch (name) { + case "Timeseries": + newAnalysis = { + id: createUniqueId(), + description: "", + type: "timeseries", + name: "Timeseries", + xVariable: "t", + yVariable: "h", + } as TimeseriesAnalysis; + break; + case "Vertical profiles": + newAnalysis = { + id: createUniqueId(), + description: "", + type: "profiles", + name: "Vertical profiles", + variable: "Potential temperature [K]", + time: -1, + } as ProfilesAnalysis; + break; + case "Thermodynamic diagram": + newAnalysis = { + id: createUniqueId(), + description: "", + type: "skewT", + name: "Thermodynamic diagram", + time: -1, + } as SkewTAnalysis; + break; + default: + throw new Error(`Unknown analysis type: ${name}`); + } - setAnalyses(analyses.length, { - name, - id: createUniqueId(), - experiments: experiments, - type, - }); + setAnalyses(analyses.length, newAnalysis); } export function deleteAnalysis(analysis: Analysis) { setAnalyses(analyses.filter((ana) => ana.id !== analysis.id)); } + +export function updateAnalysis(analysis: Analysis, newData: object) { + setAnalyses( + produce((analyses) => { + const currentAnalysis = analyses.find((a) => a.id === analysis.id); + if (currentAnalysis) { + Object.assign(currentAnalysis, newData); + } + }), + ); +} diff --git a/apps/class-solid/src/routes/index.tsx b/apps/class-solid/src/routes/index.tsx index 39089655..c94937da 100644 --- a/apps/class-solid/src/routes/index.tsx +++ b/apps/class-solid/src/routes/index.tsx @@ -16,12 +16,7 @@ import { Flex } from "~/components/ui/flex"; import { Toaster } from "~/components/ui/toast"; import { onPageLoad } from "~/lib/state"; -import { - type AnalysisType, - addAnalysis, - analysisNames, - experiments, -} from "~/lib/store"; +import { addAnalysis, analysisNames, experiments } from "~/lib/store"; import { analyses } from "~/lib/store"; export default function Home() { @@ -65,12 +60,10 @@ export default function Home() { Add analysis - - {([key, value]) => ( - addAnalysis(key)}> - {value} + + {(name) => ( + addAnalysis(name)}> + {name} )}