221 lines
5.4 KiB
TypeScript
221 lines
5.4 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>;
|
|
|
|
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("");
|
|
|
|
// Render plot when axes 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 trace = {
|
|
type: "scatter3d" as const,
|
|
mode: "markers" as const,
|
|
x: xVals,
|
|
y: yVals,
|
|
z: zVals,
|
|
marker: {
|
|
size: 4,
|
|
color: zVals,
|
|
colorscale: "Viridis" as const,
|
|
},
|
|
};
|
|
|
|
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]);
|
|
|
|
|
|
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>
|
|
|
|
<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>
|
|
);
|
|
}
|