Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor plots #91

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/class-solid/src/components/Analysis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,9 @@ export function AnalysisCard(analysis: Analysis) {
<Match when={analysis.type === "profiles"}>
<VerticalProfilePlot />
</Match>
<Match when={analysis.type === "skewT"}>
{/* <Match when={analysis.type === "skewT"}>
<ThermodynamicPlot />
</Match>
</Match> */}
</Switch>
</CardContent>
</Card>
Expand Down
62 changes: 33 additions & 29 deletions apps/class-solid/src/components/plots/Axes.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,34 @@
// 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<number, number>;
transform?: string;
tickCount?: number;
type AxisProps = {
type?: "linear" | "log";
domain?: () => [number, number]; // TODO: is this needed for reactivity?
label?: string;
tickValues?: number[];
inverted?: boolean;
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) }));
tickValues?: number[];
};

export const AxisBottom = (props: AxisProps) => {
const [chart, updateChart] = useChartContext();

if (props.type === "log") {
const range = chart.scaleX.range;
updateChart("scaleX", d3.scaleLog().range(range));
}

const scale = chart.scaleX.domain(props.domain); // does that update inplace?
console.log(chart.scaleX.domain());
updateChart("scaleX", scale);

const format = props.tickFormat ? props.tickFormat : d3.format(".3g");
return (
<g transform={props.transform}>
<line
x1={props.scale.range()[0]}
x2={props.scale.range()[1]}
y1="0"
y2="0"
stroke="currentColor"
/>
<g transform={`translate(0,${chart.innerHeight - 0.5})`}>
<line x1="0" x2={chart.innerWidth} y1="0" y2="0" stroke="currentColor" />
<For each={ticks(props)}>
{(tick) => (
<g transform={`translate(${tick.position}, 0)`}>
Expand All @@ -48,7 +39,7 @@ export const AxisBottom = (props: AxisProps) => {
</g>
)}
</For>
<text x={props.scale.range()[1]} y="9" dy="2em" text-anchor="end">
<text x={chart.innerWidth} y="9" dy="2em" text-anchor="end">
{props.label}
</text>
</g>
Expand Down Expand Up @@ -103,3 +94,16 @@ export function getNiceAxisLimits(data: number[]): [number, number] {

return [niceMin, niceMax];
}

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) }));
};
3 changes: 0 additions & 3 deletions apps/class-solid/src/components/plots/Base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,3 @@ export interface ChartData<T> {
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.
81 changes: 81 additions & 0 deletions apps/class-solid/src/components/plots/ChartContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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<number, number> | d3.ScaleLogarithmic<number, number>;
scaleY: d3.ScaleLinear<number, number> | d3.ScaleLogarithmic<number, number>;
}
type SetChart = SetStoreFunction<Chart>;
const ChartContext = createContext<[Chart, SetChart]>();

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;
}

function Child() {
const [chart, updateChart] = useChartContext();
console.log(chart);
console.log("test");
return <p>test</p>;
}

export function ChartContainer(props: { children: JSX.Element }) {
const width = 500;
const height = 500;
const margin: [number, number, number, number] = [20, 20, 35, 55];
const title = "Default chart";
const [marginTop, marginRight, marginBottom, marginLeft] = margin;
const innerHeight = height - marginTop - marginBottom;
const innerWidth = width - marginRight - marginLeft;
const [chart, updateChart] = createStore<Chart>({
width,
height,
margin,
innerHeight,
innerWidth,
scaleX: d3.scaleLinear().range([0, innerWidth]),
scaleY: d3.scaleLinear().range([innerHeight, 0]),
});
return (
<ChartContext.Provider value={[chart, updateChart]}>
<figure>
<svg
width={width}
height={height}
class="text-slate-500 text-xs tracking-wide"
>
<title>{title}</title>
<g transform={`translate(${marginLeft},${marginTop})`}>
<Child />
{props.children}
</g>
</svg>
</figure>
</ChartContext.Provider>
);
}

export function Chart() {
return (
<ChartContainer>
<Child />
</ChartContainer>
);
}

const dummy = [
{ color: "blue", label: "blue", linestyle: "--", data: [{ x: 10, y: 10 }] },
];
6 changes: 4 additions & 2 deletions apps/class-solid/src/components/plots/Legend.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { For } from "solid-js";
import { cn } from "~/lib/utils";
import type { ChartData } from "./Base";
import { useChartContext } from "./ChartContainer";

export interface LegendProps<T> {
entries: () => ChartData<T>[];
width: string;
}

export function Legend<T>(props: LegendProps<T>) {
const [chart, updateChart] = useChartContext();

return (
// {/* Legend */}
<div
class={cn(
"flex flex-wrap justify-end text-sm tracking-tight",
props.width,
chart.width,
)}
>
<For each={props.entries()}>
Expand Down
82 changes: 27 additions & 55 deletions apps/class-solid/src/components/plots/LinePlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,77 +2,49 @@ import * as d3 from "d3";
import { For } from "solid-js";
import { AxisBottom, AxisLeft, getNiceAxisLimits } from "./Axes";
import type { ChartData } from "./Base";
import { ChartContainer, useChartContext } from "./ChartContainer";
import { Legend } from "./Legend";

export interface Point {
x: number;
y: number;
}

function Line(d: ChartData<Point>) {
const [chart, updateChart] = useChartContext();

const l = d3.line<Point>(
(d) => chart.scaleX(d.x),
(d) => chart.scaleY(d.y),
);
return (
<path
fill="none"
stroke={d.color}
stroke-dasharray={d.linestyle}
stroke-width="3"
d={l(d.data) || ""}
>
<title>{d.label}</title>
</path>
);
}

export default function LinePlot({
data,
xlabel,
ylabel,
}: { data: () => ChartData<Point>[]; 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<Point>(
(d) => scaleX()(d.x),
(d) => scaleY()(d.y),
);

return (
<figure>
<Legend entries={data} width={`w-[${width}px]`} />
{/* Plot */}
<svg
width={width}
height={height}
class="text-slate-500 text-xs tracking-wide"
>
<g transform={`translate(${marginLeft},${marginTop})`}>
<title>Vertical profile plot</title>
{/* Axes */}
<AxisBottom
scale={scaleX()}
transform={`translate(0,${h - 0.5})`}
label={xlabel}
/>
<AxisLeft
scale={scaleY()}
transform="translate(-0.5,0)"
label={ylabel}
/>

{/* Line */}
<For each={data()}>
{(d) => (
<path
fill="none"
stroke={d.color}
stroke-dasharray={d.linestyle}
stroke-width="3"
d={l(d.data) || ""}
>
<title>{d.label}</title>
</path>
)}
</For>
</g>
</svg>
</figure>
<ChartContainer title="Vertical profile plot">
<Legend entries={data} />
<AxisBottom domain={xLim} label={xlabel} />
<AxisLeft domain={yLim} label={ylabel} />
<For each={data()}>{(d) => Line(d)}</For>
</ChartContainer>
);
}
Loading
Loading