@@ -0,0 +1,4 @@
|
|||||||
|
.react-router
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
README.md
|
||||||
+5
-137
@@ -1,139 +1,7 @@
|
|||||||
# Logs
|
.DS_Store
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
/node_modules/
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# React Router
|
||||||
.cache
|
/.react-router/
|
||||||
.parcel-cache
|
/build/
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# Sveltekit cache directory
|
|
||||||
.svelte-kit/
|
|
||||||
|
|
||||||
# vitepress build output
|
|
||||||
**/.vitepress/dist
|
|
||||||
|
|
||||||
# vitepress cache directory
|
|
||||||
**/.vitepress/cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# Firebase cache directory
|
|
||||||
.firebase/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v3
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/sdks
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# Vite logs files
|
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM node:20-alpine AS development-dependencies-env
|
||||||
|
COPY . /app
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-alpine AS production-dependencies-env
|
||||||
|
COPY ./package.json package-lock.json /app/
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build-env
|
||||||
|
COPY . /app/
|
||||||
|
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
COPY ./package.json package-lock.json /app/
|
||||||
|
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
COPY --from=build-env /app/build /app/build
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
@@ -1,2 +1,87 @@
|
|||||||
# 3dplot-gen
|
# Welcome to React Router!
|
||||||
A 3D plot generator online tool
|
|
||||||
|
A modern, production-ready template for building full-stack React applications using React Router.
|
||||||
|
|
||||||
|
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🚀 Server-side rendering
|
||||||
|
- ⚡️ Hot Module Replacement (HMR)
|
||||||
|
- 📦 Asset bundling and optimization
|
||||||
|
- 🔄 Data loading and mutations
|
||||||
|
- 🔒 TypeScript by default
|
||||||
|
- 🎉 TailwindCSS for styling
|
||||||
|
- 📖 [React Router docs](https://reactrouter.com/)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
Install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Start the development server with HMR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Your application will be available at `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Building for Production
|
||||||
|
|
||||||
|
Create a production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
To build and run using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t my-app .
|
||||||
|
|
||||||
|
# Run the container
|
||||||
|
docker run -p 3000:3000 my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
The containerized application can be deployed to any platform that supports Docker, including:
|
||||||
|
|
||||||
|
- AWS ECS
|
||||||
|
- Google Cloud Run
|
||||||
|
- Azure Container Apps
|
||||||
|
- Digital Ocean App Platform
|
||||||
|
- Fly.io
|
||||||
|
- Railway
|
||||||
|
|
||||||
|
### DIY Deployment
|
||||||
|
|
||||||
|
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||||
|
|
||||||
|
Make sure to deploy the output of `npm run build`
|
||||||
|
|
||||||
|
```
|
||||||
|
├── package.json
|
||||||
|
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||||
|
├── build/
|
||||||
|
│ ├── client/ # Static assets
|
||||||
|
│ └── server/ # Server-side code
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ❤️ using React Router.
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||||
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
@apply bg-neutral-900 text-slate-200;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
isRouteErrorResponse,
|
||||||
|
Links,
|
||||||
|
Meta,
|
||||||
|
Outlet,
|
||||||
|
Scripts,
|
||||||
|
ScrollRestoration,
|
||||||
|
} from "react-router";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/root";
|
||||||
|
import "./app.css";
|
||||||
|
|
||||||
|
export const links: Route.LinksFunction = () => [
|
||||||
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
|
{
|
||||||
|
rel: "preconnect",
|
||||||
|
href: "https://fonts.gstatic.com",
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<Meta />
|
||||||
|
<Links />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<ScrollRestoration />
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
let message = "Oops!";
|
||||||
|
let details = "An unexpected error occurred.";
|
||||||
|
let stack: string | undefined;
|
||||||
|
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
message = error.status === 404 ? "404" : "Error";
|
||||||
|
details =
|
||||||
|
error.status === 404
|
||||||
|
? "The requested page could not be found."
|
||||||
|
: error.statusText || details;
|
||||||
|
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||||
|
details = error.message;
|
||||||
|
stack = error.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="pt-16 p-4 container mx-auto">
|
||||||
|
<h1>{message}</h1>
|
||||||
|
<p>{details}</p>
|
||||||
|
{stack && (
|
||||||
|
<pre className="w-full p-4 overflow-x-auto">
|
||||||
|
<code>{stack}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { type RouteConfig, index } from "@react-router/dev/routes";
|
||||||
|
|
||||||
|
export default [index("routes/home.tsx")] satisfies RouteConfig;
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
type ThemeScheme = "light" | "dark" | "transparentBlack" | "transparentWhite";
|
||||||
|
|
||||||
|
const THEME_SCHEMES: Record<
|
||||||
|
ThemeScheme,
|
||||||
|
{
|
||||||
|
paperBg: string;
|
||||||
|
plotBg: string;
|
||||||
|
fontColor: string;
|
||||||
|
gridColor: string;
|
||||||
|
mainClass: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
dark: {
|
||||||
|
paperBg: "#0b0d12",
|
||||||
|
plotBg: "#0b0d12",
|
||||||
|
fontColor: "#cfd3ff",
|
||||||
|
gridColor: "rgba(148, 163, 184, 0.25)",
|
||||||
|
mainClass: "bg-neutral-950",
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
paperBg: "#f8fafc",
|
||||||
|
plotBg: "#f8fafc",
|
||||||
|
fontColor: "#1e293b",
|
||||||
|
gridColor: "rgba(0, 0, 0, 0.25)",
|
||||||
|
mainClass: "bg-slate-100",
|
||||||
|
},
|
||||||
|
transparentBlack: {
|
||||||
|
paperBg: "rgba(0,0,0,0)",
|
||||||
|
plotBg: "rgba(0,0,0,0)",
|
||||||
|
fontColor: "#475569",
|
||||||
|
gridColor: "rgba(0, 0, 0, 0.25)",
|
||||||
|
mainClass: "bg-transparent",
|
||||||
|
},
|
||||||
|
transparentWhite: {
|
||||||
|
paperBg: "rgba(0,0,0,0)",
|
||||||
|
plotBg: "rgba(0,0,0,0)",
|
||||||
|
fontColor: "#475569",
|
||||||
|
gridColor: "rgba(100, 116, 139, 0.4)",
|
||||||
|
mainClass: "bg-transparent",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
const [themeScheme, setThemeScheme] = useState<ThemeScheme>("dark");
|
||||||
|
|
||||||
|
// First row after header = axis sizes (X max, Y max, Z max); rest = plot data.
|
||||||
|
const plotData = rows.length > 1 ? rows.slice(1) : rows;
|
||||||
|
const axisSizes =
|
||||||
|
rows.length > 1 && x && y && z
|
||||||
|
? (() => {
|
||||||
|
const sx = Number(rows[0][x]);
|
||||||
|
const sy = Number(rows[0][y]);
|
||||||
|
const sz = Number(rows[0][z]);
|
||||||
|
return [sx, sy, sz].every((n) => Number.isFinite(n))
|
||||||
|
? { x: sx, y: sy, z: sz }
|
||||||
|
: null;
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 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 = plotData.map((r) => Number(r[x]));
|
||||||
|
const yVals = plotData.map((r) => Number(r[y]));
|
||||||
|
const zVals = plotData.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 < plotData.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 = plotData.map((r) => Number(r[x]));
|
||||||
|
const yVals = plotData.map((r) => Number(r[y]));
|
||||||
|
const zVals = plotData.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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const theme = THEME_SCHEMES[themeScheme];
|
||||||
|
const sceneLayout: {
|
||||||
|
bgcolor?: string;
|
||||||
|
xaxis: {
|
||||||
|
title: { text: string };
|
||||||
|
range?: [number, number];
|
||||||
|
gridcolor?: string;
|
||||||
|
tickfont?: { color: string };
|
||||||
|
};
|
||||||
|
yaxis: {
|
||||||
|
title: { text: string };
|
||||||
|
range?: [number, number];
|
||||||
|
gridcolor?: string;
|
||||||
|
tickfont?: { color: string };
|
||||||
|
};
|
||||||
|
zaxis: {
|
||||||
|
title: { text: string };
|
||||||
|
range?: [number, number];
|
||||||
|
gridcolor?: string;
|
||||||
|
tickfont?: { color: string };
|
||||||
|
};
|
||||||
|
} = {
|
||||||
|
bgcolor: theme.plotBg,
|
||||||
|
xaxis: {
|
||||||
|
title: { text: x },
|
||||||
|
gridcolor: theme.gridColor,
|
||||||
|
tickfont: { color: theme.fontColor },
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: { text: y },
|
||||||
|
gridcolor: theme.gridColor,
|
||||||
|
tickfont: { color: theme.fontColor },
|
||||||
|
},
|
||||||
|
zaxis: {
|
||||||
|
title: { text: z },
|
||||||
|
gridcolor: theme.gridColor,
|
||||||
|
tickfont: { color: theme.fontColor },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (axisSizes) {
|
||||||
|
sceneLayout.xaxis.range = [0, axisSizes.x];
|
||||||
|
sceneLayout.yaxis.range = [0, axisSizes.y];
|
||||||
|
sceneLayout.zaxis.range = [0, axisSizes.z];
|
||||||
|
}
|
||||||
|
|
||||||
|
Plotly.newPlot(
|
||||||
|
plotRef.current!,
|
||||||
|
[trace],
|
||||||
|
{
|
||||||
|
paper_bgcolor: theme.paperBg,
|
||||||
|
plot_bgcolor: theme.plotBg,
|
||||||
|
font: { color: theme.fontColor },
|
||||||
|
scene: sceneLayout,
|
||||||
|
margin: { l: 0, r: 0, t: 0, b: 0 },
|
||||||
|
},
|
||||||
|
{ responsive: true }
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [rows, x, y, z, plotType, colorscale, themeScheme]);
|
||||||
|
|
||||||
|
|
||||||
|
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] overflow-y-hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="bg-panel p-4 space-y-4 overflow-y-scroll">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-lg font-semibold">
|
||||||
|
3D plot generator <a href="https://github.com/selimaj-dev" className="text-sm text-neutral-400 block w-max">by Klesti Selimaj</a>
|
||||||
|
<div className="text-xs text-neutral-500">CSV to 3D Plot</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="https://github.com/selimaj-dev/3dplot-gen" className="text-neutral-400">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" className="lucide lucide-github-icon lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
|
||||||
|
</a>
|
||||||
|
</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>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
First data row = axis sizes (X max, Y max, Z max). Plot uses rows after that.{" "}
|
||||||
|
<a href="/example-surface.csv" className="text-accent underline" download>
|
||||||
|
Example (surface)
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Control label="Background">
|
||||||
|
<select
|
||||||
|
className="w-full bg-neutral-900 border border-slate-700 rounded px-2 py-1"
|
||||||
|
value={themeScheme}
|
||||||
|
onChange={(e) => setThemeScheme(e.target.value as ThemeScheme)}
|
||||||
|
>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="transparentBlack">Transparent Black Grid</option>
|
||||||
|
<option value="transparentWhite">Transparent White Grid</option>
|
||||||
|
</select>
|
||||||
|
</Control>
|
||||||
|
|
||||||
|
{plotType === "surface" &&
|
||||||
|
plotData.length > 0 &&
|
||||||
|
buildSurfaceGrid() === null && (
|
||||||
|
<p className="text-xs text-amber-500/90">
|
||||||
|
Surface needs a full X and Y grid of points. Showing dots instead.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={exportPNG}
|
||||||
|
className="w-full mt-2 bg-accent text-sm rounded px-2 py-1 hover:opacity-90"
|
||||||
|
>
|
||||||
|
Export PNG
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Plot */}
|
||||||
|
<main className={THEME_SCHEMES[themeScheme].mainClass}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+4078
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "3dplot-gen",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "react-router build",
|
||||||
|
"dev": "react-router dev",
|
||||||
|
"start": "react-router-serve ./build/server/index.js",
|
||||||
|
"typecheck": "react-router typegen && tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-router/node": "7.12.0",
|
||||||
|
"@react-router/serve": "7.12.0",
|
||||||
|
"@types/papaparse": "^5.5.2",
|
||||||
|
"@types/plotly.js-dist-min": "^2.3.4",
|
||||||
|
"isbot": "^5.1.31",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
|
"plotly.js-dist-min": "^3.3.1",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router": "7.12.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@react-router/dev": "7.12.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
|
"@types/node": "^22.19.7",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"tailwindcss": "^4.1.13",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
X,Y,Z
|
||||||
|
2,2,6
|
||||||
|
0,0,1.000
|
||||||
|
0.25,0,1.109
|
||||||
|
0.5,0,1.206
|
||||||
|
0.75,0,1.291
|
||||||
|
1,0,1.365
|
||||||
|
1.25,0,1.428
|
||||||
|
1.5,0,1.480
|
||||||
|
1.75,0,1.521
|
||||||
|
2,0,1.550
|
||||||
|
0,0.25,1.175
|
||||||
|
0.25,0.25,1.304
|
||||||
|
0.5,0.25,1.421
|
||||||
|
0.75,0.25,1.526
|
||||||
|
1,0.25,1.620
|
||||||
|
1.25,0.25,1.703
|
||||||
|
1.5,0.25,1.774
|
||||||
|
1.75,0.25,1.834
|
||||||
|
2,0.25,1.883
|
||||||
|
0,0.5,1.331
|
||||||
|
0.25,0.5,1.479
|
||||||
|
0.5,0.5,1.615
|
||||||
|
0.75,0.5,1.739
|
||||||
|
1,0.5,1.851
|
||||||
|
1.25,0.5,1.952
|
||||||
|
1.5,0.5,2.041
|
||||||
|
1.75,0.5,2.119
|
||||||
|
2,0.5,2.185
|
||||||
|
0,0.75,1.469
|
||||||
|
0.25,0.75,1.634
|
||||||
|
0.5,0.75,1.787
|
||||||
|
0.75,0.75,1.928
|
||||||
|
1,0.75,2.057
|
||||||
|
1.25,0.75,2.174
|
||||||
|
1.5,0.75,2.279
|
||||||
|
1.75,0.75,2.372
|
||||||
|
2,0.75,2.454
|
||||||
|
0,1,1.590
|
||||||
|
0.25,1,1.772
|
||||||
|
0.5,1,1.942
|
||||||
|
0.75,1,2.099
|
||||||
|
1,1,2.245
|
||||||
|
1.25,1,2.378
|
||||||
|
1.5,1,2.500
|
||||||
|
1.75,1,2.610
|
||||||
|
2,1,2.708
|
||||||
|
0,1.25,1.694
|
||||||
|
0.25,1.25,1.892
|
||||||
|
0.5,1.25,2.078
|
||||||
|
0.75,1.25,2.251
|
||||||
|
1,1.25,2.413
|
||||||
|
1.25,1.25,2.562
|
||||||
|
1.5,1.25,2.700
|
||||||
|
1.75,1.25,2.825
|
||||||
|
2,1.25,2.939
|
||||||
|
0,1.5,1.781
|
||||||
|
0.25,1.5,1.994
|
||||||
|
0.5,1.5,2.196
|
||||||
|
0.75,1.5,2.384
|
||||||
|
1,1.5,2.561
|
||||||
|
1.25,1.5,2.725
|
||||||
|
1.5,1.5,2.878
|
||||||
|
1.75,1.5,3.018
|
||||||
|
2,1.5,3.146
|
||||||
|
0,1.75,1.851
|
||||||
|
0.25,1.75,2.079
|
||||||
|
0.5,1.75,2.295
|
||||||
|
0.75,1.75,2.499
|
||||||
|
1,1.75,2.692
|
||||||
|
1.25,1.75,2.872
|
||||||
|
1.5,1.75,3.041
|
||||||
|
1.75,1.75,3.197
|
||||||
|
2,1.75,3.341
|
||||||
|
0,2,1.905
|
||||||
|
0.25,2,2.146
|
||||||
|
0.5,2,2.376
|
||||||
|
0.75,2,2.594
|
||||||
|
1,2,2.801
|
||||||
|
1.25,2,2.995
|
||||||
|
1.5,2,3.178
|
||||||
|
1.75,2,3.349
|
||||||
|
2,2,3.508
|
||||||
|
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Config } from "@react-router/dev/config";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Config options...
|
||||||
|
// Server-side render by default, to enable SPA mode set this to `false`
|
||||||
|
ssr: true,
|
||||||
|
} satisfies Config;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"**/*",
|
||||||
|
"**/.server/**/*",
|
||||||
|
"**/.client/**/*",
|
||||||
|
".react-router/types/**/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"types": ["node", "vite/client"],
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"rootDirs": [".", "./.react-router/types"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./app/*"]
|
||||||
|
},
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { reactRouter } from "@react-router/dev/vite";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user