Files
3dplot-gen/app/routes/home.tsx
T

310 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type React from "react";
import { useEffect, useRef, useState } from "react";
import * as papa from "papaparse";
import type { Route } from "./+types/home";
export function meta({ }: Route.MetaArgs) {
return [
{ title: "3dplot-gen" },
{ name: "description", content: "CSV → 3D Plot generator" },
];
}
type Row = Record<string, number>;
type PlotType = "scatter" | "surface";
const COLORSCALES = [
"Viridis",
"Plasma",
"Inferno",
"Magma",
"Cividis",
"Hot",
"Electric",
"Bluered",
"Portland",
"Rainbow",
"RdBu",
"Earth",
"Teal",
"Turbo",
] as const;
export default function Home() {
const plotRef = useRef<HTMLDivElement>(null);
const [rows, setRows] = useState<Row[]>([]);
const [columns, setColumns] = useState<string[]>([]);
const [x, setX] = useState("");
const [y, setY] = useState("");
const [z, setZ] = useState("");
const [plotType, setPlotType] = useState<PlotType>("scatter");
const [colorscale, setColorscale] = useState<string>(COLORSCALES[0]);
// Build surface grid from scattered (x,y,z) points. Returns null if data isn't a grid.
function buildSurfaceGrid(): {
xUnique: number[];
yUnique: number[];
zMatrix: number[][];
} | null {
const xVals = rows.map((r) => Number(r[x]));
const yVals = rows.map((r) => Number(r[y]));
const zVals = rows.map((r) => Number(r[z]));
const xUnique = [...new Set(xVals)].sort((a, b) => a - b);
const yUnique = [...new Set(yVals)].sort((a, b) => a - b);
const keyToZ = new Map<string, number>();
for (let i = 0; i < rows.length; i++) {
const k = `${xVals[i]},${yVals[i]}`;
if (!keyToZ.has(k)) keyToZ.set(k, zVals[i]);
}
if (xUnique.length * yUnique.length !== keyToZ.size) return null;
const zMatrix: number[][] = yUnique.map((yi) =>
xUnique.map((xj) => keyToZ.get(`${xj},${yi}`) ?? NaN)
);
return { xUnique, yUnique, zMatrix };
}
// Render plot when axes or options change
useEffect(() => {
if (!plotRef.current || !x || !y || !z) return;
let cancelled = false;
(async () => {
const Plotly = await import("plotly.js-dist-min");
if (cancelled) return;
const xVals = rows.map((r) => Number(r[x]));
const yVals = rows.map((r) => Number(r[y]));
const zVals = rows.map((r) => Number(r[z]));
const grid = buildSurfaceGrid();
const useSurface = plotType === "surface" && grid !== null;
const trace = useSurface && grid
? {
type: "surface" as const,
x: grid.xUnique,
y: grid.yUnique,
z: grid.zMatrix,
colorscale: colorscale as "Viridis",
colorbar: { title: { text: z } },
}
: {
type: "scatter3d" as const,
mode: "markers" as const,
x: xVals,
y: yVals,
z: zVals,
marker: {
size: 4,
color: zVals,
colorscale: colorscale as "Viridis",
},
};
Plotly.newPlot(
plotRef.current!,
[trace],
{
paper_bgcolor: "#0b0d12",
plot_bgcolor: "#0b0d12",
font: { color: "#cfd3ff" },
scene: {
xaxis: { title: { text: x } },
yaxis: { title: { text: y } },
zaxis: { title: { text: z } },
},
margin: { l: 0, r: 0, t: 0, b: 0 },
},
{ responsive: true }
);
})();
return () => {
cancelled = true;
};
}, [rows, x, y, z, plotType, colorscale]);
function onFile(file: File) {
papa.parse(file, {
header: true,
dynamicTyping: true,
skipEmptyLines: true,
complete: (res) => {
const data = (res.data as Row[]).filter(
(row) => row && Object.keys(row).length > 0
);
if (!data.length) {
setRows([]);
setColumns([]);
setX("");
setY("");
setZ("");
return;
}
const cols = Object.keys(data[0]);
setRows(data);
setColumns(cols);
if (cols.length >= 3) {
setX(cols[0]);
setY(cols[1]);
setZ(cols[2]);
} else {
setX("");
setY("");
setZ("");
}
},
});
}
async function exportPNG() {
const Plotly = await import("plotly.js-dist-min");
if (!plotRef.current) return;
Plotly.toImage(plotRef.current, {
format: "png",
width: 1600,
height: 900,
}).then((url) => {
const a = document.createElement("a");
a.href = url;
a.download = "3dplot-gen.png";
a.click();
});
}
return (
<div className="h-screen grid grid-cols-[280px_1fr]">
{/* Sidebar */}
<aside className="bg-panel p-4 space-y-4">
<div className="text-lg font-semibold">
3dplot-gen
<div className="text-xs text-muted">CSV → 3D Plot</div>
</div>
<Control label="CSV file">
<input
type="file"
accept=".csv"
onChange={(e) =>
e.target.files && onFile(e.target.files[0])
}
className="w-full text-sm file:bg-neutral-900 file:text-slate-300 file:border-0
file:rounded file:px-2 file:py-1"
/>
</Control>
<Control label="X axis">
<select
className="w-full bg-neutral-900 border border-slate-700 rounded px-2 py-1"
value={x}
onChange={(e) => setX(e.target.value)}
>
<option value="">—</option>
{columns.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</Control>
<Control label="Y axis">
<select
className="w-full bg-neutral-900 border border-slate-700 rounded px-2 py-1"
value={y}
onChange={(e) => setY(e.target.value)}
>
<option value="">—</option>
{columns.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</Control>
<Control label="Z axis">
<select
className="w-full bg-neutral-900 border border-slate-700 rounded px-2 py-1"
value={z}
onChange={(e) => setZ(e.target.value)}
>
<option value="">—</option>
{columns.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</Control>
<Control label="Plot type">
<select
className="w-full bg-neutral-900 border border-slate-700 rounded px-2 py-1"
value={plotType}
onChange={(e) => setPlotType(e.target.value as PlotType)}
>
<option value="scatter">Dots (scatter)</option>
<option value="surface">Surface (area)</option>
</select>
</Control>
<Control label="Colorscale">
<select
className="w-full bg-neutral-900 border border-slate-700 rounded px-2 py-1"
value={colorscale}
onChange={(e) => setColorscale(e.target.value)}
>
{COLORSCALES.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</Control>
{plotType === "surface" &&
rows.length > 0 &&
buildSurfaceGrid() === null && (
<p className="text-xs text-amber-500/90">
Surface needs a full X×Y grid of points. Showing dots instead.
</p>
)}
<button
onClick={exportPNG}
className="w-full mt-2 bg-accent text-black text-sm rounded px-2 py-1 hover:opacity-90"
>
Export PNG
</button>
</aside>
{/* Plot */}
<main className="bg-plot">
<div ref={plotRef} className="w-full h-full" />
</main>
</div>
);
}
function Control({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label className="text-xs text-muted">{label}</label>
{children}
</div>
);
}