Skip to content

Commit cea0f0c

Browse files
Pan zoom (#133)
* Implement basic zoom; TODO: fix angle in skewT * Add logarithmic zoom for skew-T diagram; TODO: T calculations should use real axis extent rather than fixes base/top pressure * Fix skew-T lines responding to original extent instead of actual; now they don't tilt anymore * Add panning effect, but it is stroboscopic and doesn't work for skewT yet * combine side-effects for both axes in a single callback * Remove animationframe * Use produce to update both scales in a single call * Don't update panstart; this fixes the jittering * Also work in log space * Make consistent for x-direction * zoom towards cursor * Add reset plot button * Replace reset plot icon with less busy icon * Add grabby hand + dont select text when panning in Chrome --------- Co-authored-by: sverhoeven <[email protected]>
1 parent 3f7de8d commit cea0f0c

File tree

5 files changed

+235
-45
lines changed

5 files changed

+235
-45
lines changed

apps/class-solid/src/components/Analysis.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Switch,
1313
createEffect,
1414
createMemo,
15+
createSignal,
1516
createUniqueId,
1617
} from "solid-js";
1718
import { createStore } from "solid-js/store";
@@ -31,7 +32,7 @@ import {
3132
experiments,
3233
updateAnalysis,
3334
} from "~/lib/store";
34-
import { MdiCamera, MdiDelete } from "./icons";
35+
import { MdiCamera, MdiDelete, MdiImageFilterCenterFocus } from "./icons";
3536
import { AxisBottom, AxisLeft, getNiceAxisLimits } from "./plots/Axes";
3637
import { Chart, ChartContainer } from "./plots/ChartContainer";
3738
import { Legend } from "./plots/Legend";
@@ -162,7 +163,7 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) {
162163
{/* TODO: get label for yVariable from model config */}
163164
<ChartContainer>
164165
<Legend entries={chartData} toggles={toggles} onChange={toggleLine} />
165-
<Chart title="Timeseries plot" formatX={formatSeconds}>
166+
<Chart id={analysis.id} title="Timeseries plot" formatX={formatSeconds}>
166167
<AxisBottom domain={xLim} label="Time [s]" />
167168
<AxisLeft domain={yLim} label={analysis.yVariable} />
168169
<For each={chartData()}>
@@ -269,7 +270,7 @@ export function VerticalProfilePlot({
269270
toggles={toggles}
270271
onChange={toggleLine}
271272
/>
272-
<Chart title="Vertical profile plot">
273+
<Chart id={analysis.id} title="Vertical profile plot">
273274
<AxisBottom domain={xLim} label={analysis.variable} />
274275
<AxisLeft domain={yLim} label="Height[m]" />
275276
<For each={profileData()}>
@@ -394,7 +395,10 @@ export function ThermodynamicPlot({ analysis }: { analysis: SkewTAnalysis }) {
394395

395396
return (
396397
<>
397-
<SkewTPlot data={() => [...skewTData(), ...observations()]} />
398+
<SkewTPlot
399+
id={analysis.id}
400+
data={() => [...skewTData(), ...observations()]}
401+
/>
398402
{TimeSlider(
399403
() => analysis.time,
400404
uniqueTimes,
@@ -443,6 +447,9 @@ async function takeScreenshot(event: MouseEvent, analyse: Analysis) {
443447
saveAs(file);
444448
}
445449

450+
// Emit a signal when plot reset button is pressed
451+
export const [resetPlot, setResetPlot] = createSignal("", { equals: false });
452+
446453
export function AnalysisCard(analysis: Analysis) {
447454
const id = createUniqueId();
448455
return (
@@ -451,6 +458,9 @@ export function AnalysisCard(analysis: Analysis) {
451458
{/* TODO: make name & description editable */}
452459
<CardTitle id={id}>{analysis.name}</CardTitle>
453460
<div class="flex gap-1">
461+
<Button variant="outline" onclick={() => setResetPlot(analysis.id)}>
462+
<MdiImageFilterCenterFocus />
463+
</Button>
454464
<Button
455465
variant="outline"
456466
onClick={(e: MouseEvent) => takeScreenshot(e, analysis)}

apps/class-solid/src/components/icons.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,21 @@ export function MdiCamera(props: JSX.IntrinsicElements["svg"]) {
355355
</svg>
356356
);
357357
}
358+
359+
export function MdiImageFilterCenterFocus(props: JSX.IntrinsicElements["svg"]) {
360+
return (
361+
<svg
362+
xmlns="http://www.w3.org/2000/svg"
363+
width="1em"
364+
height="1em"
365+
viewBox="0 0 24 24"
366+
{...props}
367+
>
368+
<title>Reset plot</title>
369+
<path
370+
fill="#888888"
371+
d="M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m7 10h-4v2h4a2 2 0 0 0 2-2v-4h-2m0-12h-4v2h4v4h2V5a2 2 0 0 0-2-2M5 5h4V3H5a2 2 0 0 0-2 2v4h2m0 6H3v4a2 2 0 0 0 2 2h4v-2H5z"
372+
/>
373+
</svg>
374+
);
375+
}

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

Lines changed: 173 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
createSignal,
77
useContext,
88
} from "solid-js";
9-
import { type SetStoreFunction, createStore } from "solid-js/store";
9+
import { type SetStoreFunction, createStore, produce } from "solid-js/store";
10+
import { cn } from "~/lib/utils";
11+
import { resetPlot } from "../Analysis";
1012

1113
export type SupportedScaleTypes =
1214
| d3.ScaleLinear<number, number, never>
@@ -35,6 +37,8 @@ interface Chart {
3537
formatX: (value: number) => string;
3638
formatY: (value: number) => string;
3739
transformX?: (x: number, y: number, scaleY: SupportedScaleTypes) => number;
40+
zoom: number;
41+
pan: [number, number];
3842
}
3943
type SetChart = SetStoreFunction<Chart>;
4044
const ChartContext = createContext<[Chart, SetChart]>();
@@ -65,24 +69,44 @@ export function ChartContainer(props: {
6569
scaleY: initialScale,
6670
formatX: d3.format(".4"),
6771
formatY: d3.format(".4"),
72+
zoom: 1,
73+
pan: [0, 0],
6874
});
75+
76+
// Update scales when props change
6977
createEffect(() => {
70-
// Update scaleXInstance when scaleX props change
78+
const [minX, maxX] = chart.scalePropsX.domain;
79+
const [minY, maxY] = chart.scalePropsY.domain;
80+
const [panX, panY] = chart.pan;
81+
const zoom = chart.zoom;
82+
83+
const zoomedXDomain = getZoomedAndPannedDomainLinear(
84+
minX,
85+
maxX,
86+
panX,
87+
zoom,
88+
);
7189
const scaleX = supportedScales[chart.scalePropsX.type]()
7290
.range(chart.scalePropsX.range)
73-
.domain(chart.scalePropsX.domain);
74-
// .nice(); // TODO: could use this instead of getNiceAxisLimits but messes up skewT
75-
updateChart("scaleX", () => scaleX);
76-
});
91+
.domain(zoomedXDomain);
92+
93+
const zoomedYDomain =
94+
chart.scalePropsY.type === "log"
95+
? getZoomedAndPannedDomainLog(minY, maxY, panY, zoom)
96+
: getZoomedAndPannedDomainLinear(minY, maxY, panY, zoom);
7797

78-
createEffect(() => {
79-
// Update scaleYInstance when scaleY props change
8098
const scaleY = supportedScales[chart.scalePropsY.type]()
8199
.range(chart.scalePropsY.range)
82-
.domain(chart.scalePropsY.domain);
83-
// .nice();
84-
updateChart("scaleY", () => scaleY);
100+
.domain(zoomedYDomain);
101+
102+
updateChart(
103+
produce((prev) => {
104+
prev.scaleX = scaleX;
105+
prev.scaleY = scaleY;
106+
}),
107+
);
85108
});
109+
86110
return (
87111
<ChartContext.Provider value={[chart, updateChart]}>
88112
<figure>{props.children}</figure>
@@ -93,16 +117,30 @@ export function ChartContainer(props: {
93117
/** Container for chart elements such as axes and lines */
94118
export function Chart(props: {
95119
children: JSX.Element;
120+
id: string;
96121
title?: string;
97122
formatX?: (value: number) => string;
98123
formatY?: (value: number) => string;
99124
transformX?: (x: number, y: number, scaleY: SupportedScaleTypes) => number;
100125
}) {
101126
const [hovering, setHovering] = createSignal(false);
102-
const [coords, setCoords] = createSignal<[number, number]>([0, 0]);
127+
const [panning, setPanning] = createSignal(false);
128+
const [dataCoords, setDataCoords] = createSignal<[number, number]>([0, 0]);
103129
const [chart, updateChart] = useChartContext();
104130
const title = props.title || "Default chart";
105131
const [marginTop, _, __, marginLeft] = chart.margin;
132+
let panstart = [0, 0];
133+
134+
createEffect(() => {
135+
if (resetPlot() === props.id) {
136+
updateChart(
137+
produce((prev) => {
138+
prev.zoom = 1;
139+
prev.pan = [0, 0];
140+
}),
141+
);
142+
}
143+
});
106144

107145
if (props.formatX) {
108146
updateChart("formatX", () => props.formatX);
@@ -114,30 +152,106 @@ export function Chart(props: {
114152
updateChart("transformX", () => props.transformX);
115153
}
116154

117-
const onMouseMove = (e: MouseEvent) => {
155+
// Utility function to calculate coordinates from mouse event
156+
const getDataCoordsFromEvent = (e: MouseEvent) => {
118157
let x = e.offsetX - marginLeft;
119158
const y = e.offsetY - marginTop;
120159

121160
if (chart.transformX) {
161+
// Correct for skewed lines in thermodynamic diagram
122162
x = chart.transformX(x, y, chart.scaleY);
123163
}
124164

125-
setCoords([chart.scaleX.invert(x), chart.scaleY.invert(y)]);
165+
return [chart.scaleX.invert(x), chart.scaleY.invert(y)];
166+
};
167+
168+
const onMouseDown = (e: MouseEvent) => {
169+
setPanning(true);
170+
panstart = getDataCoordsFromEvent(e);
171+
};
172+
173+
const onMouseMove = (e: MouseEvent) => {
174+
const [x, y] = getDataCoordsFromEvent(e);
175+
176+
if (panning()) {
177+
const [startX, startY] = panstart;
178+
179+
const dx =
180+
chart.scalePropsX.type === "log"
181+
? Math.log10(x) - Math.log10(startX)
182+
: x - startX;
183+
184+
const dy =
185+
chart.scalePropsY.type === "log"
186+
? Math.log10(y) - Math.log10(startY)
187+
: y - startY;
188+
189+
updateChart("pan", (prev) => [prev[0] - dx, prev[1] - dy]);
190+
} else {
191+
// Update the coordinate tracker in the plot
192+
setDataCoords([x, y]);
193+
}
194+
};
195+
196+
const onWheel = (e: WheelEvent) => {
197+
// Zoom towards cursor
198+
e.preventDefault();
199+
const zoomFactor = 1.1;
200+
const zoomDirection = e.deltaY < 0 ? 1 : -1;
201+
const zoomChange = zoomFactor ** zoomDirection;
202+
203+
const [cursorX, cursorY] = getDataCoordsFromEvent(e);
204+
205+
updateChart(
206+
produce((draft) => {
207+
const { scalePropsX, scalePropsY, pan } = draft;
208+
const [panX, panY] = pan;
209+
210+
// Calculate x-pan (linear only for now)
211+
const [xmin, xmax] = scalePropsX.domain;
212+
const centerX = (xmin + xmax) / 2 + panX;
213+
const dx = cursorX - centerX;
214+
215+
// Calculate y-pan
216+
const [ymin, ymax] = scalePropsY.domain;
217+
let dy: number;
218+
if (scalePropsY.type === "log") {
219+
const logCursor = Math.log10(Math.max(cursorY, 1e-10));
220+
const logCenter = (Math.log10(ymin) + Math.log10(ymax)) / 2 + panY;
221+
dy = logCursor - logCenter;
222+
} else {
223+
const centerY = (ymin + ymax) / 2 + panY;
224+
dy = cursorY - centerY;
225+
}
226+
227+
// Update the chart (mutating plays nicely with produce)
228+
draft.zoom *= zoomChange;
229+
draft.pan[0] += dx * (1 - 1 / zoomChange);
230+
draft.pan[1] += dy * (1 - 1 / zoomChange);
231+
}),
232+
);
126233
};
127234

128235
const renderXCoord = () =>
129-
hovering() ? `x: ${chart.formatX(coords()[0])}` : "";
236+
hovering() ? `x: ${chart.formatX(dataCoords()[0])}` : "";
130237
const renderYCoord = () =>
131-
hovering() ? `y: ${chart.formatY(coords()[1])}` : "";
238+
hovering() ? `y: ${chart.formatY(dataCoords()[1])}` : "";
132239

133240
return (
134241
<svg
135242
width={chart.width}
136243
height={chart.height}
137-
class="text-slate-500 text-xs tracking-wide"
244+
class={cn(
245+
"text-slate-500 text-xs tracking-wide",
246+
panning() ? "cursor-grabbing select-none" : "cursor-grab",
247+
)}
138248
onmouseover={() => setHovering(true)}
139-
onmousemove={onMouseMove}
140249
onmouseout={() => setHovering(false)}
250+
onmousedown={onMouseDown}
251+
onmouseup={() => setPanning(false)}
252+
onmousemove={onMouseMove}
253+
onmouseleave={() => setPanning(false)}
254+
onwheel={onWheel}
141255
>
142256
<title>{title}</title>
143257
<g transform={`translate(${marginLeft},${marginTop})`}>
@@ -149,6 +263,7 @@ export function Chart(props: {
149263
{renderYCoord()}
150264
</text>
151265
</g>
266+
<ClipPath />
152267
</svg>
153268
);
154269
}
@@ -163,6 +278,17 @@ export function useChartContext() {
163278
return context;
164279
}
165280

281+
// To constrain lines and other elements to the axes' extent
282+
function ClipPath() {
283+
const [chart, _updateChart] = useChartContext();
284+
285+
return (
286+
<clipPath id="clipper">
287+
<rect x="0" y="0" width={chart.innerWidth} height={chart.innerHeight} />
288+
</clipPath>
289+
);
290+
}
291+
166292
export interface ChartData<T> {
167293
label: string;
168294
color: string;
@@ -178,3 +304,32 @@ export function highlight(hex: string) {
178304
.padStart(2, "0");
179305
return `#${b(hex, 1)}${b(hex, 3)}${b(hex, 5)}`;
180306
}
307+
308+
function getZoomedAndPannedDomainLinear(
309+
min: number,
310+
max: number,
311+
pan: number,
312+
zoom: number,
313+
): [number, number] {
314+
const center = (min + max) / 2 + pan;
315+
const halfExtent = (max - min) / (2 * zoom);
316+
return [center - halfExtent, center + halfExtent];
317+
}
318+
319+
function getZoomedAndPannedDomainLog(
320+
min: number,
321+
max: number,
322+
pan: number,
323+
zoom: number,
324+
): [number, number] {
325+
const logMin = Math.log10(min);
326+
const logMax = Math.log10(max);
327+
328+
const logCenter = (logMin + logMax) / 2 + pan;
329+
const halfExtent = (logMax - logMin) / (2 * zoom);
330+
331+
const newLogMin = logCenter - halfExtent;
332+
const newLogMax = logCenter + halfExtent;
333+
334+
return [10 ** newLogMin, 10 ** newLogMax];
335+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ export function Line(d: ChartData<Point>) {
2121

2222
return (
2323
<path
24+
clip-path="url(#clipper)"
2425
onMouseEnter={() => setHovered(true)}
2526
onMouseLeave={() => setHovered(false)}
2627
fill="none"
2728
stroke={stroke()}
2829
stroke-dasharray={d.linestyle}
2930
stroke-width="3"
3031
d={l(d.data) || ""}
32+
class="cursor-pointer"
3133
>
3234
<title>{d.label}</title>
3335
</path>

0 commit comments

Comments
 (0)