crosslang/octive-lean/widget/js/interactivePlot.js
Maximus Gorog 6592cd058d Add 'octive-lean/' from commit '4b6fcec565a170d7029d4ccba21be2ecd0512d13'
git-subtree-dir: octive-lean
git-subtree-mainline: fd3d42ae33
git-subtree-split: 4b6fcec565
2026-05-12 02:59:14 -06:00

303 lines
15 KiB
JavaScript

window;
import { jsx as h } from "react/jsx-runtime";
import { useState, useRef, useCallback, useEffect } from "react";
const W = 500, H = 370;
const PL = 58, PR = 20, PT = 40, PB = 48;
const PW = W - PL - PR, PHt = H - PT - PB;
function niceTicks(lo, hi, n = 5) {
if (!isFinite(lo) || !isFinite(hi) || lo >= hi) return [lo || 0];
const raw = (hi - lo) / n;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const step = norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10;
const s = step * mag;
const ticks = [];
for (let t = Math.ceil(lo / s) * s; t <= hi + s * 0.01; t += s)
ticks.push(+t.toPrecision(10));
return ticks.length ? ticks : [lo];
}
function fmt(v) {
if (!isFinite(v)) return String(v);
const a = Math.abs(v);
if (a >= 1e5 || (a > 0 && a < 0.001)) return v.toExponential(3);
return +v.toPrecision(5) + "";
}
function dataRange(series) {
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
for (const s of series) {
for (const x of s.xData) { if (x < x0) x0 = x; if (x > x1) x1 = x; }
for (const y of s.yData) { if (y < y0) y0 = y; if (y > y1) y1 = y; }
}
if (!isFinite(x0)) { x0 = 0; x1 = 1; }
if (!isFinite(y0)) { y0 = 0; y1 = 1; }
if (x0 === x1) { x0 -= 0.5; x1 += 0.5; }
if (y0 === y1) { y0 -= 0.5; y1 += 0.5; }
const xp = (x1 - x0) * 0.05, yp = (y1 - y0) * 0.05;
return { x0: x0 - xp, x1: x1 + xp, y0: y0 - yp, y1: y1 + yp };
}
function Figure2D({ fig }) {
const [view, setView] = useState(() => dataRange(fig.series));
const [tip, setTip] = useState(null);
const svgRef = useRef(null);
const drag = useRef(null);
const clipId = useRef("clip-" + Math.random().toString(36).slice(2)).current;
const sx = (x) => PL + (x - view.x0) / (view.x1 - view.x0) * PW;
const sy = (y) => PT + (1 - (y - view.y0) / (view.y1 - view.y0)) * PHt;
const ux = (px) => view.x0 + (px - PL) / PW * (view.x1 - view.x0);
const uy = (py) => view.y0 + (1 - (py - PT) / PHt) * (view.y1 - view.y0);
useEffect(() => {
const el = svgRef.current;
if (!el) return;
const onWheel = (e) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const cx = ux(e.clientX - rect.left);
const cy = uy(e.clientY - rect.top);
const f = e.deltaY > 0 ? 1.2 : 1 / 1.2;
setView(v => ({
x0: cx + (v.x0 - cx) * f, x1: cx + (v.x1 - cx) * f,
y0: cy + (v.y0 - cy) * f, y1: cy + (v.y1 - cy) * f,
}));
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [view]);
const onDown = useCallback((e) => {
if (e.button !== 0) return;
drag.current = { x: e.clientX, y: e.clientY, v: { ...view } };
e.preventDefault();
}, [view]);
const onMove = useCallback((e) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
const px = e.clientX - rect.left, py = e.clientY - rect.top;
if (drag.current) {
const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y;
const xs = (drag.current.v.x1 - drag.current.v.x0) / PW;
const ys = (drag.current.v.y1 - drag.current.v.y0) / PHt;
setView({
x0: drag.current.v.x0 - dx * xs, x1: drag.current.v.x1 - dx * xs,
y0: drag.current.v.y0 + dy * ys, y1: drag.current.v.y1 + dy * ys,
});
}
if (px < PL || px > W - PR || py < PT || py > H - PB) { setTip(null); return; }
let best = null, bestD = 225;
for (const s of fig.series) {
for (let i = 0; i < s.xData.length; i++) {
const dx = sx(s.xData[i]) - px, dy = sy(s.yData[i]) - py;
const d2 = dx * dx + dy * dy;
if (d2 < bestD) { bestD = d2; best = { x: s.xData[i], y: s.yData[i], px, py }; }
}
}
setTip(best);
}, [view, fig]);
const onUp = () => { drag.current = null; };
const onLeave = () => { drag.current = null; setTip(null); };
const xTicks = niceTicks(view.x0, view.x1);
const yTicks = niceTicks(view.y0, view.y1);
const clip = `url(#${clipId})`;
const seriesElems = fig.series.flatMap((s, si) => {
const c = s.color || "#1f77b4";
if (s.markType === "line" || s.markType === "histogram") {
const pts = s.xData.map((x, i) => `${sx(x)},${sy(s.yData[i])}`).join(" ");
return [h("polyline", { key: si, points: pts, fill: "none", stroke: c, strokeWidth: 2, clipPath: clip, strokeLinejoin: "round" })];
}
if (s.markType === "scatter") {
return s.xData.map((x, i) =>
h("circle", { key: `${si}-${i}`, cx: sx(x), cy: sy(s.yData[i]), r: 4, fill: c, clipPath: clip })
);
}
if (s.markType === "bar") {
const bw = Math.max(2, PW / (s.xData.length * 1.3));
const z0 = Math.min(H - PB, Math.max(PT, sy(0)));
return s.xData.map((x, i) => {
const pyi = sy(s.yData[i]);
return h("rect", { key: `${si}-${i}`, x: sx(x) - bw / 2, y: Math.min(pyi, z0), width: bw, height: Math.abs(z0 - pyi), fill: c, clipPath: clip });
});
}
if (s.markType === "stem") {
const z0 = Math.min(H - PB, Math.max(PT, sy(0)));
return s.xData.flatMap((x, i) => {
const pxi = sx(x), pyi = sy(s.yData[i]);
return [
h("line", { key: `${si}l${i}`, x1: pxi, y1: z0, x2: pxi, y2: pyi, stroke: c, strokeWidth: 1.5, clipPath: clip }),
h("circle", { key: `${si}c${i}`, cx: pxi, cy: pyi, r: 4, fill: c, clipPath: clip }),
];
});
}
return [];
});
const labeled = fig.series.filter(s => s.label);
const legendElems = labeled.length === 0 ? [] : (() => {
const lh = 18, bw = 130, bh = lh * labeled.length + 10;
const bx = W - PR - bw - 4, by = PT + 6;
return [
h("rect", { key: "lb", x: bx, y: by, width: bw, height: bh, fill: "rgba(255,255,255,0.92)", stroke: "#ccc" }),
...labeled.flatMap((s, i) => [
h("rect", { key: `li${i}`, x: bx + 6, y: by + 10 + i * lh - 7, width: 16, height: 10, fill: s.color }),
h("text", { key: `lt${i}`, x: bx + 26, y: by + 10 + i * lh, fontSize: 11, fill: "#333" }, s.label),
]),
];
})();
return h("div", { style: { display: "inline-block", position: "relative", userSelect: "none" } },
h("svg", { ref: svgRef, width: W, height: H, style: { cursor: "crosshair", background: "#fff", display: "block" }, onMouseDown: onDown, onMouseMove: onMove, onMouseUp: onUp, onMouseLeave: onLeave },
h("defs", {}, h("clipPath", { id: clipId }, h("rect", { x: PL, y: PT, width: PW, height: PHt }))),
h("rect", { x: PL, y: PT, width: PW, height: PHt, fill: "#fff", stroke: "#ccc" }),
...xTicks.map(t => h("line", { key: `xg${t}`, x1: sx(t), y1: PT, x2: sx(t), y2: H - PB, stroke: "#e5e5e5" })),
...yTicks.map(t => h("line", { key: `yg${t}`, x1: PL, y1: sy(t), x2: W - PR, y2: sy(t), stroke: "#e5e5e5" })),
h("line", { x1: PL, y1: H - PB, x2: W - PR, y2: H - PB, stroke: "#333", strokeWidth: 1.5 }),
h("line", { x1: PL, y1: PT, x2: PL, y2: H - PB, stroke: "#333", strokeWidth: 1.5 }),
...xTicks.flatMap(t => [
h("line", { key: `xt${t}`, x1: sx(t), y1: H - PB, x2: sx(t), y2: H - PB + 5, stroke: "#333" }),
h("text", { key: `xl${t}`, x: sx(t), y: H - PB + 17, textAnchor: "middle", fontSize: 11, fill: "#333" }, fmt(t)),
]),
...yTicks.flatMap(t => [
h("line", { key: `yt${t}`, x1: PL - 5, y1: sy(t), x2: PL, y2: sy(t), stroke: "#333" }),
h("text", { key: `yl${t}`, x: PL - 8, y: sy(t) + 4, textAnchor: "end", fontSize: 11, fill: "#333" }, fmt(t)),
]),
fig.title && h("text", { x: W / 2, y: 22, textAnchor: "middle", fontSize: 14, fontWeight: "bold", fill: "#111" }, fig.title),
fig.xlabel && h("text", { x: W / 2, y: H - 6, textAnchor: "middle", fontSize: 12, fill: "#333" }, fig.xlabel),
fig.ylabel && h("text", { x: 14, y: PT + PHt / 2, textAnchor: "middle", fontSize: 12, fill: "#333", transform: `rotate(-90,14,${PT + PHt / 2})` }, fig.ylabel),
...seriesElems,
...legendElems,
tip && h("g", { key: "xh" },
h("line", { x1: PL, y1: sy(tip.y), x2: W - PR, y2: sy(tip.y), stroke: "#666", strokeWidth: 0.5, strokeDasharray: "3,3" }),
h("line", { x1: sx(tip.x), y1: PT, x2: sx(tip.x), y2: H - PB, stroke: "#666", strokeWidth: 0.5, strokeDasharray: "3,3" }),
),
),
tip && h("div", { key: "tt", style: { position: "absolute", left: tip.px + 12, top: tip.py - 28, background: "rgba(0,0,0,0.75)", color: "#fff", padding: "3px 7px", borderRadius: 4, fontSize: 12, pointerEvents: "none", whiteSpace: "nowrap" } },
`(${fmt(tip.x)}, ${fmt(tip.y)})`
),
h("button", { key: "rst", onClick: () => setView(dataRange(fig.series)), style: { position: "absolute", top: 4, right: 4, fontSize: 11, padding: "2px 6px", cursor: "pointer", background: "#f0f0f0", border: "1px solid #ccc", borderRadius: 3 } }, "⟳"),
);
}
function proj3(x, y, z, az, el, x0, x1, y0, y1, z0, z1) {
const nx = x1 > x0 ? (x - x0) / (x1 - x0) * 2 - 1 : 0;
const ny = y1 > y0 ? (y - y0) / (y1 - y0) * 2 - 1 : 0;
const nz = z1 > z0 ? (z - z0) / (z1 - z0) * 2 - 1 : 0;
const azR = az * Math.PI / 180, elR = el * Math.PI / 180;
const cAz = Math.cos(azR), sAz = Math.sin(azR);
const cEl = Math.cos(elR), sEl = Math.sin(elR);
const px = nx * cAz - ny * sAz;
const py2 = nx * sAz * sEl + ny * cAz * sEl + nz * cEl;
const sc = Math.min(PW, PHt) * 0.42;
return [W / 2 + px * sc, H / 2 - py2 * sc];
}
function bounds3(series) {
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, z0 = Infinity, z1 = -Infinity;
for (const s of series) {
for (const x of s.xData) { if (x < x0) x0 = x; if (x > x1) x1 = x; }
for (const y of s.yData) { if (y < y0) y0 = y; if (y > y1) y1 = y; }
for (const z of (s.zData || [])) { if (z < z0) z0 = z; if (z > z1) z1 = z; }
}
if (!isFinite(x0)) { x0 = 0; x1 = 1; } if (x0 === x1) { x0 -= 0.5; x1 += 0.5; }
if (!isFinite(y0)) { y0 = 0; y1 = 1; } if (y0 === y1) { y0 -= 0.5; y1 += 0.5; }
if (!isFinite(z0)) { z0 = 0; z1 = 1; } if (z0 === z1) { z0 -= 0.5; z1 += 0.5; }
return [x0, x1, y0, y1, z0, z1];
}
function Figure3D({ fig }) {
const [rot, setRot] = useState({ az: 30, el: 20 });
const drag = useRef(null);
const [bx0, bx1, by0, by1, bz0, bz1] = bounds3(fig.series);
const p = (x, y, z) => proj3(x, y, z, rot.az, rot.el, bx0, bx1, by0, by1, bz0, bz1);
const onDown = (e) => { drag.current = { x: e.clientX, y: e.clientY, rot: { ...rot } }; e.preventDefault(); };
const onMove = (e) => {
if (!drag.current) return;
const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y;
setRot({ az: drag.current.rot.az - dx * 0.5, el: Math.max(-89, Math.min(89, drag.current.rot.el + dy * 0.3)) });
};
const onUp = () => { drag.current = null; };
const seriesElems = fig.series.flatMap((s, si) => {
const c = s.color || "#1f77b4";
if (s.markType === "scatter3") {
const n = Math.min(s.xData.length, s.yData.length, (s.zData || []).length);
return Array.from({ length: n }, (_, i) => {
const [px, py] = p(s.xData[i], s.yData[i], s.zData[i]);
return h("circle", { key: `${si}-${i}`, cx: px, cy: py, r: 3.5, fill: c });
});
}
if (s.markType === "line3") {
const n = Math.min(s.xData.length, s.yData.length, (s.zData || []).length);
const pts = Array.from({ length: n }, (_, i) => p(s.xData[i], s.yData[i], s.zData[i])).map(([px, py]) => `${px},${py}`).join(" ");
return [h("polyline", { key: si, points: pts, fill: "none", stroke: c, strokeWidth: 1.5, strokeLinejoin: "round" })];
}
if (s.markType === "surface") {
const rows = s.gridRows, cols = s.gridCols;
if (rows < 2 || cols < 2 || !s.zData) return [];
const zArr = s.zData;
const zMin = Math.min(...zArr), zMax = Math.max(...zArr), zRng = zMax === zMin ? 1 : zMax - zMin;
return Array.from({ length: rows - 1 }, (_, i) =>
Array.from({ length: cols - 1 }, (_, j) => {
const g = (r, c) => [s.xData[r * cols + c] ?? 0, s.yData[r * cols + c] ?? 0, zArr[r * cols + c] ?? 0];
const pts = [[i,j],[i,j+1],[i+1,j+1],[i+1,j]].map(([r,c]) => p(...g(r,c))).map(([x,y]) => `${x},${y}`).join(" ");
const avgZ = (zArr[i*cols+j] + zArr[i*cols+j+1] + zArr[(i+1)*cols+j] + zArr[(i+1)*cols+j+1]) / 4;
const t = (avgZ - zMin) / zRng;
const rv = Math.round(255 * t), bv = Math.round(255 * (1 - t));
return h("polygon", { key: `${i}-${j}`, points: pts, fill: `rgb(${rv},80,${bv})`, stroke: "rgba(0,0,0,0.1)", strokeWidth: 0.5, fillOpacity: 0.85 });
})
).flat();
}
if (s.markType === "waterfall") {
const rows = s.gridRows, cols = s.gridCols;
if (rows < 2 || cols < 2) return [];
return Array.from({ length: rows }, (_, i) => {
const pts = Array.from({ length: cols }, (_, j) => p(s.xData[i*cols+j]??0, s.yData[i*cols+j]??0, (s.zData??[])[i*cols+j]??0)).map(([x,y]) => `${x},${y}`).join(" ");
return h("polyline", { key: i, points: pts, fill: "none", stroke: c, strokeWidth: 1.5 });
});
}
if (s.markType === "contour") {
const rows = s.gridRows, cols = s.gridCols;
if (rows < 2 || cols < 2 || !s.zData) return [];
const zArr = s.zData, zMin = Math.min(...zArr), zMax = Math.max(...zArr), zRng = zMax === zMin ? 1 : zMax - zMin;
const cw = PW / cols, ch = PHt / rows;
return Array.from({ length: rows }, (_, i) =>
Array.from({ length: cols }, (_, j) => {
const t = (zArr[i*cols+j] - zMin) / zRng;
const rv = Math.round(220 * t + 20), bv = Math.round(220 * (1 - t) + 20);
return h("rect", { key: `${i}-${j}`, x: PL + j * cw, y: PT + (rows-1-i) * ch, width: cw + 1, height: ch + 1, fill: `rgb(${rv},60,${bv})` });
})
).flat();
}
return [];
});
return h("div", { style: { display: "inline-block", position: "relative", userSelect: "none" } },
h("svg", { width: W, height: H, style: { cursor: drag.current ? "grabbing" : "grab", background: "#f8f8f8", display: "block" }, onMouseDown: onDown, onMouseMove: onMove, onMouseUp: onUp, onMouseLeave: onUp },
h("rect", { x: PL, y: PT, width: PW, height: PHt, fill: "#f0f0f0", stroke: "#ccc" }),
...seriesElems,
fig.title && h("text", { x: W / 2, y: 22, textAnchor: "middle", fontSize: 14, fontWeight: "bold", fill: "#111" }, fig.title),
),
h("div", { style: { textAlign: "center", fontSize: 11, color: "#888", marginTop: 2 } }, "drag to rotate"),
h("button", { onClick: () => setRot({ az: 30, el: 20 }), style: { display: "block", margin: "2px auto", fontSize: 11, padding: "2px 6px", cursor: "pointer", background: "#f0f0f0", border: "1px solid #ccc", borderRadius: 3 } }, "⟳"),
);
}
function InteractivePlot({ figures }) {
if (!figures || figures.length === 0) return null;
return h("div", { style: { display: "flex", flexWrap: "wrap", gap: "8px", padding: "4px" } },
figures.map((fig, i) => h(fig.is3D ? Figure3D : Figure2D, { key: i, fig }))
);
}
export default InteractivePlot;