Skip to content

Commit 4337353

Browse files
authored
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
1 parent 1d20761 commit 4337353

File tree

6 files changed

+297
-262
lines changed

6 files changed

+297
-262
lines changed
Lines changed: 43 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,81 @@
11
// Code generated by AI and checked/modified for correctness
22

3-
import type { ScaleLinear } from "d3";
43
import * as d3 from "d3";
54
import { For } from "solid-js";
5+
import { useChartContext } from "./ChartContainer";
66

7-
interface AxisProps {
8-
scale: ScaleLinear<number, number>;
9-
transform?: string;
10-
tickCount?: number;
7+
type AxisProps = {
8+
type?: "linear" | "log";
9+
domain?: () => [number, number]; // TODO: is this needed for reactivity?
1110
label?: string;
1211
tickValues?: number[];
1312
tickFormat?: (n: number | { valueOf(): number }) => string;
14-
decreasing?: boolean;
15-
}
16-
17-
const ticks = (props: AxisProps) => {
18-
const domain = props.scale.domain();
19-
const generateTicks = (domain = [0, 1], tickCount = 5) => {
20-
const step = (domain[1] - domain[0]) / (tickCount - 1);
21-
return [...Array(10).keys()].map((i) => domain[0] + i * step);
22-
};
23-
24-
const values = props.tickValues
25-
? props.tickValues.filter((x) => x >= domain[0] && x <= domain[1])
26-
: generateTicks(domain, props.tickCount);
27-
return values.map((value) => ({ value, position: props.scale(value) }));
2813
};
2914

3015
export const AxisBottom = (props: AxisProps) => {
16+
const [chart, updateChart] = useChartContext();
17+
props.domain && chart.scaleX.domain(props.domain());
18+
19+
if (props.type === "log") {
20+
const range = chart.scaleX.range();
21+
const domain = chart.scaleX.range();
22+
updateChart("scaleX", d3.scaleLog().domain(domain).range(range));
23+
}
24+
3125
const format = props.tickFormat ? props.tickFormat : d3.format(".3g");
26+
const ticks = props.tickValues || generateTicks(chart.scaleX.domain());
3227
return (
33-
<g transform={props.transform}>
34-
<line
35-
x1={props.scale.range()[0]}
36-
x2={props.scale.range()[1]}
37-
y1="0"
38-
y2="0"
39-
stroke="currentColor"
40-
/>
41-
<For each={ticks(props)}>
28+
<g transform={`translate(0,${chart.innerHeight - 0.5})`}>
29+
<line x1="0" x2={chart.innerWidth} y1="0" y2="0" stroke="currentColor" />
30+
<For each={ticks}>
4231
{(tick) => (
43-
<g transform={`translate(${tick.position}, 0)`}>
32+
<g transform={`translate(${chart.scaleX(tick)}, 0)`}>
4433
<line y2="6" stroke="currentColor" />
4534
<text y="9" dy="0.71em" text-anchor="middle">
46-
{format(tick.value)}
35+
{format(tick)}
4736
</text>
4837
</g>
4938
)}
5039
</For>
51-
<text x={props.scale.range()[1]} y="9" dy="2em" text-anchor="end">
40+
<text x={chart.innerWidth} y="9" dy="2em" text-anchor="end">
5241
{props.label}
5342
</text>
5443
</g>
5544
);
5645
};
5746

5847
export const AxisLeft = (props: AxisProps) => {
48+
const [chart, updateChart] = useChartContext();
49+
props.domain && chart.scaleY.domain(props.domain());
50+
51+
if (props.type === "log") {
52+
const range = chart.scaleY.range();
53+
const domain = chart.scaleY.domain();
54+
updateChart("scaleY", () => d3.scaleLog().range(range).domain(domain));
55+
}
56+
57+
const ticks = props.tickValues || generateTicks(chart.scaleY.domain());
5958
const format = props.tickFormat ? props.tickFormat : d3.format(".0f");
60-
const yAnchor = props.decreasing ? 0 : 1;
6159
return (
62-
<g transform={props.transform}>
60+
<g transform="translate(-0.5,0)">
6361
<line
6462
x1={0}
6563
x2={0}
66-
y1={props.scale.range()[0]}
67-
y2={props.scale.range()[1]}
64+
y1={chart.scaleY.range()[0]}
65+
y2={chart.scaleY.range()[1]}
6866
stroke="currentColor"
6967
/>
70-
<For each={ticks(props)}>
68+
<For each={ticks}>
7169
{(tick) => (
72-
<g transform={`translate(0, ${tick.position})`}>
70+
<g transform={`translate(0, ${chart.scaleY(tick)})`}>
7371
<line x2="-6" stroke="currentColor" />
7472
<text x="-9" dy="0.32em" text-anchor="end">
75-
{format(tick.value)}
73+
{format(tick)}
7674
</text>
7775
</g>
7876
)}
7977
</For>
80-
<text
81-
y={props.scale.range()[yAnchor]}
82-
text-anchor="end"
83-
transform="translate(-45, 0) rotate(-90)"
84-
>
78+
<text y="0" text-anchor="end" transform="translate(-45, 0) rotate(-90)">
8579
{props.label}
8680
</text>
8781
</g>
@@ -103,3 +97,9 @@ export function getNiceAxisLimits(data: number[]): [number, number] {
10397

10498
return [niceMin, niceMax];
10599
}
100+
101+
/** Generate evenly space tick values for a linear scale */
102+
const generateTicks = (domain = [0, 1], tickCount = 5) => {
103+
const step = (domain[1] - domain[0]) / (tickCount - 1);
104+
return [...Array(10).keys()].map((i) => domain[0] + i * step);
105+
};

apps/class-solid/src/components/plots/Base.tsx

Lines changed: 0 additions & 9 deletions
This file was deleted.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import * as d3 from "d3";
2+
import type { JSX } from "solid-js";
3+
import { createContext, useContext } from "solid-js";
4+
import { type SetStoreFunction, createStore } from "solid-js/store";
5+
6+
interface Chart {
7+
width: number;
8+
height: number;
9+
margin: [number, number, number, number];
10+
innerWidth: number;
11+
innerHeight: number;
12+
scaleX: d3.ScaleLinear<number, number> | d3.ScaleLogarithmic<number, number>;
13+
scaleY: d3.ScaleLinear<number, number> | d3.ScaleLogarithmic<number, number>;
14+
}
15+
type SetChart = SetStoreFunction<Chart>;
16+
const ChartContext = createContext<[Chart, SetChart]>();
17+
18+
/** Container and context manager for chart + legend */
19+
export function ChartContainer(props: {
20+
children: JSX.Element;
21+
width?: number;
22+
height?: number;
23+
margin?: [number, number, number, number];
24+
}) {
25+
const width = props.width || 500;
26+
const height = props.height || 500;
27+
const margin = props.margin || [20, 20, 35, 55];
28+
const [marginTop, marginRight, marginBottom, marginLeft] = margin;
29+
const innerHeight = height - marginTop - marginBottom;
30+
const innerWidth = width - marginRight - marginLeft;
31+
const [chart, updateChart] = createStore<Chart>({
32+
width,
33+
height,
34+
margin,
35+
innerHeight,
36+
innerWidth,
37+
scaleX: d3.scaleLinear().range([0, innerWidth]),
38+
scaleY: d3.scaleLinear().range([innerHeight, 0]),
39+
});
40+
return (
41+
<ChartContext.Provider value={[chart, updateChart]}>
42+
<figure>{props.children}</figure>
43+
</ChartContext.Provider>
44+
);
45+
}
46+
47+
/** Container for chart elements such as axes and lines */
48+
export function Chart(props: { children: JSX.Element; title?: string }) {
49+
const [chart, updateChart] = useChartContext();
50+
const title = props.title || "Default chart";
51+
const [marginTop, _, __, marginLeft] = chart.margin;
52+
53+
return (
54+
<svg
55+
width={chart.width}
56+
height={chart.height}
57+
class="text-slate-500 text-xs tracking-wide"
58+
>
59+
<title>{title}</title>
60+
<g transform={`translate(${marginLeft},${marginTop})`}>
61+
{props.children}
62+
{/* Line along right edge of plot
63+
<line
64+
x1={chart.innerWidth - 0.5}
65+
x2={chart.innerWidth - 0.5}
66+
y1="0"
67+
y2={chart.innerHeight}
68+
stroke="#dfdfdf"
69+
stroke-width="0.75px"
70+
fill="none"
71+
/> */}
72+
</g>
73+
</svg>
74+
);
75+
}
76+
77+
export function useChartContext() {
78+
const context = useContext(ChartContext);
79+
if (!context) {
80+
throw new Error(
81+
"useChartContext must be used within a ChartProvider; typically by wrapping your components in a ChartContainer.",
82+
);
83+
}
84+
return context;
85+
}
86+
export interface ChartData<T> {
87+
label: string;
88+
color: string;
89+
linestyle: string;
90+
data: T[];
91+
}

apps/class-solid/src/components/plots/Legend.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import { For } from "solid-js";
22
import { cn } from "~/lib/utils";
3-
import type { ChartData } from "./Base";
3+
import type { ChartData } from "./ChartContainer";
4+
import { useChartContext } from "./ChartContainer";
45

56
export interface LegendProps<T> {
67
entries: () => ChartData<T>[];
7-
width: string;
88
}
99

1010
export function Legend<T>(props: LegendProps<T>) {
11+
const [chart, updateChart] = useChartContext();
12+
1113
return (
12-
// {/* Legend */}
1314
<div
1415
class={cn(
1516
"flex flex-wrap justify-end text-sm tracking-tight",
16-
props.width,
17+
`w-[${chart.width}px]`,
1718
)}
1819
>
1920
<For each={props.entries()}>
Lines changed: 34 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,55 @@
11
import * as d3 from "d3";
2-
import { For } from "solid-js";
2+
import { For, createSignal } from "solid-js";
33
import { AxisBottom, AxisLeft, getNiceAxisLimits } from "./Axes";
4-
import type { ChartData } from "./Base";
4+
import type { ChartData } from "./ChartContainer";
5+
import { Chart, ChartContainer, useChartContext } from "./ChartContainer";
56
import { Legend } from "./Legend";
67

78
export interface Point {
89
x: number;
910
y: number;
1011
}
1112

13+
function Line(d: ChartData<Point>) {
14+
const [chart, updateChart] = useChartContext();
15+
const [hovered, setHovered] = createSignal(false);
16+
17+
const l = d3.line<Point>(
18+
(d) => chart.scaleX(d.x),
19+
(d) => chart.scaleY(d.y),
20+
);
21+
return (
22+
<path
23+
onMouseEnter={() => setHovered(true)}
24+
onMouseLeave={() => setHovered(false)}
25+
fill="none"
26+
stroke={d.color}
27+
stroke-dasharray={d.linestyle}
28+
stroke-width={hovered() ? 5 : 3}
29+
d={l(d.data) || ""}
30+
>
31+
<title>{d.label}</title>
32+
</path>
33+
);
34+
}
35+
1236
export default function LinePlot({
1337
data,
1438
xlabel,
1539
ylabel,
1640
}: { data: () => ChartData<Point>[]; xlabel?: string; ylabel?: string }) {
17-
// TODO: Make responsive
18-
// const margin = [30, 40, 20, 45]; // reference from skew-T
19-
const [marginTop, marginRight, marginBottom, marginLeft] = [20, 20, 35, 55];
20-
const width = 500;
21-
const height = 500;
22-
const w = 500 - marginRight - marginLeft;
23-
const h = 500 - marginTop - marginBottom;
24-
2541
const xLim = () =>
2642
getNiceAxisLimits(data().flatMap((d) => d.data.flatMap((d) => d.x)));
2743
const yLim = () =>
2844
getNiceAxisLimits(data().flatMap((d) => d.data.flatMap((d) => d.y)));
29-
const scaleX = () => d3.scaleLinear(xLim(), [0, w]);
30-
const scaleY = () => d3.scaleLinear(yLim(), [h, 0]);
31-
32-
const l = d3.line<Point>(
33-
(d) => scaleX()(d.x),
34-
(d) => scaleY()(d.y),
35-
);
36-
3745
return (
38-
<figure>
39-
<Legend entries={data} width={`w-[${width}px]`} />
40-
{/* Plot */}
41-
<svg
42-
width={width}
43-
height={height}
44-
class="text-slate-500 text-xs tracking-wide"
45-
>
46-
<g transform={`translate(${marginLeft},${marginTop})`}>
47-
<title>Vertical profile plot</title>
48-
{/* Axes */}
49-
<AxisBottom
50-
scale={scaleX()}
51-
transform={`translate(0,${h - 0.5})`}
52-
label={xlabel}
53-
/>
54-
<AxisLeft
55-
scale={scaleY()}
56-
transform="translate(-0.5,0)"
57-
label={ylabel}
58-
/>
59-
60-
{/* Line */}
61-
<For each={data()}>
62-
{(d) => (
63-
<path
64-
fill="none"
65-
stroke={d.color}
66-
stroke-dasharray={d.linestyle}
67-
stroke-width="3"
68-
d={l(d.data) || ""}
69-
>
70-
<title>{d.label}</title>
71-
</path>
72-
)}
73-
</For>
74-
</g>
75-
</svg>
76-
</figure>
46+
<ChartContainer>
47+
<Legend entries={data} />
48+
<Chart title="Vertical profile plot">
49+
<AxisBottom domain={xLim} label={xlabel} />
50+
<AxisLeft domain={yLim} label={ylabel} />
51+
<For each={data()}>{(d) => Line(d)}</For>
52+
</Chart>
53+
</ChartContainer>
7754
);
7855
}

0 commit comments

Comments
 (0)