From 4337353da72d15f58b4c6fcf1b8c8abbcfeadcd2 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 29 Nov 2024 17:15:17 +0100 Subject: [PATCH] Refactor plots (#91) * Trying to wrap charts in chartcontainer with context provider, but something's not working yet * Don't use prop destructuring; seems to fix issue with context * Distinguish between ChartContainer and Chart so legend can reuse context; lineplot now works perfectly with context, very nice * Also use contextmanager in skewT plot, very clean. +Add hover to lineplot + make legend width work * Move chartdata interface to chartcontainer * formatting --- .../class-solid/src/components/plots/Axes.tsx | 86 +++--- .../class-solid/src/components/plots/Base.tsx | 9 - .../src/components/plots/ChartContainer.tsx | 91 ++++++ .../src/components/plots/Legend.tsx | 9 +- .../src/components/plots/LinePlot.tsx | 91 +++--- .../src/components/plots/skewTlogP.tsx | 273 ++++++++---------- 6 files changed, 297 insertions(+), 262 deletions(-) delete mode 100644 apps/class-solid/src/components/plots/Base.tsx create mode 100644 apps/class-solid/src/components/plots/ChartContainer.tsx diff --git a/apps/class-solid/src/components/plots/Axes.tsx b/apps/class-solid/src/components/plots/Axes.tsx index f0f8b48..5a2c39e 100644 --- a/apps/class-solid/src/components/plots/Axes.tsx +++ b/apps/class-solid/src/components/plots/Axes.tsx @@ -1,54 +1,43 @@ // Code generated by AI and checked/modified for correctness -import type { ScaleLinear } from "d3"; import * as d3 from "d3"; import { For } from "solid-js"; +import { useChartContext } from "./ChartContainer"; -interface AxisProps { - scale: ScaleLinear; - transform?: string; - tickCount?: number; +type AxisProps = { + type?: "linear" | "log"; + domain?: () => [number, number]; // TODO: is this needed for reactivity? label?: string; tickValues?: number[]; tickFormat?: (n: number | { valueOf(): number }) => string; - decreasing?: boolean; -} - -const ticks = (props: AxisProps) => { - const domain = props.scale.domain(); - const generateTicks = (domain = [0, 1], tickCount = 5) => { - const step = (domain[1] - domain[0]) / (tickCount - 1); - return [...Array(10).keys()].map((i) => domain[0] + i * step); - }; - - const values = props.tickValues - ? props.tickValues.filter((x) => x >= domain[0] && x <= domain[1]) - : generateTicks(domain, props.tickCount); - return values.map((value) => ({ value, position: props.scale(value) })); }; export const AxisBottom = (props: AxisProps) => { + const [chart, updateChart] = useChartContext(); + props.domain && chart.scaleX.domain(props.domain()); + + 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()); return ( - - - + + + {(tick) => ( - + - {format(tick.value)} + {format(tick)} )} - + {props.label} @@ -56,32 +45,37 @@ 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)); + } + + const ticks = props.tickValues || generateTicks(chart.scaleY.domain()); const format = props.tickFormat ? props.tickFormat : d3.format(".0f"); - const yAnchor = props.decreasing ? 0 : 1; return ( - + - + {(tick) => ( - + - {format(tick.value)} + {format(tick)} )} - + {props.label} @@ -103,3 +97,9 @@ export function getNiceAxisLimits(data: number[]): [number, number] { return [niceMin, niceMax]; } + +/** Generate evenly space tick values for a linear scale */ +const generateTicks = (domain = [0, 1], tickCount = 5) => { + const step = (domain[1] - domain[0]) / (tickCount - 1); + return [...Array(10).keys()].map((i) => domain[0] + i * step); +}; diff --git a/apps/class-solid/src/components/plots/Base.tsx b/apps/class-solid/src/components/plots/Base.tsx deleted file mode 100644 index d0aef4e..0000000 --- a/apps/class-solid/src/components/plots/Base.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export interface ChartData { - label: string; - color: string; - linestyle: string; - data: T[]; -} - -// TODO: would be nice to create a chartContainer/context that manages logic like -// width/height/margins etc. that should be consistent across different plots. diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx new file mode 100644 index 0000000..724a940 --- /dev/null +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -0,0 +1,91 @@ +import * as d3 from "d3"; +import type { JSX } from "solid-js"; +import { createContext, useContext } from "solid-js"; +import { type SetStoreFunction, createStore } from "solid-js/store"; + +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; +} +type SetChart = SetStoreFunction; +const ChartContext = createContext<[Chart, SetChart]>(); + +/** Container and context manager for chart + legend */ +export function ChartContainer(props: { + children: JSX.Element; + width?: number; + height?: number; + margin?: [number, number, number, number]; +}) { + const width = props.width || 500; + const height = props.height || 500; + const margin = props.margin || [20, 20, 35, 55]; + const [marginTop, marginRight, marginBottom, marginLeft] = margin; + const innerHeight = height - marginTop - marginBottom; + const innerWidth = width - marginRight - marginLeft; + const [chart, updateChart] = createStore({ + width, + height, + margin, + innerHeight, + innerWidth, + scaleX: d3.scaleLinear().range([0, innerWidth]), + scaleY: d3.scaleLinear().range([innerHeight, 0]), + }); + return ( + +
{props.children}
+
+ ); +} + +/** Container for chart elements such as axes and lines */ +export function Chart(props: { children: JSX.Element; title?: string }) { + const [chart, updateChart] = useChartContext(); + const title = props.title || "Default chart"; + const [marginTop, _, __, marginLeft] = chart.margin; + + return ( + + {title} + + {props.children} + {/* Line along right edge of plot + */} + + + ); +} + +export function useChartContext() { + const context = useContext(ChartContext); + if (!context) { + throw new Error( + "useChartContext must be used within a ChartProvider; typically by wrapping your components in a ChartContainer.", + ); + } + return context; +} +export interface ChartData { + label: string; + color: string; + linestyle: string; + data: T[]; +} diff --git a/apps/class-solid/src/components/plots/Legend.tsx b/apps/class-solid/src/components/plots/Legend.tsx index c3be8ad..ec29bc7 100644 --- a/apps/class-solid/src/components/plots/Legend.tsx +++ b/apps/class-solid/src/components/plots/Legend.tsx @@ -1,19 +1,20 @@ import { For } from "solid-js"; import { cn } from "~/lib/utils"; -import type { ChartData } from "./Base"; +import type { ChartData } from "./ChartContainer"; +import { useChartContext } from "./ChartContainer"; export interface LegendProps { entries: () => ChartData[]; - width: string; } export function Legend(props: LegendProps) { + const [chart, updateChart] = useChartContext(); + return ( - // {/* Legend */}
diff --git a/apps/class-solid/src/components/plots/LinePlot.tsx b/apps/class-solid/src/components/plots/LinePlot.tsx index d18a243..cb81629 100644 --- a/apps/class-solid/src/components/plots/LinePlot.tsx +++ b/apps/class-solid/src/components/plots/LinePlot.tsx @@ -1,7 +1,8 @@ import * as d3 from "d3"; -import { For } from "solid-js"; +import { For, createSignal } from "solid-js"; import { AxisBottom, AxisLeft, getNiceAxisLimits } from "./Axes"; -import type { ChartData } from "./Base"; +import type { ChartData } from "./ChartContainer"; +import { Chart, ChartContainer, useChartContext } from "./ChartContainer"; import { Legend } from "./Legend"; export interface Point { @@ -9,70 +10,46 @@ export interface Point { y: number; } +function Line(d: ChartData) { + const [chart, updateChart] = useChartContext(); + const [hovered, setHovered] = createSignal(false); + + const l = d3.line( + (d) => chart.scaleX(d.x), + (d) => chart.scaleY(d.y), + ); + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + fill="none" + stroke={d.color} + stroke-dasharray={d.linestyle} + stroke-width={hovered() ? 5 : 3} + d={l(d.data) || ""} + > + {d.label} + + ); +} + export default function LinePlot({ data, xlabel, ylabel, }: { data: () => ChartData[]; xlabel?: string; ylabel?: string }) { - // TODO: Make responsive - // const margin = [30, 40, 20, 45]; // reference from skew-T - const [marginTop, marginRight, marginBottom, marginLeft] = [20, 20, 35, 55]; - const width = 500; - const height = 500; - const w = 500 - marginRight - marginLeft; - const h = 500 - marginTop - marginBottom; - const xLim = () => getNiceAxisLimits(data().flatMap((d) => d.data.flatMap((d) => d.x))); const yLim = () => getNiceAxisLimits(data().flatMap((d) => d.data.flatMap((d) => d.y))); - const scaleX = () => d3.scaleLinear(xLim(), [0, w]); - const scaleY = () => d3.scaleLinear(yLim(), [h, 0]); - - const l = d3.line( - (d) => scaleX()(d.x), - (d) => scaleY()(d.y), - ); - return ( -
- - {/* Plot */} - - - Vertical profile plot - {/* Axes */} - - - - {/* Line */} - - {(d) => ( - - {d.label} - - )} - - - -
+ + + + + + {(d) => Line(d)} + + ); } diff --git a/apps/class-solid/src/components/plots/skewTlogP.tsx b/apps/class-solid/src/components/plots/skewTlogP.tsx index e9de043..fe09deb 100644 --- a/apps/class-solid/src/components/plots/skewTlogP.tsx +++ b/apps/class-solid/src/components/plots/skewTlogP.tsx @@ -2,7 +2,8 @@ import * as d3 from "d3"; import { For, createSignal } from "solid-js"; import { AxisBottom, AxisLeft } from "./Axes"; -import type { ChartData } from "./Base"; +import type { ChartData } from "./ChartContainer"; +import { Chart, ChartContainer, useChartContext } from "./ChartContainer"; import { Legend } from "./Legend"; interface SoundingRecord { @@ -16,26 +17,56 @@ const tan = Math.tan(55 * deg2rad); const basep = 1050; const topPressure = 100; -function SkewTBackGround({ - w, - h, - x, - y, -}: { - w: number; - h: number; - x: d3.ScaleLinear; - y: d3.ScaleLogarithmic; -}) { - const pressureLines = [1000, 850, 700, 500, 300, 200, 100]; - const temperatureLines = d3.range(-100, 45, 10); +function ClipPath() { + const [chart, updateChart] = useChartContext(); - // Dry adiabats (lines of constant potential temperature): array of lines of [p, T] - const pressurePoints = d3.range(topPressure, basep + 1, 10); - const temperaturePoints = d3.range(-30, 240, 20); - const dryAdiabats: [number, number][][] = temperaturePoints.map( - (temperature) => pressurePoints.map((pressure) => [pressure, temperature]), + return ( + + + ); +} + +function SkewTGridLine(temperature: number) { + const [chart, updateChart] = useChartContext(); + const x = chart.scaleX; + const y = chart.scaleY; + return ( + + ); +} + +function LogPGridLine(pressure: number) { + const [chart, updateChart] = useChartContext(); + const x = chart.scaleX; + const y = chart.scaleY; + return ( + + ); +} + +/** Dry adiabats (lines of constant potential temperature): array of lines of [p, T] */ +function DryAdiabat(d: [number, number][]) { + const [chart, updateChart] = useChartContext(); + const x = chart.scaleX; + const y = chart.scaleY; const dryline = d3 .line() @@ -46,98 +77,23 @@ function SkewTBackGround({ ) .y((d) => y(d[0])); return ( - - - - - {/* Add grid */} - {/* Temperature lines */} - - {(tline) => ( - - )} - - {/* Pressure lines */} - - {(pline) => ( - - )} - - {/* Dry Adiabats */} - - {(d) => ( - - )} - - {/* Line along right edge of plot */} - - - - - + ); } -// Note: using temperatures in Kelvin as that's easiest to get from CLASS, but -// perhaps not the most interoperable with other sounding data sources. -export function SkewTPlot({ - data, -}: { data: () => ChartData[] }) { - const [hovered, setHovered] = createSignal(null); - const width = 500; - const height = 500; - const [marginTop, marginRight, marginBottom, marginLeft] = [20, 20, 35, 55]; - const w = 500 - marginRight - marginLeft; - const h = 500 - marginTop - marginBottom; +function Sounding(data: ChartData) { + const [chart, updateChart] = useChartContext(); + const [hovered, setHovered] = createSignal(false); // Scales and axes. Note the inverted domain for the y-scale: bigger is up! - const x = d3.scaleLinear().range([0, w]).domain([-45, 50]); - const y = d3.scaleLog().range([0, h]).domain([topPressure, basep]); + const x = chart.scaleX; + const y = chart.scaleY; const temperatureLine = d3 .line() @@ -149,50 +105,69 @@ export function SkewTPlot({ .x((d) => x(d.Td - 273.15) + (y(basep) - y(d.p)) / tan) .y((d) => y(d.p)); - // // bisector function for tooltips - // const bisectTemp = d3.bisector((d) => d.press).left; + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {data.label} + + + + ); +} + +// Note: using temperatures in Kelvin as that's easiest to get from CLASS, but +// perhaps not the most interoperable with other sounding data sources. +export function SkewTPlot({ + data, +}: { data: () => ChartData[] }) { + const pressureLines = [1000, 850, 700, 500, 300, 200, 100]; + const temperatureLines = d3.range(-100, 45, 10); + + const pressureGrid = d3.range(topPressure, basep + 1, 10); + const temperatureGrid = d3.range(-30, 240, 20); + const dryAdiabats: [number, number][][] = temperatureGrid.map((temperature) => + pressureGrid.map((pressure) => [pressure, temperature]), + ); return ( -
- - {/* Create svg container for sounding */} - - Thermodynamic diagram - - - - {(d, index) => ( - setHovered(index())} - onMouseLeave={() => setHovered(null)} - > - {d.label} - - - - )} - - - -
+ + + + [-45, 50]} + tickValues={temperatureLines} + tickFormat={d3.format(".0d")} + label="Temperature [°C]" + /> + [basep, topPressure]} + tickValues={pressureLines} + tickFormat={d3.format(".0d")} + label="Pressure [hPa]" + /> + + {(t) => SkewTGridLine(t)} + {(p) => LogPGridLine(p)} + {(d) => DryAdiabat(d)} + {(d) => Sounding(d)} + + ); }