diff --git a/apps/frontend/app/components/flows/ViewExperiment/Chart.tsx b/apps/frontend/app/components/flows/ViewExperiment/Chart.tsx new file mode 100644 index 00000000..a42acea1 --- /dev/null +++ b/apps/frontend/app/components/flows/ViewExperiment/Chart.tsx @@ -0,0 +1,227 @@ +import React, { useState, useEffect } from 'react'; +import { Chart, registerables, ChartTypeRegistry } from 'chart.js'; +import { BoxPlotController, BoxAndWiskers, ViolinController, Violin } from '@sgratzl/chartjs-chart-boxplot'; +import 'tailwindcss/tailwind.css'; +import { ExperimentData } from '../../../../lib/db_types'; +import GraphModal from './ChartModal'; + +Chart.register(...registerables); +Chart.register(BoxPlotController, BoxAndWiskers, ViolinController, Violin); + +interface ChartModalProps { + onClose: () => void; + project: ExperimentData; +} + +const ChartModal: React.FC = ({ onClose, project }) => { + const [chartInstance, setChartInstance] = useState(null); + const [canvasKey, setCanvasKey] = useState(0); + const [chartType, setChartType] = useState('line'); + const [experimentChartData, setExperimentChartData] = useState({ _id: '', experimentId: '', resultContent: '' }); + const [loading, setLoading] = useState(true); + const [xAxis, setXAxis] = useState('X'); + //const [yAxis, setYAxis] = useState('Y'); + const [headers, setHeaders] = useState([]); + const [img, setImg] = useState(''); + const [isFullscreen, setIsFullscreen] = useState(false); + + const toggleFullscreen = () => { + setIsFullscreen(!isFullscreen); + }; + + const setLineChart = () => setChartType('line'); + const setBarChart = () => setChartType('bar'); + const setPieChart = () => setChartType('pie'); + const setBoxPlot = () => setChartType('boxplot'); + const setViolin = () => setChartType('violin'); + + useEffect(() => { + fetch(`/api/download/csv/${project.expId}`).then((response) => response.json()).then((record) => { + setExperimentChartData(record); + setLoading(false); + }).catch((response) => { + console.warn('Error getting experiment results', response.status); + response.json().then((json: any) => { + console.warn(json?.response ?? json); + const message = json?.response; + if (message) { + alert(`Error getting experiment results: ${message}`); + } + }); + } + ); + }, [project.expId]); + + const downloadImage = () => { + const a = document.createElement('a'); + a.href = img; + a.download = `${project.name}.png`; + a.click(); + }; + + const parseCSV = (data) => { + const rows = data.trim().split('\n'); + const headers = rows[0].split(','); + + const xList = [] as any[]; + const yLists = [] as any[]; + + var xIndex = 0; + for (let i = 0; i < headers.length; i++) { + yLists.push([]); + if (headers[i] === xAxis) { + xIndex = i; + } + } + + yLists.splice(xIndex, 1); + + for (let i = 1; i < rows.length; i++) { + const cols = rows[i].split(','); + xList.push(cols[xIndex]); + for (let j = 0; j < cols.length; j++) { + if (j < xIndex) { + yLists[j].push(cols[j]); + } + else if (j > xIndex) { + yLists[j - 1].push(cols[j]); + } + } + } + + return { headers, xList, yLists, xIndex }; + }; + + const generateColors = (numColors) => { + const colors = [] as string[]; + for (let i = 0; i < numColors; i++) { + const color = `rgba(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, 0.2)`; + colors.push(color); + } + return colors; + }; + + useEffect(() => { + if (!loading && experimentChartData.resultContent) { + const { headers, xList, yLists, xIndex } = parseCSV(experimentChartData.resultContent); + const colors = generateColors(xList.length); + const ctx = document.getElementById('myChart') as HTMLCanvasElement; + if (chartInstance) { + chartInstance.destroy(); + } + + const totalLength = headers.length; + const newHeaders = [] as any[]; + for (let i = 0; i < totalLength; i++) { + if (i != xIndex) { + newHeaders.push(headers[i]); + } + } + + const datasetsObj = newHeaders.map((header, i) => ({ + label: header, + data: yLists[i], + borderColor: colors, + backgroundColor: colors + })); + + + const newChartInstance = new Chart(ctx, { + type: chartType, + data: { + labels: xList, + datasets: datasetsObj + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + display: true, + title: { + display: true, + text: 'X Axis' + } + }, + y: { + display: true, + title: { + display: true, + text: 'Y Axis' + } + } + }, + animation: { + onComplete: function () { + setImg(newChartInstance.toBase64Image()); + } + } + } + }); + setChartInstance(newChartInstance); + + setHeaders(headers); + } + }, [loading, experimentChartData, chartType, xAxis, isFullscreen]); + + const regenerateCanvas = () => { + setCanvasKey(prevKey => prevKey + 1); + }; + + + return ( + { onClose(); regenerateCanvas(); }} fullScreen={isFullscreen} toggleFullscreen={toggleFullscreen}> +
+ + + + {/* + */} +
+
+

{project.name}'s Chart

+ {loading ? ( +

Loading data...

+ ) : ( +
+ +
+ )} +
+
+

X-Axis Column:

+
+ {headers.map((header) => ( +
+ setXAxis(header)} + name="xaxis" + value={header} + + /> + +
+ ))} +
+
+ +
+ ); +}; + +export default ChartModal; \ No newline at end of file diff --git a/apps/frontend/app/components/flows/ViewExperiment/ChartModal.tsx b/apps/frontend/app/components/flows/ViewExperiment/ChartModal.tsx new file mode 100644 index 00000000..4d6fb286 --- /dev/null +++ b/apps/frontend/app/components/flows/ViewExperiment/ChartModal.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { ArrowsPointingOutIcon, ArrowsPointingInIcon, XMarkIcon } from '@heroicons/react/24/outline' + +interface ModalProps { + onClose?: () => void; + fullScreen?: boolean; + toggleFullscreen?: () => void; + children: React.ReactNode; +} + +const GraphModal: React.FC = ({ onClose, fullScreen, toggleFullscreen, children }) => { + return ReactDOM.createPortal( +
+
+
+ + +
+
+ {children} +
+
+
, + document.body + ); +}; + +export default GraphModal; \ No newline at end of file diff --git a/apps/frontend/app/components/flows/ViewExperiment/ExperimentListing.tsx b/apps/frontend/app/components/flows/ViewExperiment/ExperimentListing.tsx index 968f49c2..0d7eaf07 100644 --- a/apps/frontend/app/components/flows/ViewExperiment/ExperimentListing.tsx +++ b/apps/frontend/app/components/flows/ViewExperiment/ExperimentListing.tsx @@ -2,8 +2,8 @@ import { ChevronRightIcon } from '@heroicons/react/24/solid'; import { useEffect, useState } from 'react'; import { ExperimentData } from '../../../../lib/db_types'; -import { MdEdit, MdPadding } from 'react-icons/md'; -import { Timestamp } from 'mongodb'; +import { MdEdit } from 'react-icons/md'; +import Chart from './Chart'; import { addShareLink, unfollowExperiment, updateExperimentNameById } from '../../../../lib/mongodb_funcs'; import toast from 'react-hot-toast'; import { useSession } from 'next-auth/react'; @@ -41,6 +41,7 @@ export const ExperimentListing = ({ projectData: projectData, onCopyExperiment, const [originalProjectName, setOriginalProjectName] = useState(projectData.name); // State to store the original project name const [isDeleteModalOpen, setDeleteModalOpen] = useState(false); + const [showGraphModal, setShowGraphModal] = useState(false); const handleEdit = () => { // Enable editing and set the edited project name to the current project name @@ -97,6 +98,15 @@ export const ExperimentListing = ({ projectData: projectData, onCopyExperiment, setDeleteModalOpen(false); }; + const openGraphModal = () => { + setShowGraphModal(true); + }; + + const closeGraphModal = () => { + setShowGraphModal(false); + } + + return (
@@ -247,33 +257,41 @@ export const ExperimentListing = ({ projectData: projectData, onCopyExperiment, : } - { - project.creator == session?.user?.id! ? - : null } + { + project.creator == session?.user?.id! ? + : null + }
: null } + { + (showGraphModal && project.finished) && ( + + ) + }
); diff --git a/apps/frontend/app/components/flows/ViewExperiment/Modal.tsx b/apps/frontend/app/components/flows/ViewExperiment/Modal.tsx deleted file mode 100644 index 03cf413f..00000000 --- a/apps/frontend/app/components/flows/ViewExperiment/Modal.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { InputSection } from '../../InputSection'; -import ReactDOM from 'react-dom'; - -interface ModalProps { - onClose?: () => void; - children: React.ReactNode; -} - -const GraphModal: React.FC = ({onClose, children}) => { - return ReactDOM.createPortal( -
-
-
- -
-
- {children} -
-
-
, - document.body - ); -}; - -export default GraphModal; \ No newline at end of file diff --git a/apps/frontend/lib/db.ts b/apps/frontend/lib/db.ts index 72b5f1d9..a155fb79 100644 --- a/apps/frontend/lib/db.ts +++ b/apps/frontend/lib/db.ts @@ -78,3 +78,25 @@ export const downloadExperimentProjectZip = async (expId: string) => { }); }); }; + +export const getExperimentDataForGraph = async (expId: string) => { + console.log(`Getting results for ${expId} to use in graph modal...`); + await fetch(`/api/download/csv/${expId}`).then((response) => { + if (response?.ok) { + return response.json(); + } + return Promise.reject(response); + }).then((record: ResultsCsv) => { + console.log(record); + return record; + }).catch((response: Response) => { + console.warn('Error getting experiment results', response.status); + response.json().then((json: any) => { + console.warn(json?.response ?? json); + const message = json?.response; + if (message) { + alert(`Error getting experiment results: ${message}`); + } + }); + }); +} \ No newline at end of file diff --git a/apps/frontend/lib/mongodb_funcs.ts b/apps/frontend/lib/mongodb_funcs.ts index cd318aa6..793d4207 100644 --- a/apps/frontend/lib/mongodb_funcs.ts +++ b/apps/frontend/lib/mongodb_funcs.ts @@ -43,6 +43,15 @@ export async function deleteDocumentById(expId: string) { return Promise.reject(`Could not find document with id: ${expId}`); } + //Since we found it, make sure to delete data from logs, results, and zips + const db = client.db(DB_NAME); + //Delete logs + await db.collection('logs').deleteMany({ "experimentId": expId }); + //Delete results + await db.collection('results').deleteMany({ "experimentId": expId }); + //Delete zips + await db.collection('zips').deleteMany({ "experimentId": expId }); + return Promise.resolve(); } diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 6c2b13c0..f3486506 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -13,15 +13,17 @@ "@fortawesome/fontawesome-svg-core": "^6.2.0", "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/react-fontawesome": "^0.2.0", - "@headlessui/react": "^1.7.3", + "@headlessui/react": "^1.7.19", "@heroicons/react": "^2.0.12", "@mantine/core": "^5.6.2", "@mantine/dropzone": "^5.6.2", "@mantine/form": "^7.14.1", "@mantine/hooks": "^5.6.2", + "@sgratzl/chartjs-chart-boxplot": "^4.4.3", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@types/formidable": "^3.4.5", + "chart.js": "^4.4.6", "classnames": "^2.3.2", "dayjs": "^1.11.6", "encoding": "^0.1.13", @@ -1208,10 +1210,12 @@ } }, "node_modules/@headlessui/react": { - "version": "1.7.17", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.17.tgz", - "integrity": "sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==", + "version": "1.7.19", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz", + "integrity": "sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==", + "license": "MIT", "dependencies": { + "@tanstack/react-virtual": "^3.0.0-beta.60", "client-only": "^0.0.1" }, "engines": { @@ -1806,6 +1810,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@mantine/core": { "version": "5.10.5", "resolved": "https://registry.npmjs.org/@mantine/core/-/core-5.10.5.tgz", @@ -2334,6 +2343,24 @@ "integrity": "sha512-0xd7qez0AQ+MbHatZTlI1gu5vkG8r7MYRUJAHPAHJBmGLs16zpkrpAVLvjQKQOqaXPDUBwOiJzNc00znHSCVBw==", "dev": true }, + "node_modules/@sgratzl/boxplots": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@sgratzl/boxplots/-/boxplots-1.3.3.tgz", + "integrity": "sha512-P0a5eFLMMLKNPgi2Ap/A5p9QmlHaxJ2iOedCY6JuKJpi5Id+N8/Rrg3q+0QlckbqtwBSDuTkBGHUPemvOOArPg==", + "license": "MIT" + }, + "node_modules/@sgratzl/chartjs-chart-boxplot": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@sgratzl/chartjs-chart-boxplot/-/chartjs-chart-boxplot-4.4.3.tgz", + "integrity": "sha512-STaXspyQksqstN7XGGYTV1N7eGazJ3NpXhdskMhcMmsamsyl6xNEYaTtKUnbC5kG4mNYneopikDV1R9mDcFUpQ==", + "license": "MIT", + "dependencies": { + "@sgratzl/boxplots": "^1.3.2" + }, + "peerDependencies": { + "chart.js": "^4.1.1" + } + }, "node_modules/@sideway/address": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", @@ -2382,6 +2409,33 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.10.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz", + "integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.10.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.10.9", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.9.tgz", + "integrity": "sha512-kBknKOKzmeR7lN+vSadaKWXaLS0SZZG+oqpQ/k80Q6g9REn6zRHS/ZYdrIzHnpHgy/eWs00SujveUN/GJT2qTw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", @@ -3375,6 +3429,18 @@ "node": ">=8" } }, + "node_modules/chart.js": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.6.tgz", + "integrity": "sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f41beb63..dd6bcf04 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,15 +16,17 @@ "@fortawesome/fontawesome-svg-core": "^6.2.0", "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/react-fontawesome": "^0.2.0", - "@headlessui/react": "^1.7.3", + "@headlessui/react": "^1.7.19", "@heroicons/react": "^2.0.12", "@mantine/core": "^5.6.2", "@mantine/dropzone": "^5.6.2", "@mantine/form": "^7.14.1", "@mantine/hooks": "^5.6.2", + "@sgratzl/chartjs-chart-boxplot": "^4.4.3", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@types/formidable": "^3.4.5", + "chart.js": "^4.4.6", "classnames": "^2.3.2", "dayjs": "^1.11.6", "encoding": "^0.1.13",