Skip to content

Commit 48d2623

Browse files
authored
Merge pull request #164 from classmodel/rectangle-zoom
add rectangle zoom and double click reset
2 parents 09dbb5b + 4ef525e commit 48d2623

File tree

3 files changed

+136
-127
lines changed

3 files changed

+136
-127
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ type AxisProps = {
1212

1313
export const AxisBottom = (props: AxisProps) => {
1414
const [chart, updateChart] = useChartContext();
15+
16+
// Store original domain on first render
17+
props.domain && updateChart("originalDomainX", props.domain());
18+
19+
// Update scale props when domain or type changes
1520
createEffect(() => {
1621
props.domain && updateChart("scalePropsX", { domain: props.domain() });
1722
props.type && updateChart("scalePropsX", { type: props.type });
@@ -40,6 +45,11 @@ export const AxisBottom = (props: AxisProps) => {
4045

4146
export const AxisLeft = (props: AxisProps) => {
4247
const [chart, updateChart] = useChartContext();
48+
49+
// Store original domain on first render
50+
props.domain && updateChart("originalDomainY", props.domain());
51+
52+
// Update scale props when domain or type changes
4353
createEffect(() => {
4454
props.domain && updateChart("scalePropsY", { domain: props.domain() });
4555
props.type && updateChart("scalePropsY", { type: props.type });

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

Lines changed: 117 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ interface Chart {
3232
innerHeight: number;
3333
scalePropsX: ScaleProps;
3434
scalePropsY: ScaleProps;
35+
originalDomainX: [number, number];
36+
originalDomainY: [number, number];
3537
scaleX: SupportedScaleTypes;
3638
scaleY: SupportedScaleTypes;
3739
formatX: (value: number) => string;
3840
formatY: (value: number) => string;
3941
transformX?: (x: number, y: number, scaleY: SupportedScaleTypes) => number;
40-
zoom: number;
41-
pan: [number, number];
4242
}
4343
type SetChart = SetStoreFunction<Chart>;
4444
const ChartContext = createContext<[Chart, SetChart]>();
@@ -65,39 +65,26 @@ export function ChartContainer(props: {
6565
innerWidth,
6666
scalePropsX: { type: "linear", domain: [0, 1], range: [0, innerWidth] },
6767
scalePropsY: { type: "linear", domain: [0, 1], range: [innerHeight, 0] },
68+
originalDomainX: [0, 1],
69+
originalDomainY: [0, 1],
6870
scaleX: initialScale,
6971
scaleY: initialScale,
7072
formatX: d3.format(".4"),
7173
formatY: d3.format(".4"),
72-
zoom: 1,
73-
pan: [0, 0],
7474
});
75+
// Set original domains based on initial scale props
76+
updateChart("originalDomainX", () => chart.scalePropsX.domain);
77+
updateChart("originalDomainY", () => chart.scalePropsY.domain);
7578

7679
// Update scales when props change
7780
createEffect(() => {
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-
);
8981
const scaleX = supportedScales[chart.scalePropsX.type]()
9082
.range(chart.scalePropsX.range)
91-
.domain(zoomedXDomain);
92-
93-
const zoomedYDomain =
94-
chart.scalePropsY.type === "log"
95-
? getZoomedAndPannedDomainLog(minY, maxY, panY, zoom)
96-
: getZoomedAndPannedDomainLinear(minY, maxY, panY, zoom);
83+
.domain(chart.scalePropsX.domain);
9784

9885
const scaleY = supportedScales[chart.scalePropsY.type]()
9986
.range(chart.scalePropsY.range)
100-
.domain(zoomedYDomain);
87+
.domain(chart.scalePropsY.domain);
10188

10289
updateChart(
10390
produce((prev) => {
@@ -123,140 +110,171 @@ export function Chart(props: {
123110
formatY?: () => (value: number) => string;
124111
transformX?: (x: number, y: number, scaleY: SupportedScaleTypes) => number;
125112
}) {
113+
const [zoomRectData, setZoomRectData] = createSignal<{
114+
x0: number;
115+
y0: number;
116+
x1: number;
117+
y1: number;
118+
} | null>(null);
119+
const [zoomRectPixel, setZoomRectPixel] = createSignal<{
120+
x0: number;
121+
y0: number;
122+
x1: number;
123+
y1: number;
124+
} | null>(null);
126125
const [hovering, setHovering] = createSignal(false);
127-
const [panning, setPanning] = createSignal(false);
128126
const [dataCoords, setDataCoords] = createSignal<[number, number]>([0, 0]);
129127
const [chart, updateChart] = useChartContext();
130128
const title = props.title || "Default chart";
131129
const [marginTop, _, __, marginLeft] = chart.margin;
132-
let panstart = [0, 0];
133130

131+
function resetZoom() {
132+
updateChart(
133+
produce((draft) => {
134+
draft.scalePropsX.domain = draft.originalDomainX;
135+
draft.scalePropsY.domain = draft.originalDomainY;
136+
}),
137+
);
138+
}
139+
140+
// Reset zoom/pan when requested from outside (button outside chart area)
134141
createEffect(() => {
135142
if (resetPlot() === props.id) {
136-
updateChart(
137-
produce((prev) => {
138-
prev.zoom = 1;
139-
prev.pan = [0, 0];
140-
}),
141-
);
143+
resetZoom();
142144
}
143145
});
144146

147+
// Update formatters and transform function when props change
145148
createEffect(() => {
146-
if (props.formatX) {
147-
updateChart("formatX", () => props.formatX?.());
148-
}
149+
if (props.formatX) updateChart("formatX", () => props.formatX?.());
150+
if (props.formatY) updateChart("formatY", () => props.formatY?.());
151+
if (props.transformX) updateChart("transformX", () => props.transformX);
149152
});
150-
createEffect(() => {
151-
if (props.formatY) {
152-
updateChart("formatY", () => props.formatY?.());
153-
}
154-
});
155-
156-
if (props.transformX) {
157-
updateChart("transformX", () => props.transformX);
158-
}
159153

160154
// Utility function to calculate coordinates from mouse event
161-
const getDataCoordsFromEvent = (e: MouseEvent) => {
155+
const getDataCoordsFromEvent = (e: MouseEvent, applyTransform = true) => {
162156
let x = e.offsetX - marginLeft;
163157
const y = e.offsetY - marginTop;
164158

165-
if (chart.transformX) {
159+
if (applyTransform && chart.transformX) {
166160
// Correct for skewed lines in thermodynamic diagram
167161
x = chart.transformX(x, y, chart.scaleY);
168162
}
169163

170164
return [chart.scaleX.invert(x), chart.scaleY.invert(y)];
171165
};
172166

167+
function getPixelCoordsFromEvent(e: MouseEvent) {
168+
const x = e.offsetX - marginLeft; // x relative to chart area
169+
const y = e.offsetY - marginTop; // y relative to chart area
170+
return [x, y] as [number, number];
171+
}
172+
173173
const onMouseDown = (e: MouseEvent) => {
174-
setPanning(true);
175-
panstart = getDataCoordsFromEvent(e);
174+
const [xd, yd] = getDataCoordsFromEvent(e, false);
175+
const [xp, yp] = getPixelCoordsFromEvent(e);
176+
177+
setZoomRectPixel({ x0: xp, y0: yp, x1: xp, y1: yp });
178+
setZoomRectData({ x0: xd, y0: yd, x1: xd, y1: yd });
176179
};
177180

178181
const onMouseMove = (e: MouseEvent) => {
179-
const [x, y] = getDataCoordsFromEvent(e);
182+
// Update the coordinate tracker in the plot
183+
const [xdSkew, ydSkew] = getDataCoordsFromEvent(e, true);
184+
setDataCoords([xdSkew, ydSkew]);
180185

181-
if (panning()) {
182-
const [startX, startY] = panstart;
186+
// Update zoom rectangle if drawing
187+
const [xd, yd] = getDataCoordsFromEvent(e, false);
188+
const [xp, yp] = getPixelCoordsFromEvent(e);
183189

184-
const dx =
185-
chart.scalePropsX.type === "log"
186-
? Math.log10(x) - Math.log10(startX)
187-
: x - startX;
190+
setZoomRectPixel((zr) => (zr ? { ...zr, x1: xp, y1: yp } : null));
191+
setZoomRectData((zr) => (zr ? { ...zr, x1: xd, y1: yd } : null));
192+
};
188193

189-
const dy =
190-
chart.scalePropsY.type === "log"
191-
? Math.log10(y) - Math.log10(startY)
192-
: y - startY;
194+
const onMouseUp = () => {
195+
// Apply zoom if a rectangle was drawn
196+
const newZoomData = zoomRectData(); // enable type narrowing for null check
197+
const newZoomPixels = zoomRectData();
193198

194-
updateChart("pan", (prev) => [prev[0] - dx, prev[1] - dy]);
195-
} else {
196-
// Update the coordinate tracker in the plot
197-
setDataCoords([x, y]);
198-
}
199-
};
199+
if (!newZoomData || !newZoomPixels) return;
200200

201-
const onWheel = (e: WheelEvent) => {
202-
// Zoom towards cursor
203-
e.preventDefault();
204-
const zoomFactor = 1.1;
205-
const zoomDirection = e.deltaY < 0 ? 1 : -1;
206-
const zoomChange = zoomFactor ** zoomDirection;
201+
// Don't zoom if the rectangle is too small (ie just a click)
202+
const { x0: x0p, x1: x1p, y0: y0p, y1: y1p } = newZoomPixels;
203+
if (Math.abs(x1p - x0p) < 5 || Math.abs(y1p - y0p) < 5) {
204+
setZoomRectData(null);
205+
setZoomRectPixel(null);
206+
return;
207+
}
207208

208-
const [cursorX, cursorY] = getDataCoordsFromEvent(e);
209+
const { x0, x1, y0, y1 } = newZoomData;
209210

210211
updateChart(
211212
produce((draft) => {
212-
const { scalePropsX, scalePropsY, pan } = draft;
213-
const [panX, panY] = pan;
214-
215-
// Calculate x-pan (linear only for now)
216-
const [xmin, xmax] = scalePropsX.domain;
217-
const centerX = (xmin + xmax) / 2 + panX;
218-
const dx = cursorX - centerX;
219-
220-
// Calculate y-pan
221-
const [ymin, ymax] = scalePropsY.domain;
222-
let dy: number;
223-
if (scalePropsY.type === "log") {
224-
const logCursor = Math.log10(Math.max(cursorY, 1e-10));
225-
const logCenter = (Math.log10(ymin) + Math.log10(ymax)) / 2 + panY;
226-
dy = logCursor - logCenter;
227-
} else {
228-
const centerY = (ymin + ymax) / 2 + panY;
229-
dy = cursorY - centerY;
230-
}
231-
232-
// Update the chart (mutating plays nicely with produce)
233-
draft.zoom *= zoomChange;
234-
draft.pan[0] += dx * (1 - 1 / zoomChange);
235-
draft.pan[1] += dy * (1 - 1 / zoomChange);
213+
// Handle log scales
214+
const scaleX = draft.scalePropsX;
215+
const scaleY = draft.scalePropsY;
216+
217+
draft.scalePropsX.domain =
218+
scaleX.type === "log"
219+
? [Math.max(Math.min(x0, x1), 1e-10), Math.max(x0, x1)]
220+
: [Math.min(x0, x1), Math.max(x0, x1)];
221+
222+
draft.scalePropsY.domain =
223+
// logY is used for skew-T, use inverse Y-axis and prevent zero/negative
224+
scaleY.type === "log"
225+
? [Math.max(y0, y1), Math.max(Math.min(y0, y1), 1e-10)]
226+
: [Math.min(y0, y1), Math.max(y0, y1)];
236227
}),
237228
);
229+
230+
setZoomRectData(null);
231+
setZoomRectPixel(null);
232+
};
233+
234+
const cancelZoomRect = () => {
235+
setZoomRectData(null);
236+
setZoomRectPixel(null);
238237
};
239238

240239
const renderXCoord = () =>
241240
hovering() ? `x: ${chart.formatX(dataCoords()[0])}` : "";
242241
const renderYCoord = () =>
243242
hovering() ? `y: ${chart.formatY(dataCoords()[1])}` : "";
244243

244+
const drawZoomRect = () => {
245+
const newBounds = zoomRectPixel();
246+
if (!newBounds) return;
247+
248+
const { x0, y0, x1, y1 } = newBounds;
249+
250+
return (
251+
<rect
252+
x={Math.min(x0, x1)}
253+
y={Math.min(y0, y1)}
254+
width={Math.abs(x1 - x0)}
255+
height={Math.abs(y1 - y0)}
256+
fill="rgba(0,0,255,0.2)"
257+
stroke="blue"
258+
stroke-width={1}
259+
/>
260+
);
261+
};
262+
245263
return (
246264
<svg
247265
width={chart.width}
248266
height={chart.height}
249267
class={cn(
250268
"text-slate-500 text-xs tracking-wide",
251-
panning() ? "cursor-grabbing select-none" : "cursor-grab",
269+
zoomRectData() ? "cursor-crosshair select-none" : "cursor-crosshair",
252270
)}
253271
onmouseover={() => setHovering(true)}
254272
onmouseout={() => setHovering(false)}
255273
onmousedown={onMouseDown}
256-
onmouseup={() => setPanning(false)}
257274
onmousemove={onMouseMove}
258-
onmouseleave={() => setPanning(false)}
259-
onwheel={onWheel}
275+
onmouseup={onMouseUp}
276+
ondblclick={resetZoom}
277+
onmouseleave={cancelZoomRect}
260278
>
261279
<title>{title}</title>
262280
<g transform={`translate(${marginLeft},${marginTop})`}>
@@ -267,6 +285,7 @@ export function Chart(props: {
267285
<text x="5" y="20">
268286
{renderYCoord()}
269287
</text>
288+
{zoomRectData() && drawZoomRect()}
270289
</g>
271290
<ClipPath />
272291
</svg>
@@ -309,32 +328,3 @@ export function highlight(hex: string) {
309328
.padStart(2, "0");
310329
return `#${b(hex, 1)}${b(hex, 3)}${b(hex, 5)}`;
311330
}
312-
313-
function getZoomedAndPannedDomainLinear(
314-
min: number,
315-
max: number,
316-
pan: number,
317-
zoom: number,
318-
): [number, number] {
319-
const center = (min + max) / 2 + pan;
320-
const halfExtent = (max - min) / (2 * zoom);
321-
return [center - halfExtent, center + halfExtent];
322-
}
323-
324-
function getZoomedAndPannedDomainLog(
325-
min: number,
326-
max: number,
327-
pan: number,
328-
zoom: number,
329-
): [number, number] {
330-
const logMin = Math.log10(min);
331-
const logMax = Math.log10(max);
332-
333-
const logCenter = (logMin + logMax) / 2 + pan;
334-
const halfExtent = (logMax - logMin) / (2 * zoom);
335-
336-
const newLogMin = logCenter - halfExtent;
337-
const newLogMax = logCenter + halfExtent;
338-
339-
return [10 ** newLogMin, 10 ** newLogMax];
340-
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ function getTempAtCursor(x: number, y: number, scaleY: SupportedScaleTypes) {
2525
return x - (scaleY(basep()) - y) / tan;
2626
}
2727

28+
export function getXPixelFromTemp(
29+
T: number,
30+
p: number,
31+
scaleY: SupportedScaleTypes,
32+
) {
33+
const basep = () => scaleY.domain()[0];
34+
return T + (scaleY(basep()) - scaleY(p)) / tan;
35+
}
36+
2837
function SkewTGridLine(temperature: number) {
2938
const [chart, updateChart] = useChartContext();
3039
const x = (temp: number) => chart.scaleX(temp);

0 commit comments

Comments
 (0)