import { useRef, useState, type RefObject } from "react"; import { usePlotlyClick } from "@elvis/ui"; import Plot from "react-plotly.js"; import type { Camera, Data, Layout } from "plotly.js"; import type { Trajectory } from "./useOptimisation"; import type { FunctionValues, Range, SurfaceValues } from "./types"; interface OptimisationPlotProps { values: FunctionValues | null; trajectories: Trajectory[]; xRange: RefObject; yRange: RefObject; onViewChange: (x: Range, y: Range) => void; // Called with data coordinates when the plot is clicked; null ignores clicks onClick: ((x: number, y: number) => void) | null; // With two variables: a 3D surface instead of a contour map surface: boolean; arrows: boolean; } const CONFIG = { scrollZoom: true, displayModeBar: true, doubleClick: false, responsive: true } as const; const LEGEND = { x: 0.02, y: 0.98, xanchor: "left", yanchor: "top", bgcolor: "rgba(255,255,255,0.8)" } as const; const INK = "#1B2A4A"; export default function OptimisationPlot({ values, trajectories, xRange, yRange, onViewChange, onClick, surface, arrows }: OptimisationPlotProps) { const plotRef = useRef(null); // The 3D camera as the student last left it. Plotly resets it on each redraw // while stepping (uirevision alone doesn't keep it), so it's passed back in. const [camera, setCamera] = useState | undefined>(undefined); // While the student drags the surface, the frame they started on; a redraw // from stepping would otherwise cancel the drag const [held, setHeld] = useState<{ data: Data[]; layout: Partial } | null>(null); // el exists at runtime only // eslint-disable-next-line @typescript-eslint/no-explicit-any usePlotlyClick(() => (plotRef.current as any)?.el, surface ? null : onClick); const twoVariables = values !== null && "y" in values; const showLegend = trajectories.length > 1; if (twoVariables && surface) { const data = [surfaceTrace(values), ...trajectories.flatMap((t) => pathTraces(t, true))]; const layout: Partial = { // Keep the camera while stepping uirevision: "surface", showlegend: showLegend, legend: LEGEND, margin: { t: 20, r: 20, b: 20, l: 20 }, scene: { camera, aspectmode: "cube", xaxis: { title: { text: "x" }, range: [...xRange.current] }, yaxis: { title: { text: "y" }, range: [...yRange.current] }, zaxis: { title: { text: "f(x, y)" } }, }, }; return (
{ setHeld({ data, layout }); // Catch up once the drag ends, after Plotly has reported the new camera window.addEventListener("pointerup", () => setTimeout(() => setHeld(null), 50), { once: true }); }} > { const moved = (event as { "scene.camera"?: Partial })["scene.camera"]; if (moved) { setCamera(moved); } }} config={CONFIG} style={{ width: "100%", height: "100%" }} />
); } const data: Data[] = []; if (values && "y" in values) { data.push(contourTrace(values)); if (arrows) { data.push(...arrowTraces(values, xRange.current, yRange.current)); } } else if (values) { data.push({ x: values.x, y: values.f, type: "scatter", mode: "lines", line: { color: INK, width: 2 }, name: "f(x)", showlegend: false, hovertemplate: "x = %{x:.4g}
f(x) = %{y:.4g}", }); } data.push(...trajectories.flatMap((t) => pathTraces(t, false, twoVariables))); const layout: Partial = { dragmode: "pan", // Keep the user's pan and zoom while stepping; a new range (e.g. from a preset) still applies uirevision: "view", showlegend: showLegend, legend: LEGEND, margin: { t: 40, r: 40, b: 40, l: 40 }, xaxis: { title: { text: "x" }, range: [...xRange.current] }, yaxis: { title: { text: twoVariables ? "y" : "f(x)" }, range: [...yRange.current] }, }; return (
{ // eslint-disable-next-line @typescript-eslint/no-explicit-any const full = (plotRef.current as any)?.el?._fullLayout; const x = (full?.xaxis?.range ?? [event["xaxis.range[0]"], event["xaxis.range[1]"]]) as Range; const y = (full?.yaxis?.range ?? [event["yaxis.range[0]"], event["yaxis.range[1]"]]) as Range; const changed = x[0] !== xRange.current[0] || x[1] !== xRange.current[1] || y[0] !== yRange.current[0] || y[1] !== yRange.current[1]; if (changed && x.every(Number.isFinite) && y.every(Number.isFinite)) { onViewChange([x[0], x[1]], [y[0], y[1]]); } }} config={CONFIG} style={{ width: "100%", height: "100%", minWidth: 0 }} />
); } function contourTrace(values: SurfaceValues): Data { return { x: values.x, y: values.y, z: values.f, type: "contour", colorscale: "Viridis", contours: { coloring: "heatmap", showlines: false }, colorbar: { thickness: 12, title: { text: "f" } }, hovertemplate: "x = %{x:.3g}, y = %{y:.3g}
f = %{z:.4g}", } as Data; } function surfaceTrace(values: SurfaceValues): Data { return { x: values.x, y: values.y, z: values.f, type: "surface", colorscale: "Viridis", opacity: 0.85, colorbar: { thickness: 12, title: { text: "f" } }, hovertemplate: "x = %{x:.3g}, y = %{y:.3g}
f = %{z:.4g}", } as Data; } // A run's path and, larger, its current point function pathTraces(t: Trajectory, threeD: boolean, twoVariables = true): Data[] { const xs = t.path.map((p) => p.position[0]); const ys = twoVariables ? t.path.map((p) => p.position[1]) : t.path.map((p) => p.value); const fs = t.path.map((p) => p.value); const hovertemplate = twoVariables ? "x = %{x:.4g}, y = %{y:.4g}
f = %{customdata:.4g}%{fullData.name}" : "x = %{x:.4g}
f(x) = %{y:.4g}%{fullData.name}"; const last = t.path.length - 1; if (threeD) { return [ { x: xs, y: ys, z: fs, type: "scatter3d", mode: "lines+markers", name: t.label, legendgroup: t.label, line: { color: t.color, width: 5 }, marker: { color: t.color, size: 2.5 }, hoverinfo: "skip" }, { x: xs.slice(last), y: ys.slice(last), z: fs.slice(last), type: "scatter3d", mode: "markers", name: t.label, legendgroup: t.label, showlegend: false, marker: { color: t.color, size: 6, line: { color: "white", width: 1 } }, hoverinfo: "skip" }, ] as Data[]; } return [ { x: xs, y: ys, customdata: fs, type: "scatter", mode: "lines+markers", name: t.label, legendgroup: t.label, line: { color: t.color, width: 2 }, marker: { color: t.color, size: 7 }, hovertemplate }, { x: xs.slice(last), y: ys.slice(last), customdata: fs.slice(last), type: "scatter", mode: "markers", name: t.label, legendgroup: t.label, showlegend: false, marker: { color: t.color, size: 14, line: { color: "white", width: 2 } }, hovertemplate }, ] as Data[]; } // The gradient as arrows (pointing uphill), scaled so a typical arrow spans // most of the gap between grid points function arrowTraces({ arrows }: SurfaceValues, [x0, x1]: Range, [y0, y1]: Range): Data[] { // Arrows per side of the grid const side = Math.sqrt(arrows.x.length); // Lengths as fractions of the view, so x and y scale alike const lengths = arrows.u.map((u, i) => Math.hypot((u ?? NaN) / (x1 - x0), (arrows.v[i] ?? NaN) / (y1 - y0))); const finite = lengths.filter((l) => Number.isFinite(l) && l > 0).sort((a, b) => a - b); const typical = finite[Math.floor(0.9 * (finite.length - 1))] ?? 1; const shaftX: (number | null)[] = []; const shaftY: (number | null)[] = []; const headX: number[] = []; const headY: number[] = []; const angles: number[] = []; const hover: number[][] = []; arrows.x.forEach((x, i) => { const u = arrows.u[i]; const v = arrows.v[i]; if (u === null || v === null || !(lengths[i] > 0)) { return; } // At most 0.9 of a grid cell (1 / side of the view) const scale = (0.9 * Math.min(1, lengths[i] / typical)) / (lengths[i] * side); const dx = u * scale; const dy = v * scale; const y = arrows.y[i]; shaftX.push(x, x + dx, null); shaftY.push(y, y + dy, null); headX.push(x + dx); headY.push(y + dy); // Plotly turns markers clockwise from up, in screen space angles.push((Math.atan2(dx / (x1 - x0), dy / (y1 - y0)) * 180) / Math.PI); hover.push([x, y, u, v]); }); const color = "rgba(255, 255, 255, 0.85)"; return [ { x: shaftX, y: shaftY, type: "scatter", mode: "lines", line: { color, width: 1 }, hoverinfo: "skip", showlegend: false }, { x: headX, y: headY, customdata: hover, type: "scatter", mode: "markers", showlegend: false, marker: { symbol: "triangle-up", size: 6, color, angle: angles, angleref: "up" }, hovertemplate: "x = %{customdata[0]:.3g}, y = %{customdata[1]:.3g}
∇f = (%{customdata[2]:.3g}, %{customdata[3]:.3g})", }, ] as Data[]; }