From 15b1562f04e18dc91503c5d02723d883d7394e33 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 11 Apr 2025 10:59:12 +0200 Subject: [PATCH 01/47] Implement basic zoom; TODO: fix angle in skewT --- .../src/components/plots/ChartContainer.tsx | 51 +++++++++++++++++-- .../class-solid/src/components/plots/Line.tsx | 1 + .../src/components/plots/skewTlogP.tsx | 11 ---- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index f8a3cf3..ea08e96 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -35,6 +35,7 @@ interface Chart { formatX: (value: number) => string; formatY: (value: number) => string; transformX?: (x: number, y: number, scaleY: SupportedScaleTypes) => number; + zoom: number; } type SetChart = SetStoreFunction; const ChartContext = createContext<[Chart, SetChart]>(); @@ -65,24 +66,45 @@ export function ChartContainer(props: { scaleY: initialScale, formatX: d3.format(".4"), formatY: d3.format(".4"), + zoom: 1, }); + + // Update scaleXInstance when scaleX props change createEffect(() => { - // Update scaleXInstance when scaleX props change + const fullExtent = + chart.scalePropsX.domain[1] - chart.scalePropsX.domain[0]; + const centerExtent = chart.scalePropsX.domain[0] + fullExtent / 2; + const zoomedExtent = fullExtent / chart.zoom; + const zoomedDomain = [ + centerExtent - zoomedExtent / 2, + centerExtent + zoomedExtent / 2, + ]; + const scaleX = supportedScales[chart.scalePropsX.type]() .range(chart.scalePropsX.range) - .domain(chart.scalePropsX.domain); + .domain(zoomedDomain); // .nice(); // TODO: could use this instead of getNiceAxisLimits but messes up skewT updateChart("scaleX", () => scaleX); }); + // Update scaleYInstance when scaleY props change createEffect(() => { - // Update scaleYInstance when scaleY props change + const fullExtent = + chart.scalePropsY.domain[1] - chart.scalePropsY.domain[0]; + const centerExtent = chart.scalePropsY.domain[0] + fullExtent / 2; + const zoomedExtent = fullExtent / chart.zoom; + const zoomedDomain = [ + centerExtent - zoomedExtent / 2, + centerExtent + zoomedExtent / 2, + ]; + const scaleY = supportedScales[chart.scalePropsY.type]() .range(chart.scalePropsY.range) - .domain(chart.scalePropsY.domain); + .domain(zoomedDomain); // .nice(); updateChart("scaleY", () => scaleY); }); + return (
{props.children}
@@ -125,6 +147,14 @@ export function Chart(props: { setCoords([chart.scaleX.invert(x), chart.scaleY.invert(y)]); }; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const zoomFactor = 1.1; + updateChart("zoom", (prev) => + e.deltaY < 0 ? prev * zoomFactor : prev / zoomFactor, + ); + }; + const renderXCoord = () => hovering() ? `x: ${chart.formatX(coords()[0])}` : ""; const renderYCoord = () => @@ -138,6 +168,7 @@ export function Chart(props: { onmouseover={() => setHovering(true)} onmousemove={onMouseMove} onmouseout={() => setHovering(false)} + onwheel={onWheel} > {title} @@ -149,6 +180,7 @@ export function Chart(props: { {renderYCoord()} + ); } @@ -163,6 +195,17 @@ export function useChartContext() { return context; } +// To constrain lines and other elements to the axes' extent +function ClipPath() { + const [chart, _updateChart] = useChartContext(); + + return ( + + + + ); +} + export interface ChartData { label: string; color: string; diff --git a/apps/class-solid/src/components/plots/Line.tsx b/apps/class-solid/src/components/plots/Line.tsx index 0320a2d..23d3579 100644 --- a/apps/class-solid/src/components/plots/Line.tsx +++ b/apps/class-solid/src/components/plots/Line.tsx @@ -21,6 +21,7 @@ export function Line(d: ChartData) { return ( setHovered(true)} onMouseLeave={() => setHovered(false)} fill="none" diff --git a/apps/class-solid/src/components/plots/skewTlogP.tsx b/apps/class-solid/src/components/plots/skewTlogP.tsx index 26bb985..1f98603 100644 --- a/apps/class-solid/src/components/plots/skewTlogP.tsx +++ b/apps/class-solid/src/components/plots/skewTlogP.tsx @@ -26,16 +26,6 @@ function getTempAtCursor(x: number, y: number, scaleY: SupportedScaleTypes) { return x + 0.5 - (scaleY(basep) - y) / tan; } -function ClipPath() { - const [chart, updateChart] = useChartContext(); - - return ( - - - - ); -} - function SkewTGridLine(temperature: number) { const [chart, updateChart] = useChartContext(); const x = (temp: number) => chart.scaleX(temp); @@ -201,7 +191,6 @@ export function SkewTPlot(props: { data: () => ChartData[] }) { tickValues={pressureLines} label="Pressure [hPa]" /> - {(t) => SkewTGridLine(t)} {(p) => LogPGridLine(p)} {(d) => } From 4b9625c9f07da8f0c23e3cb6aec80ce55b1b4e7f Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 11 Apr 2025 11:16:28 +0200 Subject: [PATCH 02/47] Add logarithmic zoom for skew-T diagram; TODO: T calculations should use real axis extent rather than fixes base/top pressure --- .../src/components/plots/ChartContainer.tsx | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index ea08e96..527dad7 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -71,37 +71,31 @@ export function ChartContainer(props: { // Update scaleXInstance when scaleX props change createEffect(() => { - const fullExtent = - chart.scalePropsX.domain[1] - chart.scalePropsX.domain[0]; - const centerExtent = chart.scalePropsX.domain[0] + fullExtent / 2; - const zoomedExtent = fullExtent / chart.zoom; - const zoomedDomain = [ - centerExtent - zoomedExtent / 2, - centerExtent + zoomedExtent / 2, - ]; + const [min, max] = chart.scalePropsX.domain; + const zoom = chart.zoom; + const zoomedDomain = linearZoom(min, max, zoom); const scaleX = supportedScales[chart.scalePropsX.type]() .range(chart.scalePropsX.range) .domain(zoomedDomain); - // .nice(); // TODO: could use this instead of getNiceAxisLimits but messes up skewT + updateChart("scaleX", () => scaleX); }); // Update scaleYInstance when scaleY props change createEffect(() => { - const fullExtent = - chart.scalePropsY.domain[1] - chart.scalePropsY.domain[0]; - const centerExtent = chart.scalePropsY.domain[0] + fullExtent / 2; - const zoomedExtent = fullExtent / chart.zoom; - const zoomedDomain = [ - centerExtent - zoomedExtent / 2, - centerExtent + zoomedExtent / 2, - ]; + const [min, max] = chart.scalePropsY.domain; + const zoom = chart.zoom; + + const zoomedDomain = + chart.scalePropsY.type === "log" + ? logarithmicZoom(min, max, zoom) + : linearZoom(min, max, zoom); const scaleY = supportedScales[chart.scalePropsY.type]() .range(chart.scalePropsY.range) .domain(zoomedDomain); - // .nice(); + updateChart("scaleY", () => scaleY); }); @@ -221,3 +215,29 @@ export function highlight(hex: string) { .padStart(2, "0"); return `#${b(hex, 1)}${b(hex, 3)}${b(hex, 5)}`; } + +function linearZoom( + min: number, + max: number, + zoomFactor: number, +): [number, number] { + const center = (min + max) / 2; + const halfExtent = (max - min) / (2 * zoomFactor); + return [center - halfExtent, center + halfExtent]; +} + +function logarithmicZoom( + min: number, + max: number, + zoomFactor: number, +): [number, number] { + const logMin = Math.log10(min); + const logMax = Math.log10(max); + const center = (logMin + logMax) / 2; + const halfExtent = (logMax - logMin) / (2 * zoomFactor); + + const newLogMin = center - halfExtent; + const newLogMax = center + halfExtent; + + return [10 ** newLogMin, 10 ** newLogMax]; +} From 89f028b0230bb8e6ca2c041c789f643537b1521f Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 11 Apr 2025 16:02:29 +0200 Subject: [PATCH 03/47] Fix skew-T lines responding to original extent instead of actual; now they don't tilt anymore --- .../src/components/plots/skewTlogP.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/class-solid/src/components/plots/skewTlogP.tsx b/apps/class-solid/src/components/plots/skewTlogP.tsx index 1f98603..a9cfc72 100644 --- a/apps/class-solid/src/components/plots/skewTlogP.tsx +++ b/apps/class-solid/src/components/plots/skewTlogP.tsx @@ -19,21 +19,22 @@ interface SoundingRecord { const deg2rad = Math.PI / 180; const tan = Math.tan(55 * deg2rad); -const basep = 1050; -const topPressure = 100; function getTempAtCursor(x: number, y: number, scaleY: SupportedScaleTypes) { - return x + 0.5 - (scaleY(basep) - y) / tan; + const basep = () => scaleY.domain()[0]; + return x - (scaleY(basep()) - y) / tan; } function SkewTGridLine(temperature: number) { const [chart, updateChart] = useChartContext(); const x = (temp: number) => chart.scaleX(temp); const y = (pres: number) => chart.scaleY(pres); + const basep = () => chart.scaleY.domain()[0]; + const topPressure = () => chart.scaleY.domain()[1]; return ( ); } @@ -66,13 +68,13 @@ function DryAdiabat(d: [number, number][]) { const [chart, updateChart] = useChartContext(); const x = (temp: number) => chart.scaleX(temp); const y = (pres: number) => chart.scaleY(pres); - + const basep = () => chart.scaleY.domain()[0]; const dryline = d3 .line() .x( (d) => x((273.15 + d[1]) / (1000 / d[0]) ** 0.286 - 273.15) + - (y(basep) - y(d[0])) / tan, + (y(basep()) - y(d[0])) / tan, ) .y((d) => y(d[0])); return ( @@ -93,15 +95,16 @@ function Sounding(data: ChartData) { // Scales and axes. Note the inverted domain for the y-scale: bigger is up! const x = (temp: number) => chart.scaleX(temp); const y = (pres: number) => chart.scaleY(pres); + const basep = () => chart.scaleY.domain()[0]; const temperatureLine = d3 .line() - .x((d) => x(d.T - 273.15) + (y(basep) - y(d.p)) / tan) + .x((d) => x(d.T - 273.15) + (y(basep()) - y(d.p)) / tan) .y((d) => y(d.p)); const dewpointLine = d3 .line() - .x((d) => x(d.Td - 273.15) + (y(basep) - y(d.p)) / tan) + .x((d) => x(d.Td - 273.15) + (y(basep()) - y(d.p)) / tan) .y((d) => y(d.p)); const titleT = () => `${data.label} T`; @@ -144,7 +147,14 @@ export function SkewTPlot(props: { 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 initialBasePressure = 1050; + const initialTopPressure = 100; + + const pressureGrid = d3.range( + initialTopPressure, + initialBasePressure + 1, + 10, + ); const temperatureGrid = d3.range(-30, 240, 20); const dryAdiabats: [number, number][][] = temperatureGrid.map((temperature) => pressureGrid.map((pressure) => [pressure, temperature]), @@ -187,7 +197,7 @@ export function SkewTPlot(props: { data: () => ChartData[] }) { /> [basep, topPressure]} + domain={() => [initialBasePressure, initialTopPressure]} tickValues={pressureLines} label="Pressure [hPa]" /> From 8e29a5aff49979ac9cd91f8d748dd49d7c57b0f2 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 11 Apr 2025 17:08:31 +0200 Subject: [PATCH 04/47] Add panning effect, but it is stroboscopic and doesn't work for skewT yet --- .../src/components/plots/ChartContainer.tsx | 84 ++++++++++++++----- 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index 527dad7..46f7587 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -36,6 +36,7 @@ interface Chart { formatY: (value: number) => string; transformX?: (x: number, y: number, scaleY: SupportedScaleTypes) => number; zoom: number; + pan: [number, number]; } type SetChart = SetStoreFunction; const ChartContext = createContext<[Chart, SetChart]>(); @@ -67,13 +68,15 @@ export function ChartContainer(props: { formatX: d3.format(".4"), formatY: d3.format(".4"), zoom: 1, + pan: [0, 0], }); // Update scaleXInstance when scaleX props change createEffect(() => { const [min, max] = chart.scalePropsX.domain; + const pan = chart.pan[0]; const zoom = chart.zoom; - const zoomedDomain = linearZoom(min, max, zoom); + const zoomedDomain = getZoomedAndPannedDomainLinear(min, max, pan, zoom); const scaleX = supportedScales[chart.scalePropsX.type]() .range(chart.scalePropsX.range) @@ -82,15 +85,16 @@ export function ChartContainer(props: { updateChart("scaleX", () => scaleX); }); - // Update scaleYInstance when scaleY props change + // Update scales when props change createEffect(() => { const [min, max] = chart.scalePropsY.domain; + const pan = chart.pan[1]; const zoom = chart.zoom; const zoomedDomain = chart.scalePropsY.type === "log" - ? logarithmicZoom(min, max, zoom) - : linearZoom(min, max, zoom); + ? getZoomedAndPannedDomainLog(min, max, pan, zoom) + : getZoomedAndPannedDomainLinear(min, max, pan, zoom); const scaleY = supportedScales[chart.scalePropsY.type]() .range(chart.scalePropsY.range) @@ -115,10 +119,12 @@ export function Chart(props: { transformX?: (x: number, y: number, scaleY: SupportedScaleTypes) => number; }) { const [hovering, setHovering] = createSignal(false); - const [coords, setCoords] = createSignal<[number, number]>([0, 0]); + const [panning, setPanning] = createSignal(false); + const [dataCoords, setDataCoords] = createSignal<[number, number]>([0, 0]); const [chart, updateChart] = useChartContext(); const title = props.title || "Default chart"; const [marginTop, _, __, marginLeft] = chart.margin; + let panstart = [0, 0]; if (props.formatX) { updateChart("formatX", () => props.formatX); @@ -130,7 +136,8 @@ export function Chart(props: { updateChart("transformX", () => props.transformX); } - const onMouseMove = (e: MouseEvent) => { + // Utility function to calculate coordinates from mouse event + const getDataCoordsFromEvent = (e: MouseEvent) => { let x = e.offsetX - marginLeft; const y = e.offsetY - marginTop; @@ -138,7 +145,38 @@ export function Chart(props: { x = chart.transformX(x, y, chart.scaleY); } - setCoords([chart.scaleX.invert(x), chart.scaleY.invert(y)]); + return [chart.scaleX.invert(x), chart.scaleY.invert(y)]; + }; + + const onMouseDown = (e: MouseEvent) => { + setPanning(true); + panstart = getDataCoordsFromEvent(e); + }; + + let animationFrameId: number | null = null; + const onMouseMove = (e: MouseEvent) => { + // If an animation frame is already scheduled, cancel it to prevent multiple frames being scheduled unnecessarily + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + + // Request a new animation frame for the next paint cycle + animationFrameId = requestAnimationFrame(() => handlePanMove(e)); + }; + + const handlePanMove = (e: MouseEvent) => { + const [x, y] = getDataCoordsFromEvent(e); + + if (panning()) { + const [startX, startY] = panstart; + const dx = x - startX; + const dy = y - startY; + panstart = [x, y]; + updateChart("pan", (prev) => [prev[0] - dx, prev[1] - dy]); + } else { + // Update the coordinate tracker in the plot when not panning + setDataCoords([x, y]); + } }; const onWheel = (e: WheelEvent) => { @@ -150,9 +188,9 @@ export function Chart(props: { }; const renderXCoord = () => - hovering() ? `x: ${chart.formatX(coords()[0])}` : ""; + hovering() ? `x: ${chart.formatX(dataCoords()[0])}` : ""; const renderYCoord = () => - hovering() ? `y: ${chart.formatY(coords()[1])}` : ""; + hovering() ? `y: ${chart.formatY(dataCoords()[1])}` : ""; return ( setHovering(true)} - onmousemove={onMouseMove} onmouseout={() => setHovering(false)} + onmousedown={onMouseDown} + onmouseup={() => setPanning(false)} + onmousemove={onMouseMove} + onmouseleave={() => setPanning(false)} onwheel={onWheel} > {title} @@ -216,28 +257,31 @@ export function highlight(hex: string) { return `#${b(hex, 1)}${b(hex, 3)}${b(hex, 5)}`; } -function linearZoom( +function getZoomedAndPannedDomainLinear( min: number, max: number, - zoomFactor: number, + pan: number, + zoom: number, ): [number, number] { - const center = (min + max) / 2; - const halfExtent = (max - min) / (2 * zoomFactor); + const center = (min + max) / 2 + pan; + const halfExtent = (max - min) / (2 * zoom); return [center - halfExtent, center + halfExtent]; } -function logarithmicZoom( +function getZoomedAndPannedDomainLog( min: number, max: number, - zoomFactor: number, + pan: number, + zoom: number, ): [number, number] { const logMin = Math.log10(min); const logMax = Math.log10(max); - const center = (logMin + logMax) / 2; - const halfExtent = (logMax - logMin) / (2 * zoomFactor); - const newLogMin = center - halfExtent; - const newLogMax = center + halfExtent; + const logCenter = (logMin + logMax) / 2 + pan; + const halfExtent = (logMax - logMin) / (2 * zoom); + + const newLogMin = logCenter - halfExtent; + const newLogMax = logCenter + halfExtent; return [10 ** newLogMin, 10 ** newLogMax]; } From 53f2cb19b5e6a0d2b14849101d76b20438eb31a2 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 11 Apr 2025 17:14:05 +0200 Subject: [PATCH 05/47] combine side-effects for both axes in a single callback --- .../src/components/plots/ChartContainer.tsx | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index 46f7587..e9251e4 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -71,35 +71,33 @@ export function ChartContainer(props: { pan: [0, 0], }); - // Update scaleXInstance when scaleX props change + // Update scales when props change createEffect(() => { - const [min, max] = chart.scalePropsX.domain; - const pan = chart.pan[0]; + const [minX, maxX] = chart.scalePropsX.domain; + const [minY, maxY] = chart.scalePropsY.domain; + const [panX, panY] = chart.pan; const zoom = chart.zoom; - const zoomedDomain = getZoomedAndPannedDomainLinear(min, max, pan, zoom); + const zoomedXDomain = getZoomedAndPannedDomainLinear( + minX, + maxX, + panX, + zoom, + ); const scaleX = supportedScales[chart.scalePropsX.type]() .range(chart.scalePropsX.range) - .domain(zoomedDomain); - - updateChart("scaleX", () => scaleX); - }); + .domain(zoomedXDomain); - // Update scales when props change - createEffect(() => { - const [min, max] = chart.scalePropsY.domain; - const pan = chart.pan[1]; - const zoom = chart.zoom; - - const zoomedDomain = + const zoomedYDomain = chart.scalePropsY.type === "log" - ? getZoomedAndPannedDomainLog(min, max, pan, zoom) - : getZoomedAndPannedDomainLinear(min, max, pan, zoom); + ? getZoomedAndPannedDomainLog(minY, maxY, panY, zoom) + : getZoomedAndPannedDomainLinear(minY, maxY, panY, zoom); const scaleY = supportedScales[chart.scalePropsY.type]() .range(chart.scalePropsY.range) - .domain(zoomedDomain); + .domain(zoomedYDomain); + updateChart("scaleX", () => scaleX); updateChart("scaleY", () => scaleY); }); From ca46f1fd3cde488ae1a250747e94bf6c642d80ba Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 18 Apr 2025 10:07:11 +0200 Subject: [PATCH 06/47] Remove animationframe --- .../src/components/plots/ChartContainer.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index e9251e4..41ea82d 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -151,18 +151,7 @@ export function Chart(props: { panstart = getDataCoordsFromEvent(e); }; - let animationFrameId: number | null = null; const onMouseMove = (e: MouseEvent) => { - // If an animation frame is already scheduled, cancel it to prevent multiple frames being scheduled unnecessarily - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - - // Request a new animation frame for the next paint cycle - animationFrameId = requestAnimationFrame(() => handlePanMove(e)); - }; - - const handlePanMove = (e: MouseEvent) => { const [x, y] = getDataCoordsFromEvent(e); if (panning()) { @@ -172,7 +161,7 @@ export function Chart(props: { panstart = [x, y]; updateChart("pan", (prev) => [prev[0] - dx, prev[1] - dy]); } else { - // Update the coordinate tracker in the plot when not panning + // Update the coordinate tracker in the plot setDataCoords([x, y]); } }; From 8be96356c597732bbf11a00ac10aff885b820bd0 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 18 Apr 2025 10:14:34 +0200 Subject: [PATCH 07/47] Use produce to update both scales in a single call --- .../src/components/plots/ChartContainer.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index 41ea82d..0e1a73a 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -6,7 +6,7 @@ import { createSignal, useContext, } from "solid-js"; -import { type SetStoreFunction, createStore } from "solid-js/store"; +import { type SetStoreFunction, createStore, produce } from "solid-js/store"; export type SupportedScaleTypes = | d3.ScaleLinear @@ -97,8 +97,12 @@ export function ChartContainer(props: { .range(chart.scalePropsY.range) .domain(zoomedYDomain); - updateChart("scaleX", () => scaleX); - updateChart("scaleY", () => scaleY); + updateChart( + produce((prev) => { + prev.scaleX = scaleX; + prev.scaleY = scaleY; + }), + ); }); return ( From 859557b4a527f17691fc84bc99af9cc50189588e Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 18 Apr 2025 10:19:24 +0200 Subject: [PATCH 08/47] Don't update panstart; this fixes the jittering --- apps/class-solid/src/components/plots/ChartContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index 0e1a73a..a778998 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -162,7 +162,7 @@ export function Chart(props: { const [startX, startY] = panstart; const dx = x - startX; const dy = y - startY; - panstart = [x, y]; + // panstart = [x, y]; updateChart("pan", (prev) => [prev[0] - dx, prev[1] - dy]); } else { // Update the coordinate tracker in the plot From 9c32384520fcca6ab637b367d87d12ca9a84ac15 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 18 Apr 2025 10:31:51 +0200 Subject: [PATCH 09/47] Also work in log space --- .../src/components/plots/ChartContainer.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index a778998..0cc427c 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -144,6 +144,7 @@ export function Chart(props: { const y = e.offsetY - marginTop; if (chart.transformX) { + // Correct for skewed lines in thermodynamic diagram x = chart.transformX(x, y, chart.scaleY); } @@ -161,8 +162,15 @@ export function Chart(props: { if (panning()) { const [startX, startY] = panstart; const dx = x - startX; - const dy = y - startY; - // panstart = [x, y]; + let dy: number; + + if (chart.scalePropsY.type === "log") { + const logStartY = Math.log10(startY); + const logY = Math.log10(y); + dy = logY - logStartY; + } else { + dy = y - startY; + } updateChart("pan", (prev) => [prev[0] - dx, prev[1] - dy]); } else { // Update the coordinate tracker in the plot From d3fae43694231c07a0874090f3b07e6eaeb761d7 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 18 Apr 2025 10:36:57 +0200 Subject: [PATCH 10/47] Make consistent for x-direction --- .../src/components/plots/ChartContainer.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index 0cc427c..e422c36 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -161,16 +161,17 @@ export function Chart(props: { if (panning()) { const [startX, startY] = panstart; - const dx = x - startX; - let dy: number; - - if (chart.scalePropsY.type === "log") { - const logStartY = Math.log10(startY); - const logY = Math.log10(y); - dy = logY - logStartY; - } else { - dy = y - startY; - } + + const dx = + chart.scalePropsX.type === "log" + ? Math.log10(x) - Math.log10(startX) + : x - startX; + + const dy = + chart.scalePropsY.type === "log" + ? Math.log10(y) - Math.log10(startY) + : y - startY; + updateChart("pan", (prev) => [prev[0] - dx, prev[1] - dy]); } else { // Update the coordinate tracker in the plot From ea696fc77e6dca63a7d0c659ffdab4cd86465df2 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 18 Apr 2025 11:23:42 +0200 Subject: [PATCH 11/47] zoom towards cursor --- .../src/components/plots/ChartContainer.tsx | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/class-solid/src/components/plots/ChartContainer.tsx b/apps/class-solid/src/components/plots/ChartContainer.tsx index e422c36..a3df2ec 100644 --- a/apps/class-solid/src/components/plots/ChartContainer.tsx +++ b/apps/class-solid/src/components/plots/ChartContainer.tsx @@ -180,10 +180,41 @@ export function Chart(props: { }; const onWheel = (e: WheelEvent) => { + // Zoom towards cursor e.preventDefault(); const zoomFactor = 1.1; - updateChart("zoom", (prev) => - e.deltaY < 0 ? prev * zoomFactor : prev / zoomFactor, + const zoomDirection = e.deltaY < 0 ? 1 : -1; + const zoomChange = zoomFactor ** zoomDirection; + + const [cursorX, cursorY] = getDataCoordsFromEvent(e); + + updateChart( + produce((draft) => { + const { scalePropsX, scalePropsY, pan } = draft; + const [panX, panY] = pan; + + // Calculate x-pan (linear only for now) + const [xmin, xmax] = scalePropsX.domain; + const centerX = (xmin + xmax) / 2 + panX; + const dx = cursorX - centerX; + + // Calculate y-pan + const [ymin, ymax] = scalePropsY.domain; + let dy: number; + if (scalePropsY.type === "log") { + const logCursor = Math.log10(Math.max(cursorY, 1e-10)); + const logCenter = (Math.log10(ymin) + Math.log10(ymax)) / 2 + panY; + dy = logCursor - logCenter; + } else { + const centerY = (ymin + ymax) / 2 + panY; + dy = cursorY - centerY; + } + + // Update the chart (mutating plays nicely with produce) + draft.zoom *= zoomChange; + draft.pan[0] += dx * (1 - 1 / zoomChange); + draft.pan[1] += dy * (1 - 1 / zoomChange); + }), ); }; From b65f5161d95d16028cb3315b581f2a73972f7bb3 Mon Sep 17 00:00:00 2001 From: Peter Kalverla Date: Fri, 18 Apr 2025 13:00:37 +0200 Subject: [PATCH 12/47] Add reset plot button --- apps/class-solid/src/components/Analysis.tsx | 18 ++++++++++++---- apps/class-solid/src/components/icons.tsx | 21 +++++++++++++++++++ .../src/components/plots/ChartContainer.tsx | 13 ++++++++++++ .../src/components/plots/skewTlogP.tsx | 6 +++++- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 6685914..d1ee9d2 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -12,6 +12,7 @@ import { Switch, createEffect, createMemo, + createSignal, createUniqueId, } from "solid-js"; import { createStore } from "solid-js/store"; @@ -31,7 +32,7 @@ import { experiments, updateAnalysis, } from "~/lib/store"; -import { MdiCamera, MdiDelete } from "./icons"; +import { MaterialSymbolsLightResetImage, MdiCamera, MdiDelete } from "./icons"; import { AxisBottom, AxisLeft, getNiceAxisLimits } from "./plots/Axes"; import { Chart, ChartContainer } from "./plots/ChartContainer"; import { Legend } from "./plots/Legend"; @@ -162,7 +163,7 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { {/* TODO: get label for yVariable from model config */} - + @@ -269,7 +270,7 @@ export function VerticalProfilePlot({ toggles={toggles} onChange={toggleLine} /> - + @@ -394,7 +395,10 @@ export function ThermodynamicPlot({ analysis }: { analysis: SkewTAnalysis }) { return ( <> - [...skewTData(), ...observations()]} /> + [...skewTData(), ...observations()]} + /> {TimeSlider( () => analysis.time, uniqueTimes, @@ -443,6 +447,9 @@ async function takeScreenshot(event: MouseEvent, analyse: Analysis) { saveAs(file); } +// Emit a signal when plot reset button is pressed +export const [resetPlot, setResetPlot] = createSignal("", { equals: false }); + export function AnalysisCard(analysis: Analysis) { const id = createUniqueId(); return ( @@ -451,6 +458,9 @@ export function AnalysisCard(analysis: Analysis) { {/* TODO: make name & description editable */} {analysis.name}
+