Skip to content
Merged
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
18 changes: 14 additions & 4 deletions apps/class-solid/src/components/Analysis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Switch,
createEffect,
createMemo,
createSignal,
createUniqueId,
} from "solid-js";
import { createStore } from "solid-js/store";
Expand All @@ -31,7 +32,7 @@ import {
experiments,
updateAnalysis,
} from "~/lib/store";
import { MdiCamera, MdiDelete } from "./icons";
import { MdiCamera, MdiDelete, MdiImageFilterCenterFocus } from "./icons";
import { AxisBottom, AxisLeft, getNiceAxisLimits } from "./plots/Axes";
import { Chart, ChartContainer } from "./plots/ChartContainer";
import { Legend } from "./plots/Legend";
Expand Down Expand Up @@ -162,7 +163,7 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) {
{/* TODO: get label for yVariable from model config */}
<ChartContainer>
<Legend entries={chartData} toggles={toggles} onChange={toggleLine} />
<Chart title="Timeseries plot" formatX={formatSeconds}>
<Chart id={analysis.id} title="Timeseries plot" formatX={formatSeconds}>
<AxisBottom domain={xLim} label="Time [s]" />
<AxisLeft domain={yLim} label={analysis.yVariable} />
<For each={chartData()}>
Expand Down Expand Up @@ -269,7 +270,7 @@ export function VerticalProfilePlot({
toggles={toggles}
onChange={toggleLine}
/>
<Chart title="Vertical profile plot">
<Chart id={analysis.id} title="Vertical profile plot">
<AxisBottom domain={xLim} label={analysis.variable} />
<AxisLeft domain={yLim} label="Height[m]" />
<For each={profileData()}>
Expand Down Expand Up @@ -394,7 +395,10 @@ export function ThermodynamicPlot({ analysis }: { analysis: SkewTAnalysis }) {

return (
<>
<SkewTPlot data={() => [...skewTData(), ...observations()]} />
<SkewTPlot
id={analysis.id}
data={() => [...skewTData(), ...observations()]}
/>
{TimeSlider(
() => analysis.time,
uniqueTimes,
Expand Down Expand Up @@ -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 (
Expand All @@ -451,6 +458,9 @@ export function AnalysisCard(analysis: Analysis) {
{/* TODO: make name & description editable */}
<CardTitle id={id}>{analysis.name}</CardTitle>
<div class="flex gap-1">
<Button variant="outline" onclick={() => setResetPlot(analysis.id)}>
<MdiImageFilterCenterFocus />
</Button>
<Button
variant="outline"
onClick={(e: MouseEvent) => takeScreenshot(e, analysis)}
Expand Down
18 changes: 18 additions & 0 deletions apps/class-solid/src/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,21 @@ export function MdiCamera(props: JSX.IntrinsicElements["svg"]) {
</svg>
);
}

export function MdiImageFilterCenterFocus(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
<title>Reset plot</title>
<path
fill="#888888"
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"
/>
</svg>
);
}
191 changes: 173 additions & 18 deletions apps/class-solid/src/components/plots/ChartContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
createSignal,
useContext,
} from "solid-js";
import { type SetStoreFunction, createStore } from "solid-js/store";
import { type SetStoreFunction, createStore, produce } from "solid-js/store";
import { cn } from "~/lib/utils";
import { resetPlot } from "../Analysis";

export type SupportedScaleTypes =
| d3.ScaleLinear<number, number, never>
Expand Down Expand Up @@ -35,6 +37,8 @@ interface Chart {
formatX: (value: number) => string;
formatY: (value: number) => string;
transformX?: (x: number, y: number, scaleY: SupportedScaleTypes) => number;
zoom: number;
pan: [number, number];
}
type SetChart = SetStoreFunction<Chart>;
const ChartContext = createContext<[Chart, SetChart]>();
Expand Down Expand Up @@ -65,24 +69,44 @@ export function ChartContainer(props: {
scaleY: initialScale,
formatX: d3.format(".4"),
formatY: d3.format(".4"),
zoom: 1,
pan: [0, 0],
});

// Update scales when props change
createEffect(() => {
// Update scaleXInstance when scaleX props change
const [minX, maxX] = chart.scalePropsX.domain;
const [minY, maxY] = chart.scalePropsY.domain;
const [panX, panY] = chart.pan;
const zoom = chart.zoom;

const zoomedXDomain = getZoomedAndPannedDomainLinear(
minX,
maxX,
panX,
zoom,
);
const scaleX = supportedScales[chart.scalePropsX.type]()
.range(chart.scalePropsX.range)
.domain(chart.scalePropsX.domain);
// .nice(); // TODO: could use this instead of getNiceAxisLimits but messes up skewT
updateChart("scaleX", () => scaleX);
});
.domain(zoomedXDomain);

const zoomedYDomain =
chart.scalePropsY.type === "log"
? getZoomedAndPannedDomainLog(minY, maxY, panY, zoom)
: getZoomedAndPannedDomainLinear(minY, maxY, panY, zoom);

createEffect(() => {
// Update scaleYInstance when scaleY props change
const scaleY = supportedScales[chart.scalePropsY.type]()
.range(chart.scalePropsY.range)
.domain(chart.scalePropsY.domain);
// .nice();
updateChart("scaleY", () => scaleY);
.domain(zoomedYDomain);

updateChart(
produce((prev) => {
prev.scaleX = scaleX;
prev.scaleY = scaleY;
}),
);
});

return (
<ChartContext.Provider value={[chart, updateChart]}>
<figure>{props.children}</figure>
Expand All @@ -93,16 +117,30 @@ export function ChartContainer(props: {
/** Container for chart elements such as axes and lines */
export function Chart(props: {
children: JSX.Element;
id: string;
title?: string;
formatX?: (value: number) => string;
formatY?: (value: number) => string;
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];

createEffect(() => {
if (resetPlot() === props.id) {
updateChart(
produce((prev) => {
prev.zoom = 1;
prev.pan = [0, 0];
}),
);
}
});

if (props.formatX) {
updateChart("formatX", () => props.formatX);
Expand All @@ -114,30 +152,106 @@ 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;

if (chart.transformX) {
// Correct for skewed lines in thermodynamic diagram
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);
};

const onMouseMove = (e: MouseEvent) => {
const [x, y] = getDataCoordsFromEvent(e);

if (panning()) {
const [startX, startY] = panstart;

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
setDataCoords([x, y]);
}
};

const onWheel = (e: WheelEvent) => {
// Zoom towards cursor
e.preventDefault();
const zoomFactor = 1.1;
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);
}),
);
};

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 (
<svg
width={chart.width}
height={chart.height}
class="text-slate-500 text-xs tracking-wide"
class={cn(
"text-slate-500 text-xs tracking-wide",
panning() ? "cursor-grabbing select-none" : "cursor-grab",
)}
onmouseover={() => setHovering(true)}
onmousemove={onMouseMove}
onmouseout={() => setHovering(false)}
onmousedown={onMouseDown}
onmouseup={() => setPanning(false)}
onmousemove={onMouseMove}
onmouseleave={() => setPanning(false)}
onwheel={onWheel}
>
<title>{title}</title>
<g transform={`translate(${marginLeft},${marginTop})`}>
Expand All @@ -149,6 +263,7 @@ export function Chart(props: {
{renderYCoord()}
</text>
</g>
<ClipPath />
</svg>
);
}
Expand All @@ -163,6 +278,17 @@ export function useChartContext() {
return context;
}

// To constrain lines and other elements to the axes' extent
function ClipPath() {
const [chart, _updateChart] = useChartContext();

return (
<clipPath id="clipper">
<rect x="0" y="0" width={chart.innerWidth} height={chart.innerHeight} />
</clipPath>
);
}

export interface ChartData<T> {
label: string;
color: string;
Expand All @@ -178,3 +304,32 @@ export function highlight(hex: string) {
.padStart(2, "0");
return `#${b(hex, 1)}${b(hex, 3)}${b(hex, 5)}`;
}

function getZoomedAndPannedDomainLinear(
min: number,
max: number,
pan: number,
zoom: number,
): [number, number] {
const center = (min + max) / 2 + pan;
const halfExtent = (max - min) / (2 * zoom);
return [center - halfExtent, center + halfExtent];
}

function getZoomedAndPannedDomainLog(
min: number,
max: number,
pan: number,
zoom: number,
): [number, number] {
const logMin = Math.log10(min);
const logMax = Math.log10(max);

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];
}
2 changes: 2 additions & 0 deletions apps/class-solid/src/components/plots/Line.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ export function Line(d: ChartData<Point>) {

return (
<path
clip-path="url(#clipper)"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
fill="none"
stroke={stroke()}
stroke-dasharray={d.linestyle}
stroke-width="3"
d={l(d.data) || ""}
class="cursor-pointer"
>
<title>{d.label}</title>
</path>
Expand Down
Loading