310 lines
8.2 KiB
TypeScript
310 lines
8.2 KiB
TypeScript
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>
|
||
);
|
||
}
|