diff --git a/bun.lockb b/bun.lockb index e433c00..e954d74 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 61a059c..0429d0c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "danfojs-node": "^1.1.2", "express": "^4.19.2", "html2canvas": "^1.4.1", + "graphviz-react": "^1.2.5", "joi": "^17.13.1", "lodash": "^4.17.21", "lucide-react": "^0.378.0", @@ -44,6 +45,7 @@ "react-dropzone": "^14.2.3", "react-force-graph": "^1.44.3", "react-hot-toast": "^2.4.1", + "react-router-dom": "^6.24.0", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", "timsort": "^0.3.0", diff --git a/src/App.tsx b/src/App.tsx index 9904549..2be00b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,121 +1,56 @@ import './App.css'; -import {useContext, useEffect, useState} from 'react'; -import { GlobalDataType, GraphData } from './lib/types'; -import DirectedGraph from './components/DirectedGraph'; -import DropZone from './components/DropZone'; -import { Button } from './components/ui/button'; -import { Context } from './Context'; -import { processDataShopData } from './lib/dataProcessingUtils'; -import {filterPromGrad} from "@/lib/GradPromUtils"; - -import './Switch.css'; - - -function App() { - - const { resetData, setGraphData, setLoading, - data, setData, graphData, loading } = useContext(Context) - const [showDropZone, setShowDropZone] = useState(true) - - const handleData = (data: GlobalDataType[]) => { - setData(data) - setShowDropZone(false) - } - - const handleLoading = (loading: boolean) => { - setLoading(loading) - } - - const filterData = (data: GlobalDataType[], filter:"PROMOTED"|"GRADUATED"|null|string) => { - if (data){ - const f = filterPromGrad(data, filter) - // setFilteredData(f) - return f - } - else { - - } - - } - - useEffect(() => { - if (data) { - const graphData: GraphData = processDataShopData(data) - setGraphData(graphData) - }}, [data]) - - - - return ( - <> -
- {/* */} - - -
- - { - loading ? -
-
-

Loading...

-
-
- : - ( - showDropZone && ( -
- -
- ) - - ) - - } - - - { - graphData && ( - <> - {/* Add suspense, lazy? */} - - - ) - } - - - {/*
*/} - {/* */} - {/* */} - {/*

*/} - {/*

*/} - {/*

*/} - - {/* */} - {/*
*/} - -
-
- - -) -} - -export default App +import React, {useContext, useState} from 'react'; +import Upload from "@/components/Upload.tsx"; +import GraphvizParent from "@/components/GraphvizParent.tsx"; +import FilterComponent from './components/FilterComponent.tsx'; +import SelfLoopSwitch from './components/selfLoopSwitch.tsx'; +import Slider from './components/slider.tsx'; +import SequenceSelector from "@/components/SequenceSelector.tsx"; +import {Context, SequenceCount} from "@/Context.tsx"; + +const App: React.FC = () => { + const [csvData, setCsvData] = useState(''); + const [filter, setFilter] = useState(''); + const [selfLoops, setSelfLoops] = useState(true); + const [minVisits, setMinVisits] = useState(0); + const {top5Sequences} = useContext(Context); + const [selectedSequence, setSelectedSequence] = useState(null); + + const handleSelectSequence = (selectedSequence: SequenceCount["sequence"]) => { + if (top5Sequences) { + setSelectedSequence(selectedSequence); // Fix: Use the correct parameter to update the state + console.log(`Selected sequence: ${selectedSequence}`); + //Selected sequence gets this far but doesn't update in GraphvizProcessing + } + }; + + const handleToggle = () => setSelfLoops(!selfLoops); + const handleSlider = (value: number) => setMinVisits(value); + const handleDataProcessed = (uploadedCsvData: string) => setCsvData(uploadedCsvData); + + + return ( +
+

Path Analysis Tool

+ + +

{selectedSequence}

+ + + + + +
+ ); +}; + +export default App; diff --git a/src/Context.tsx b/src/Context.tsx index 2f8bc28..1aa9066 100644 --- a/src/Context.tsx +++ b/src/Context.tsx @@ -1,53 +1,70 @@ -import { createContext, useState } from 'react'; -import { GlobalDataType, GraphData } from './lib/types'; +import {createContext, useState} from 'react'; +import {GlobalDataType, GraphData} from './lib/types'; + interface ContextInterface { data: GlobalDataType[] | null; graphData: GraphData | null; - filteredData: GlobalDataType[] | null - setFilteredData: (filteredData: GlobalDataType[] | null) => void; loading: boolean; + top5Sequences: SequenceCount[] | null; + selectedSequence: string[] | undefined; // string[] or SequenceCount[].sequence? setLoading: (loading: boolean) => void; setData: (data: GlobalDataType[] | null) => void; setGraphData: (graphData: GraphData | null) => void; + setTop5Sequences: (top5Sequences: SequenceCount[] | null) => void; + setSelectedSequence: (selectedSequence: string[] | undefined) => void; resetData: () => void; } + +export interface SequenceCount { + sequence: string[] | undefined; + count: number;//| null; +} + export const Context = createContext({} as ContextInterface); const initialState = { data: null, - filteredData: null, graphData: null, - loading: false + loading: false, + top5Sequences: null, + selectedSequence: undefined, } interface ProviderProps { children: React.ReactNode; } -export const Provider = ({ children }: ProviderProps) => { + + +export const Provider = ({children}: ProviderProps) => { const [data, setData] = useState(initialState.data) - const [filteredData, setFilteredData] = useState(initialState.filteredData) const [graphData, setGraphData] = useState(initialState.graphData) const [loading, setLoading] = useState(initialState.loading) + const [top5Sequences, setTop5Sequences] = useState(initialState.top5Sequences) + const [selectedSequence, setSelectedSequence] = useState(initialState.selectedSequence); const resetData = () => { setData(null) setGraphData(null) + setTop5Sequences(null) + setSelectedSequence(undefined) console.log("Data reset"); - + } return ( {children} diff --git a/src/GraphvizContainer.css b/src/GraphvizContainer.css new file mode 100644 index 0000000..8d577d8 --- /dev/null +++ b/src/GraphvizContainer.css @@ -0,0 +1,23 @@ +.graphviz-container { + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; /* Change to row to align items horizontally */ + width: 100%; /* Ensure the container takes full width */ + position: relative; + transform: translateY(-150px); +} + +.graphs { + display: flex; + flex-direction: row; /* Ensure the graphs are aligned in a row */ + justify-content: space-around; + align-items: center; + /*gap: 10px;*/ + width: 100%; /* Ensure the graphs container takes full width */ +} + +.graphs > div { + flex: 1; /* Allow the graphs to scale according to available space */ + max-width: 400px; /* Set a max-width for each graph */ +} diff --git a/src/Switch.css b/src/Switch.css index 0da78bf..10e312c 100644 --- a/src/Switch.css +++ b/src/Switch.css @@ -9,18 +9,18 @@ height: 25px; border-radius: 25px; background-color: grey; - position: absolute; + position: relative; left: 0px; - top: 35px; + top: 0px; transition: background-color 0.2s; } -.Promoted { - background-color: lightgreen; +.true { + background-color: #80d580; } -.Graduated { - background-color: deepskyblue; +.false { + background-color: #b20da7; } .switch-handle { @@ -28,13 +28,13 @@ height: 23px; border-radius: 50%; background-color: white; - position: absolute; + position: relative; top: 1px; left: 1px; - transition: left 0.2s; + transition: left 0.1s; } -.Promoted .switch-handle { +.true .switch-handle { left: 26px; } @@ -42,21 +42,6 @@ display: none; } -.switch.disabled.switch-display{ /*Not working*/ - opacity: 30; -} - -.checkbox{ - position: fixed; - left: 93px; - top: 50px; -} - -.checkbox.toggler__label{ - position: fixed; -} - -.tab { - display: inline-block; - margin-left: 10px; +.switch.disabled{ /*Not working*/ + visibility: hidden; } diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx new file mode 100644 index 0000000..715247f --- /dev/null +++ b/src/components/Debug.tsx @@ -0,0 +1,22 @@ +import { GlobalDataType } from "@/lib/types"; +import { useState } from "react"; +import DropZone from "./DropZone"; + +export default function Debug() { + const [data, setData] = useState([]) + const handleData = (data: GlobalDataType[]) => { + setData(data) + console.log("Data from file: ", data); + + } + + const handleLoading = (loading: boolean) => { + } + + return ( + <> + + + + ) +} \ No newline at end of file diff --git a/src/components/DirectedGraph.tsx b/src/components/DirectedGraph.tsx index 5477e48..f556a83 100644 --- a/src/components/DirectedGraph.tsx +++ b/src/components/DirectedGraph.tsx @@ -1,530 +1,475 @@ -import {useContext, useEffect, useRef, useState} from "react" +import { useEffect, useRef, useState } from "react" import { ForceGraph2D } from 'react-force-graph'; -import {GraphData, ToolTip, Node, Link, ContextMenuControls, LinkObject, GlobalDataType} from "@/lib/types"; +import { GraphData, ToolTip, Node, Link, ContextMenuControls, LinkObject } from "@/lib/types"; import * as d3 from "d3"; import html2canvas from 'html2canvas'; import toast from "react-hot-toast"; import { Cross2Icon } from "@radix-ui/react-icons"; import { Slider } from "@/components/ui/slider" -import {addUnusedNodes, changeLinkThreshold, processDataShopData, removeUnusedNodes} from "@/lib/dataProcessingUtils"; +import { addUnusedNodes, changeLinkThreshold, removeUnusedNodes } from "@/lib/dataProcessingUtils"; import ToggleTool from "@/components/ToggleTool"; -import {filterPromGrad} from "@/lib/GradPromUtils"; -import Switch from "@/components/ui/switch"; -import "./../Switch.css" -import {Context} from "@/Context.tsx"; // TODO make sure the node info is changed interface DirectedGraphProps { - graphData: GraphData; + graphData: GraphData; } export default function DirectedGraph({ graphData }: DirectedGraphProps) { - const initialTooltip: ToolTip = { display: false, text: '', x: 0, y: 0, fx: undefined, fy: undefined }; - const [tooltip, setTooltip] = useState(initialTooltip); - const [currentGraphData, setCurrentGraphData] = useState(graphData); - const forceGraphRef = useRef(null); // must be `any` because no typing is available for react-force-graph - const [orderByTime,] = useState(true); - const [pinnable,] = useState(false); - const [edgesThreshold, setEdgesThreshold] = useState((graphData.maxEdgeCount ?? 100) * 0.1); - const [previousEdgesThreshold, setPreviousEdgesThreshold] = useState(((graphData.maxEdgeCount ?? 100) * 0.1)); - const initialContextMenuControls: ContextMenuControls = { visible: false, x: 0, y: 0, node: null }; - - const [contextMenu, setContextMenu] = useState(initialContextMenuControls); - - const [, setRemovedNodeStorage] = useState([]); - const [windowWidth, setWindowWidth] = useState(window.innerWidth); - - const {data, setData} = useContext(Context) - // save to photo - const captureScreenshot = () => { - const input = document.getElementById('graph') as HTMLElement; - if (input) { - toast.success("Capturing screenshot") - html2canvas(input).then((canvas) => { - const imgData = canvas.toDataURL(); - const link = document.createElement('a'); - link.href = imgData; - link.download = 'graph-screenshot.png'; - link.click(); - }); - } - else { - toast.error("Could not capture screenshot", { - position: "top-center", - }) - } - - } - - const filterData = (data: GlobalDataType[], filter:"PROMOTED"|"GRADUATED"|null|string) => { - if (filter!=null){ - const filteredData = filterPromGrad(data, filter) - // setFilteredData(f) - // setData(f) - // console.log(data) - setCurrentGraphData(processDataShopData(filteredData)) - return filteredData - }} - - const [isOn, setIsOn] = useState(false); - const [isSwitchEnabled, setIsSwitchEnabled] = useState(false); - const [filter, setFilter] = useState<"PROMOTED"|"GRADUATED"|null|string>("GRADUATED"); - // Using Graduated as initial gives the first click the correct value but not after - // -- doesn't work if initial state is null - - const handleToggle = () => { - if (isSwitchEnabled){ - setIsOn(!isOn); - } - }; - - const handleCheckboxChange = (event: React.ChangeEvent) => { - setIsSwitchEnabled(event.target.checked); - }; - - // const handleFilterChange = (event: React.ChangeEvent) => { - // setFilter(event.target.value); - // }; - - useEffect(() => { - if (isSwitchEnabled) { - let value = isOn ? "PROMOTED" : "GRADUATED"; - setFilter(value); - // console.log("Filter: " + filter) - let f = filterData(data!, value) - console.log("Filter: " + value) - // console.log(f) - // if (data) { - const graphData: GraphData = processDataShopData(f!) - setCurrentGraphData(graphData) - - } - - else { - const graphData: GraphData = processDataShopData(data!) - setCurrentGraphData(graphData) - // }}, [data]) - - - }}, [isSwitchEnabled, isOn]); - - const getValueBasedOnSwitch = () => { - if (isSwitchEnabled) { - return filter - }; - } - - useEffect(() => { - // resizing of window - const handleResize = () => { - setWindowWidth(window.innerWidth); - }; - - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, []); - - - useEffect(() => { - // Ensure the forceGraphRef is current and the graph has been initialized - if (forceGraphRef.current) { - // Set the default zoom level, for example, to 1.5 - forceGraphRef.current.zoom(0.4, 500); // The second parameter (500) represents the duration of the zoom transition in milliseconds - } - }, []); - - // NODE PHYSICS - useEffect(() => { - if (forceGraphRef.current !== null) { - forceGraphRef.current.d3Force('collision', d3.forceCollide(() => (100)).strength(0.75)); - // ordering - if (orderByTime) { - forceGraphRef.current.d3Force('rank', d3.forceY((node: Node) => (node.rank ?? 0) * 100).strength(0.5)); - forceGraphRef.current.d3Force('x', d3.forceX(windowWidth / 2).strength(0.5)); - } - else { - forceGraphRef.current.d3Force('rank', null); - } - } - }, [currentGraphData, orderByTime, windowWidth]); - - - useEffect(() => { - const updateMousePosition = (event: { clientX: number; clientY: number; }) => { - if (tooltip.display) { - setTooltip({ ...tooltip, x: event.clientX, y: event.clientY }); - } - }; - window.addEventListener("mousemove", updateMousePosition); - - return () => { - window.removeEventListener("mousemove", updateMousePosition); - }; - }, [tooltip]); - - const handleNodeHover = (node: Node | null) => { - if (node) { - // setTooltip({ display: true, text: `${node.id}`, x: tooltip.x, y: tooltip.y, fx: node.fx, fy: node.fy }); - } else { - setTooltip((prev) => ({ ...prev, display: false })); - } - }; - - const handleLinkHover = (link: Link | null) => { - if (link) { - setTooltip({ display: true, text: `${link.numOfTransitions + " paths" ?? "no paths"}`, x: tooltip.x, y: tooltip.y, fx: undefined, fy: undefined }); - } - else { - setTooltip((prev) => ({ ...prev, display: false })); - } - - } - - const handleThresholdChangeRemove = () => { - const newLinks: LinkObject[] = changeLinkThreshold(graphData.links as LinkObject[], edgesThreshold); - const { newNodes, removedNodes } = removeUnusedNodes(currentGraphData.nodes, newLinks); - setRemovedNodeStorage(removedNodes); - setCurrentGraphData({ nodes: newNodes, links: newLinks }); - } - - const handleThresholdChangeAdd = () => { - const newLinks: LinkObject[] = changeLinkThreshold(graphData.links as LinkObject[], edgesThreshold); - const newNodes: Node[] = addUnusedNodes(currentGraphData.nodes, newLinks); - setCurrentGraphData({ nodes: newNodes, links: newLinks }); - } - - const handleThresholdChange = (chosenNum: number, previousValue: number) => { - if (chosenNum > previousValue) { - handleThresholdChangeRemove() - } - else { - handleThresholdChangeAdd() - } - } - - useEffect(() => { - // threshold logic - if ((edgesThreshold !== previousEdgesThreshold && forceGraphRef.current !== null) && edgesThreshold >= 0) { - if (edgesThreshold !== previousEdgesThreshold && forceGraphRef.current !== null) { - // forceGraphRef.current.pauseAnimation(); - handleThresholdChange(edgesThreshold, previousEdgesThreshold); - // forceGraphRef.current.resumeAnimation(); - } - } - }, [edgesThreshold]) - - useEffect(() => { - const timer = setTimeout(() => { - if (graphData && graphData.nodes.length > 0) { - const initialThreshold = (graphData.maxEdgeCount ?? 100) * 0.1; - setEdgesThreshold(initialThreshold); // Ensure the initial threshold is set correctly. - // Assuming handleThresholdChange is correctly implemented to handle the initial state - handleThresholdChange(initialThreshold, previousEdgesThreshold); - } - }, 200); // Delay execution by 500ms to ensure graphData is loaded before applying logic. - - // Cleanup function to clear the timeout if the component unmounts - return () => clearTimeout(timer); - }, [graphData]); // Depend on graphData to ensure it's loaded before applying logic. - - return ( - <> - - - { - contextMenu.visible && ( -
{ - // Prevent the browser context menu from appearing - e.preventDefault(); - }} - > -
- -
- Node Info -
- -
-
- Node ID: - {contextMenu.node?.id ?? "No ID"} -
- {contextMenu.node?.problemId && ( -
- Problem ID: {contextMenu.node.problemId} -
- - )} - {contextMenu.node?.selfLoops && ( -
- Self Loops: {contextMenu.node.selfLoops} -
- )} - {contextMenu.node?.cumulativeSelfLoops && ( -
- Cumulative Self Loops: {contextMenu.node.cumulativeSelfLoops} -
- )} - {contextMenu.node?.edgesIn && ( -
- Edges In: {contextMenu.node.edgesIn} -
- )} - {contextMenu.node?.edgesOut && ( -
- Edges Out: {contextMenu.node.edgesOut} -
- )} - - {contextMenu.node?.cumulativeEdgesOut && ( -
- Students leaving: {contextMenu.node.cumulativeEdgesOut} -
- )} - {contextMenu.node?.cumulativeEdgesIn && ( -
- Students entering: {contextMenu.node.cumulativeEdgesIn} -
- )} - {contextMenu.node?.rank && ( -
- Average Step Rank: {contextMenu.node.rank.toFixed(1)} -
- )} - {contextMenu.node?.times_errored && ( -
- Times Errored: {contextMenu.node.times_errored} -
- )} - {contextMenu.node?.rank && ( -
- Rank: {contextMenu.node.rank ?? ""} -
- )} - - -
- ) - } - - {tooltip.display && ( -
- {tooltip.text ?? ""} -
- )} - -
-
- -
- Edges Threshold (%): - { - const currentValue = edgesThreshold; - const newValue = (parseInt(e.target.value) / 100) * (graphData.maxEdgeCount ?? 100); - setEdgesThreshold(newValue) - setPreviousEdgesThreshold(currentValue); - }} - /> - - { - // Convert the percentage back to an absolute number for edgesThreshold - const newValue = (value[0] / 100) * (graphData.maxEdgeCount ?? 100); - setEdgesThreshold(newValue); - }} - onValueCommit={(value: number[]) => { - // This can be used for actions upon releasing the slider, similar to onValueChange - const newValue = (value[0] / 100) * (graphData.maxEdgeCount ?? 100); - setEdgesThreshold(newValue); - setPreviousEdgesThreshold(edgesThreshold); // Update previous threshold if needed - }} - /> - - - -
- -
- -
- - link.curvature || 0} - nodeAutoColorBy={"problemId"} - // cooldownTime={freezeNodes ? 0 : Infinity} // this controls the "steadiness" -- 1 is the most steady, infinity is the least - onNodeClick={(node, event) => { - setContextMenu({visible: true, x: event.clientX, y: event.clientY, node: node}); - }} - onNodeDragEnd={(node) => { - if (pinnable) { - if (node.border !== true) { - toast.success("Node pinned", { - icon: "📌", - position: "bottom-right", - }) - } - node.fx = node.x; - node.fy = node.y; - node.border = true; - } - - }} - onNodeRightClick={(node, event) => { - console.log("Node right clicked: ", node); - event.preventDefault(); - - }} - onLinkClick={(link) => { - console.log("Link clicked: ", link.source, link.target); - }} - onLinkRightClick={(link) => { - console.log("Link right clicked: ", link.source, link.target); - }} - nodeCanvasObject={(node, ctx, globalScale) => { - const label = node.label; - const fontSize = 14 / globalScale; - const radius = 14 / globalScale; - node.radius = radius; - ctx.beginPath(); - switch (node.shape) { - case 'circle': - ctx.arc(node.x!, node.y!, radius, 0, 2 * Math.PI, false); - break; - case 'triangle': - ctx.beginPath(); - ctx.moveTo(node.x!, node.y! - radius); - ctx.lineTo(node.x! + radius, node.y! + radius); - ctx.lineTo(node.x! - radius, node.y! + radius); - ctx.closePath(); - break; - case 'square': - ctx.rect(node.x! - radius, node.y! - radius, radius * 2, radius * 2); - break; - default: - ctx.arc(node.x!, node.y!, radius, 0, 2 * Math.PI, false); - - } - - ctx.fillStyle = node.color ?? 'blue'; - ctx.fill(); - - // border - if (node.border) { - ctx.lineWidth = 5 / globalScale; - ctx.strokeStyle = 'blue'; - ctx.stroke(); - } - - const labelOffsetY = radius + fontSize; - ctx.font = `${fontSize}px Sans-Serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = 'black'; - ctx.fillText(label, node.x!, node.y! + labelOffsetY); - }} - linkDirectionalArrowLength={20} - // linkDirectionalParticles={2} - // linkDirectionalParticleWidth={7} - linkDirectionalArrowColor={(link) => link.color || ''} - linkDirectionalArrowRelPos={1} - linkWidth={link => link.width || 1} - d3VelocityDecay={0.8} - // d3AlphaDecay={0.05} - minZoom={0.4} - maxZoom={2} - /> -
-
- -
- - -

-

-

- - -
- - )} - - - - - - - - - + const initialTooltip: ToolTip = { display: false, text: '', x: 0, y: 0, fx: undefined, fy: undefined }; + const [tooltip, setTooltip] = useState(initialTooltip); + const [currentGraphData, setCurrentGraphData] = useState(graphData); + const forceGraphRef = useRef(null); // must be `any` because no typing is available for react-force-graph + const [orderByTime,] = useState(true); + const [pinnable,] = useState(false); + const [edgesThreshold, setEdgesThreshold] = useState((graphData.maxEdgeCount ?? 100) * 0.1); + const [previousEdgesThreshold, setPreviousEdgesThreshold] = useState(((graphData.maxEdgeCount ?? 100) * 0.1)); + const initialContextMenuControls: ContextMenuControls = { visible: false, x: 0, y: 0, node: null }; + + const [contextMenu, setContextMenu] = useState(initialContextMenuControls); + + const [, setRemovedNodeStorage] = useState([]); + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + + // save to photo + const captureScreenshot = () => { + const input = document.getElementById('graph') as HTMLElement; + if (input) { + toast.success("Capturing screenshot") + html2canvas(input).then((canvas) => { + const imgData = canvas.toDataURL(); + const link = document.createElement('a'); + link.href = imgData; + link.download = 'graph-screenshot.png'; + link.click(); + }); + } + else { + toast.error("Could not capture screenshot", { + position: "top-center", + }) + } + + } + + + useEffect(() => { + // resizing of window + const handleResize = () => { + setWindowWidth(window.innerWidth); + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + + useEffect(() => { + // Ensure the forceGraphRef is current and the graph has been initialized + if (forceGraphRef.current) { + // Set the default zoom level, for example, to 1.5 + forceGraphRef.current.zoom(0.4, 500); // The second parameter (500) represents the duration of the zoom transition in milliseconds + } + }, []); + + // NODE PHYSICS + useEffect(() => { + if (forceGraphRef.current !== null) { + forceGraphRef.current.d3Force('collision', d3.forceCollide(() => (100)).strength(0.75)); + // ordering + if (orderByTime) { + forceGraphRef.current.d3Force('rank', d3.forceY((node: Node) => (node.rank ?? 0) * 100).strength(0.5)); + forceGraphRef.current.d3Force('x', d3.forceX(windowWidth / 2).strength(0.5)); + } + else { + forceGraphRef.current.d3Force('rank', null); + } + } + }, [currentGraphData, orderByTime, windowWidth]); + + + useEffect(() => { + const updateMousePosition = (event: { clientX: number; clientY: number; }) => { + if (tooltip.display) { + setTooltip({ ...tooltip, x: event.clientX, y: event.clientY }); + } + }; + window.addEventListener("mousemove", updateMousePosition); + + return () => { + window.removeEventListener("mousemove", updateMousePosition); + }; + }, [tooltip]); + + const handleNodeHover = (node: Node | null) => { + if (node) { + // setTooltip({ display: true, text: `${node.id}`, x: tooltip.x, y: tooltip.y, fx: node.fx, fy: node.fy }); + } else { + setTooltip((prev) => ({ ...prev, display: false })); + } + }; + + const handleLinkHover = (link: Link | null) => { + if (link) { + setTooltip({ display: true, text: `${link.numOfTransitions + " paths" ?? "no paths"}`, x: tooltip.x, y: tooltip.y, fx: undefined, fy: undefined }); + } + else { + setTooltip((prev) => ({ ...prev, display: false })); + } + + } + + const handleThresholdChangeRemove = () => { + const newLinks: LinkObject[] = changeLinkThreshold(graphData.links as LinkObject[], edgesThreshold); + const { newNodes, removedNodes } = removeUnusedNodes(currentGraphData.nodes, newLinks); + setRemovedNodeStorage(removedNodes); + setCurrentGraphData({ nodes: newNodes, links: newLinks }); + } + + const handleThresholdChangeAdd = () => { + const newLinks: LinkObject[] = changeLinkThreshold(graphData.links as LinkObject[], edgesThreshold); + const newNodes: Node[] = addUnusedNodes(currentGraphData.nodes, newLinks); + setCurrentGraphData({ nodes: newNodes, links: newLinks }); + } + + const handleThresholdChange = (chosenNum: number, previousValue: number) => { + if (chosenNum > previousValue) { + handleThresholdChangeRemove() + } + else { + handleThresholdChangeAdd() + } + } + + useEffect(() => { + // threshold logic + if ((edgesThreshold !== previousEdgesThreshold && forceGraphRef.current !== null) && edgesThreshold >= 0) { + if (edgesThreshold !== previousEdgesThreshold && forceGraphRef.current !== null) { + // forceGraphRef.current.pauseAnimation(); + handleThresholdChange(edgesThreshold, previousEdgesThreshold); + // forceGraphRef.current.resumeAnimation(); + } + } + }, [edgesThreshold]) + + useEffect(() => { + const timer = setTimeout(() => { + if (graphData && graphData.nodes.length > 0) { + const initialThreshold = (graphData.maxEdgeCount ?? 100) * 0.1; + setEdgesThreshold(initialThreshold); // Ensure the initial threshold is set correctly. + // Assuming handleThresholdChange is correctly implemented to handle the initial state + handleThresholdChange(initialThreshold, previousEdgesThreshold); + } + }, 200); // Delay execution by 200ms to ensure graphData is loaded before applying logic. + + // Cleanup function to clear the timeout if the component unmounts + return () => clearTimeout(timer); + }, [graphData]); // Depend on graphData to ensure it's loaded before applying logic. + + return ( + <> + + + + { + contextMenu.visible && ( +
{ + // Prevent the browser context menu from appearing + e.preventDefault(); + }} + > +
+ +
+ Node Info +
+ +
+
+ Node ID: + {contextMenu.node?.id ?? "No ID"} +
+ {contextMenu.node?.problemId && ( +
+ Problem ID: {contextMenu.node.problemId} +
+ + )} + {contextMenu.node?.selfLoops && ( +
+ Self Loops: {contextMenu.node.selfLoops} +
+ )} + {contextMenu.node?.cumulativeSelfLoops && ( +
+ Cumulative Self Loops: {contextMenu.node.cumulativeSelfLoops} +
+ )} + {contextMenu.node?.edgesIn && ( +
+ Edges In: {contextMenu.node.edgesIn} +
+ )} + {contextMenu.node?.edgesOut && ( +
+ Edges Out: {contextMenu.node.edgesOut} +
+ )} + + {contextMenu.node?.cumulativeEdgesOut && ( +
+ Students leaving: {contextMenu.node.cumulativeEdgesOut} +
+ )} + {contextMenu.node?.cumulativeEdgesIn && ( +
+ Students entering: {contextMenu.node.cumulativeEdgesIn} +
+ )} + {contextMenu.node?.rank && ( +
+ Average Step Rank: {contextMenu.node.rank.toFixed(1)} +
+ )} + {contextMenu.node?.times_errored && ( +
+ Times Errored: {contextMenu.node.times_errored} +
+ )} + {contextMenu.node?.rank && ( +
+ Rank: {contextMenu.node.rank ?? ""} +
+ )} + + + + +
+ ) + } + + {tooltip.display && ( +
+ {tooltip.text ?? ""} +
+ )} + +
+
+ +
+ Edges Threshold (%): + { + const currentValue = edgesThreshold; + const newValue = (parseInt(e.target.value) / 100) * (graphData.maxEdgeCount ?? 100); + setEdgesThreshold(newValue) + setPreviousEdgesThreshold(currentValue); + }} + /> + + { + // Convert the percentage back to an absolute number for edgesThreshold + const newValue = (value[0] / 100) * (graphData.maxEdgeCount ?? 100); + setEdgesThreshold(newValue); + }} + onValueCommit={(value: number[]) => { + // This can be used for actions upon releasing the slider, similar to onValueChange + const newValue = (value[0] / 100) * (graphData.maxEdgeCount ?? 100); + setEdgesThreshold(newValue); + setPreviousEdgesThreshold(edgesThreshold); // Update previous threshold if needed + }} + /> + + + +
+ +
+ +
+ + link.curvature || 0} + nodeAutoColorBy={"problemId"} + // cooldownTime={freezeNodes ? 0 : Infinity} // this controls the "steadiness" -- 1 is the most steady, infinity is the least + onNodeClick={(node, event) => { + setContextMenu({ visible: true, x: event.clientX, y: event.clientY, node: node }); + }} + onNodeDragEnd={(node) => { + if (pinnable) { + if (node.border !== true) { + toast.success("Node pinned", { + icon: "📌", + position: "bottom-right", + }) + } + node.fx = node.x; + node.fy = node.y; + node.border = true; + } + + }} + onNodeRightClick={(node, event) => { + console.log("Node right clicked: ", node); + event.preventDefault(); + + }} + onLinkClick={(link) => { + console.log("Link clicked: ", link.source, link.target); + }} + onLinkRightClick={(link) => { + console.log("Link right clicked: ", link.source, link.target); + }} + nodeCanvasObject={(node, ctx, globalScale) => { + const label = node.label; + const fontSize = 14 / globalScale; + const radius = 14 / globalScale; + node.radius = radius; + ctx.beginPath(); + switch (node.shape) { + case 'circle': + ctx.arc(node.x!, node.y!, radius, 0, 2 * Math.PI, false); + break; + case 'triangle': + ctx.beginPath(); + ctx.moveTo(node.x!, node.y! - radius); + ctx.lineTo(node.x! + radius, node.y! + radius); + ctx.lineTo(node.x! - radius, node.y! + radius); + ctx.closePath(); + break; + case 'square': + ctx.rect(node.x! - radius, node.y! - radius, radius * 2, radius * 2); + break; + default: + ctx.arc(node.x!, node.y!, radius, 0, 2 * Math.PI, false); + + } + + ctx.fillStyle = node.color ?? 'blue'; + ctx.fill(); + + // border + if (node.border) { + ctx.lineWidth = 5 / globalScale; + ctx.strokeStyle = 'blue'; + ctx.stroke(); + } + + const labelOffsetY = radius + fontSize; + ctx.font = `${fontSize}px Sans-Serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'black'; + ctx.fillText(label, node.x!, node.y! + labelOffsetY); + }} + // linkCanvasObject={(link, ctx, globalScale) => { + // const length = 30 / globalScale; // Adjust the arrow length based on global scale + // const sx = (link.source as Node).x; + // const sy = (link.source as Node).y; + // const tx = (link.target as Node).x; + // const ty = (link.target as Node).y; + // if (sx === undefined || sy === undefined || tx === undefined || ty === undefined) { + // return; + // } + // // calculate the direction of the arrow + // const dir = Math.atan2(ty - sy, tx - sx); + + // // Draw the arrow + // ctx.beginPath(); + // ctx.moveTo(sx, sy); + // ctx.lineTo(tx, ty); + // ctx.stroke(); + + // // Draw the arrow head + // ctx.beginPath(); + // ctx.moveTo(tx, ty); + // ctx.lineTo(tx - length * Math.cos(dir - Math.PI / 6), ty - length * Math.sin(dir - Math.PI / 6)); + // ctx.lineTo(tx - length * Math.cos(dir + Math.PI / 6), ty - length * Math.sin(dir + Math.PI / 6)); + // ctx.closePath(); + // ctx.fill(); + // }} + linkDirectionalArrowLength={() => { + // `arrow` can be a param here + return 30; + + }} + // linkDirectionalParticles={2} + // linkDirectionalParticleWidth={7} + linkDirectionalArrowColor={(link) => link.color || ''} + linkDirectionalArrowRelPos={1} + linkWidth={link => link.width || 1} + d3VelocityDecay={0.8} + // d3AlphaDecay={0.05} + minZoom={0.4} + maxZoom={2} + /> +
+
+ + ) +} \ No newline at end of file diff --git a/src/components/DropZone.tsx b/src/components/DropZone.tsx index 71efc57..a1e9e2f 100644 --- a/src/components/DropZone.tsx +++ b/src/components/DropZone.tsx @@ -1,241 +1,132 @@ -// import { useCallback, useState } from 'react'; -// import { Accept, useDropzone } from 'react-dropzone'; -// import { GlobalDataType } from '@/lib/types'; -// import { parseData } from '@/lib/utils'; -// import { Label } from "@/components/ui/label" -// import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" -// import toast from 'react-hot-toast'; -// -// interface DropZoneProps { -// afterDrop: (data: GlobalDataType[]) => void, -// onLoadingChange: (loading: boolean) => void -// } -// -// export default function DropZone({ afterDrop, onLoadingChange }: DropZoneProps) { -// const delimiters = ["tsv", "csv", "pipe"]; -// const [errorMessage, setErrorMessage] = useState(""); -// -// const [fileType, setFileType] = useState(delimiters[0]) -// -// const onDrop = useCallback((acceptedFiles: File[]) => { -// onLoadingChange(true); -// -// acceptedFiles.forEach((file: File) => { -// const reader = new FileReader(); -// -// reader.onabort = () => console.warn('file reading was aborted'); -// reader.onerror = () => console.error('file reading has failed'); -// reader.onload = () => { -// const textStr = reader.result; -// let delimiter: string; -// switch (fileType) { -// case 'tsv': -// delimiter = '\t'; -// break; -// case 'csv': -// delimiter = ','; -// break; -// case 'pipe': -// delimiter = '|'; -// break; -// default: -// delimiter = '\t'; -// break; -// } -// const array: GlobalDataType[] | null = parseData(textStr, delimiter); -// console.log("Array: ", array); -// // array is null when there is an error in the file structure or content -// if (!array) { -// -// toast.error("Invalid file structure or content") -// console.log("Error state before: ", errorMessage); -// setErrorMessage("Invalid file structure or content"); -// console.log("Error state after: ", errorMessage); -// -// // the below prints, but the above does not execute. Why? -// // console.error("!!!Invalid file structure or content"); -// } -// else { -// afterDrop(array); -// } -// -// -// onLoadingChange(false); -// }; -// reader.readAsText(file); -// console.log("File: ", file); -// -// }); -// }, [fileType, afterDrop, onLoadingChange]); -// -// const acceptedFileTypes: Accept = { -// 'text/plain': ['.txt', '.csv', '.tsv', '.json', '.tsv', '.pipe'], -// } -// -// -// -// const { getRootProps, getInputProps, isDragActive, isFocused, isDragReject } = useDropzone({ -// onDrop, -// accept: acceptedFileTypes, -// // validator: validateData -// }); -// -// -// -// const fileTypeOptions = [ -// { -// label: 'Tab Separated', -// value: delimiters.find((delimiter) => delimiter === 'tsv') as string -// }, -// { -// label: 'Comma Separated', -// value: delimiters.find((delimiter) => delimiter === 'csv') as string -// }, -// { -// label: 'Pipe Separated', -// value: delimiters.find((delimiter) => delimiter === 'pipe') as string -// }, -// // { -// // label: 'JSON', -// // value: delimiters.find((delimiter) => delimiter === 'json') as string -// // } -// ] -// return ( -// <> -//
-//
-// File Type -//
-// { -// setFileType(e) -// -// }}> -// {fileTypeOptions.map((option, index) => ( -//
-// -// -//
-// ))} -//
-//
-//
-// -// { -// !isDragActive ? -//
-//

Drag 'n' drop some files here, or click to select files

-//
-// : -//
-//

Drag 'n' drop some files here, or click to select files

-//
-// } -// -//
-// {errorMessage &&

{errorMessage}

} -//
-//
-// -// -// -// ); -// } - -import { useCallback, useState, useEffect } from 'react'; +import { useCallback, useState } from 'react'; import { Accept, useDropzone } from 'react-dropzone'; import { GlobalDataType } from '@/lib/types'; -import { Label } from "@/components/ui/label"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { parseData } from '@/lib/utils'; +import { Label } from "@/components/ui/label" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import toast from 'react-hot-toast'; -import * as Comlink from 'comlink'; interface DropZoneProps { - afterDrop: (data: GlobalDataType[]) => void; - onLoadingChange: (loading: boolean) => void; -} - -// Define the worker API interface -interface WorkerApi { - parseData(text: string, delimiter: string): Promise; + afterDrop: (data: GlobalDataType[]) => void, + onLoadingChange: (loading: boolean) => void } +// TODO move this up to App.tsx so I can better handle errors export default function DropZone({ afterDrop, onLoadingChange }: DropZoneProps) { const delimiters = ["tsv", "csv", "pipe"]; const [errorMessage, setErrorMessage] = useState(""); - const [fileType, setFileType] = useState(delimiters[0]); - const [worker, setWorker] = useState(null); - useEffect(() => { - // Initialize the Comlink worker - const workerInstance = new Worker(new URL('./fileWorker.ts', import.meta.url)); - const proxy = Comlink.wrap(workerInstance); - setWorker(proxy); + const [fileType, setFileType] = useState(delimiters[0]) - return () => { - workerInstance.terminate(); - }; - }, []); - - const onDrop = useCallback(async (acceptedFiles: File[]) => { + const onDrop = useCallback((acceptedFiles: File[]) => { onLoadingChange(true); - - const delimiter = fileType === 'tsv' ? '\t' : fileType === 'csv' ? ',' : '|'; - - for (const file of acceptedFiles) { + + acceptedFiles.forEach((file: File) => { const reader = new FileReader(); reader.onabort = () => console.warn('file reading was aborted'); reader.onerror = () => console.error('file reading has failed'); - reader.onload = async () => { - const textStr = reader.result as string; + reader.onload = () => { + const textStr = reader.result; + let delimiter: string; + switch (fileType) { + case 'tsv': + delimiter = '\t'; + break; + case 'csv': + delimiter = ','; + break; + case 'pipe': + delimiter = '|'; + break; + default: + delimiter = '\t'; + break; + } + const array: GlobalDataType[] | null = parseData(textStr, delimiter); + console.log("Array from file: ", array); + // array is null when there is an error in the file structure or content + if (!array) { - try { - if (worker) { - const array: GlobalDataType[] | null = await worker.parseData(textStr, delimiter); - if (array) { - afterDrop(array); - } else { - throw new Error("Invalid file structure or content"); - } - } - } catch (error) { - console.error(error.message); - toast.error("Invalid file structure or content"); + toast.error("Invalid file structure or content") + console.log("Error state before: ", errorMessage); setErrorMessage("Invalid file structure or content"); - } finally { - onLoadingChange(false); + console.log("Error state after: ", errorMessage); + + // the below prints, but the above does not execute. Why? + // console.error("!!!Invalid file structure or content"); + } + else { + afterDrop(array); } + + + onLoadingChange(false); }; reader.readAsText(file); - } - }, [fileType, afterDrop, onLoadingChange, worker]); + onLoadingChange(false); + // console.log("File: ", file); + + }); + }, [fileType, afterDrop, onLoadingChange]); const acceptedFileTypes: Accept = { - 'text/plain': ['.txt', '.csv', '.tsv', '.json', '.pipe'], - } + 'text/tab-separated-values': ['.tsv'], + 'text/csv': ['.csv'], + 'text/plain': ['.txt', '.csv', '.tsv', '.json', '.pipe'] + }; - const { getRootProps, getInputProps, isDragActive, isFocused } = useDropzone({ + + + const { getRootProps, getInputProps, isDragActive, isFocused, isDragReject } = useDropzone({ onDrop, accept: acceptedFileTypes, + validator: (file) => { + // returns FileError | Array. | null + if (!acceptedFileTypes[file.type]) { + + return { + code: 'file-invalid-type', + message: 'Invalid file type', + } + } + return null; + } }); - const fileTypeOptions = [ - { label: 'Tab Separated', value: 'tsv' }, - { label: 'Comma Separated', value: 'csv' }, - { label: 'Pipe Separated', value: 'pipe' }, - ]; + + const fileTypeOptions = [ + { + label: 'Tab Separated', + value: delimiters.find((delimiter) => delimiter === 'tsv') as string + }, + { + label: 'Comma Separated', + value: delimiters.find((delimiter) => delimiter === 'csv') as string + }, + { + label: 'Pipe Separated', + value: delimiters.find((delimiter) => delimiter === 'pipe') as string + }, + // { + // label: 'JSON', + // value: delimiters.find((delimiter) => delimiter === 'json') as string + // } + ] return ( <>
-
File Type
- +
+ File Type +
+ { + setFileType(e) + + }}> {fileTypeOptions.map((option, index) => (
- +
))} @@ -248,19 +139,22 @@ export default function DropZone({ afterDrop, onLoadingChange }: DropZoneProps) { !isDragActive ? -
-

Drag 'n' drop some files here, or click to select files

+
+

Drag 'n' drop some files here, or click to select files

: -
-

Drop the files here...

+
+

Drag 'n' drop some files here, or click to select files

} + {isDragReject &&

Invalid file type

}
{errorMessage &&

{errorMessage}

}
+ + ); -} +} \ No newline at end of file diff --git a/src/components/FilterComponent.tsx b/src/components/FilterComponent.tsx new file mode 100644 index 0000000..9af8f5f --- /dev/null +++ b/src/components/FilterComponent.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +interface FilterComponentProps { + onFilterChange: (filter: string) => void; +} + +const FilterComponent: React.FC = ({ onFilterChange }) => { + const handleFilterChange = (event: React.ChangeEvent) => { + onFilterChange(event.target.value); + }; + + return ( +
+ + +
+ ); +}; + +export default FilterComponent; \ No newline at end of file diff --git a/src/components/GraphvizParent.tsx b/src/components/GraphvizParent.tsx new file mode 100644 index 0000000..fb401bc --- /dev/null +++ b/src/components/GraphvizParent.tsx @@ -0,0 +1,167 @@ +import React, {useContext, useEffect, useState} from 'react'; +import { + generateDotString, + normalizeThicknesses, + countEdges, + createStepSequences, + createOutcomeSequences, + loadAndSortData +} from './GraphvizProcessing'; +import Graphviz from "graphviz-react"; +import ErrorBoundary from "@/components/errorBoundary.tsx"; +import '../GraphvizContainer.css'; +import {Context, SequenceCount} from "@/Context.tsx"; + +interface GraphvizParentProps { + csvData: string; + filter: string | null; + selfLoops: boolean; + minVisits: number; + selectedSequence: string[] | undefined; + +} + +const GraphvizParent: React.FC = ({ + csvData, + filter, + selfLoops, + minVisits, + }) => { + const [dotString, setDotString] = useState(null); + const [filteredDotString, setFilteredDotString] = useState(null); + const {selectedSequence, setSelectedSequence} = useContext(Context) + const {top5Sequences, setTop5Sequences} = useContext(Context); + + + useEffect(() => { + if (csvData) { + const sortedData = loadAndSortData(csvData); + const stepSequences = createStepSequences(sortedData, selfLoops); + const outcomeSequences = createOutcomeSequences(sortedData); + + const { + edgeCounts, + totalNodeEdges, + ratioEdges, + edgeOutcomeCounts, + maxEdgeCount, + topSequences + } = countEdges(stepSequences, outcomeSequences); + console.log("No 5: " + topSequences) + if (JSON.stringify(top5Sequences) !== JSON.stringify(topSequences) || top5Sequences === null) { + // console.log(true) + console.log("BF: " + topSequences![0].sequence) + setTop5Sequences(topSequences); + + if (top5Sequences && selectedSequence === undefined) { + console.log("AR: " + top5Sequences![0].sequence) + setSelectedSequence(top5Sequences![0].sequence); + console.log(selectedSequence) + } + } + + const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); + const generatedDotStr = generateDotString( + normalizedThicknesses, + ratioEdges, + edgeOutcomeCounts, + edgeCounts, + totalNodeEdges, + 1, + minVisits, + selectedSequence + ); + + setDotString(generatedDotStr); + } + }, [csvData, selfLoops, minVisits, selectedSequence]); + + // useEffect(() => { + // if (csvData && selectedSequence) { + // const sortedData = loadAndSortData(csvData); + // const stepSequences = createStepSequences(sortedData, selfLoops); + // const outcomeSequences = createOutcomeSequences(sortedData); + // + // const { + // edgeCounts, + // totalNodeEdges, + // ratioEdges, + // edgeOutcomeCounts, + // maxEdgeCount, + // } = countEdges(stepSequences, outcomeSequences); + // + // const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); + // const updatedDotStr = generateDotString( + // normalizedThicknesses, + // ratioEdges, + // edgeOutcomeCounts, + // edgeCounts, + // totalNodeEdges, + // 1, + // minVisits, + // selectedSequence + // ); + // + // setDotString(updatedDotStr); + // console.log(dotString) + // } + // }, [selectedSequence, csvData]); + + + useEffect(() => { + if (filter) { + const sortedData = loadAndSortData(csvData); + const filteredData = sortedData.filter(row => row['CF (Workspace Progress Status)'] === filter); + const filteredStepSequences = createStepSequences(filteredData, selfLoops); + const filteredOutcomeSequences = createOutcomeSequences(filteredData); + + const { + edgeCounts: filteredEdgeCounts, + totalNodeEdges: filteredTotalNodeEdges, + ratioEdges: filteredRatioEdges, + edgeOutcomeCounts: filteredEdgeOutcomeCounts, + maxEdgeCount: filteredMaxEdgeCount, + } = countEdges(filteredStepSequences, filteredOutcomeSequences); + + const filteredNormalizedThicknesses = normalizeThicknesses(filteredEdgeCounts, filteredMaxEdgeCount, 10); + const filteredDotStr = generateDotString( + filteredNormalizedThicknesses, + filteredRatioEdges, + filteredEdgeOutcomeCounts, + filteredEdgeCounts, + filteredTotalNodeEdges, + 1, + minVisits, + selectedSequence + ); + + setFilteredDotString(filteredDotStr); + } else { + setFilteredDotString(null); + } + }, [csvData, filter, selfLoops, minVisits, selectedSequence, top5Sequences]); + + + return ( +
+ +
+ {dotString && selectedSequence && ( + + )} + {filteredDotString && selectedSequence && ( + + )} +
+
+
+ ); +}; + +export default GraphvizParent; diff --git a/src/components/GraphvizProcessing.ts b/src/components/GraphvizProcessing.ts new file mode 100644 index 0000000..dd78303 --- /dev/null +++ b/src/components/GraphvizProcessing.ts @@ -0,0 +1,312 @@ +import Papa from 'papaparse'; +import {SequenceCount} from "@/Context"; + +interface CSVRow { + 'Session Id': string; + 'Time': string; + 'Step Name': string; + 'Outcome': string; + 'CF (Workspace Progress Status)': string; +} + +// Function to load and sort data +export const loadAndSortData = (csvData: string): CSVRow[] => { + // Step 1: Parse the CSV data using PapaParse + const parsedData = Papa.parse(csvData, { + header: true, + skipEmptyLines: true + }).data; + + // Step 2: Transform data to replace missing Step Names with a default value + const transformedData = parsedData.map(row => { + return { + 'Session Id': row['Session Id'], + 'Time': row['Time'], + 'Step Name': row['Step Name'] || 'DoneButton', // Default value for missing Step Names + 'Outcome': row['Outcome'], + 'CF (Workspace Progress Status)': row['CF (Workspace Progress Status)'], + }; + }); + + // Step 3: Sort the transformed data by Session Id and Time + return transformedData.sort((a, b) => { + if (a['Session Id'] === b['Session Id']) { + return new Date(a['Time']).getTime() - new Date(b['Time']).getTime(); + } + return a['Session Id'].localeCompare(b['Session Id']); + }); +}; + +// Function to create step sequences from sorted data +export const createStepSequences = (sortedData: CSVRow[], selfLoops: boolean): { [key: string]: string[] } => { + // Iterate over sorted data to build step sequences + return sortedData.reduce((acc, row) => { + const sessionId = row['Session Id']; + if (!acc[sessionId]) { + acc[sessionId] = []; + } + const stepName = row['Step Name']; + + // Add step to sequence based on whether self-loops are allowed + if (selfLoops || acc[sessionId].length === 0 || acc[sessionId][acc[sessionId].length - 1] !== stepName) { + acc[sessionId].push(stepName); + } + + return acc; + }, {} as { [key: string]: string[] }); +}; + +// Function to create outcome sequences from sorted data +export const createOutcomeSequences = (sortedData: CSVRow[]): { [key: string]: string[] } => { + // Iterate over sorted data to build outcome sequences + return sortedData.reduce((acc, row) => { + const sessionId = row['Session Id']; + if (!acc[sessionId]) { + acc[sessionId] = []; + } + acc[sessionId].push(row['Outcome']); + return acc; + }, {} as { [key: string]: string[] }); +}; + +export function getTopSequences(stepSequences: any, topN: number = 5) { + // Create a frequency map to count how many times each unique sequence (list) occurs + const sequenceCounts: { [sequence: string]: number } = {}; + + // Iterate over the values (which are lists) of the stepSequences dictionary + Object.values(stepSequences).forEach((sequence) => { + const sequenceKey = JSON.stringify(sequence); // Convert the list to a string key + + // Count occurrences of each unique sequence + if (sequenceCounts[sequenceKey]) { + sequenceCounts[sequenceKey]++; + } else { + sequenceCounts[sequenceKey] = 1; + } + }); + + // Sort the sequences based on their counts in descending order and take the top N + const sortedSequences = Object.entries(sequenceCounts) + .sort(([, countA], [, countB]) => countB - countA) + .slice(0, topN); + + // Convert to the desired format: { sequence: [step1, step2, step3], count } + const topSequences = sortedSequences.map(([sequenceKey, count]) => ({ + sequence: JSON.parse(sequenceKey), // Convert the string back to an array + count, + })); + + console.log("Processing topSequences: " + topSequences); // Log the top sequences for debugging + return topSequences; // Return the array of top sequences +} + + +interface EdgeCounts { + edgeCounts: { [key: string]: number }; + totalNodeEdges: { [key: string]: number }; + ratioEdges: { [key: string]: number }; + edgeOutcomeCounts: { [key: string]: { [outcome: string]: number } }; +} + +// Function to count edges between steps +export const countEdges = ( + stepSequences: { [key: string]: string[] }, + outcomeSequences: { [key: string]: string[] } +): { + totalNodeEdges: { [p: string]: number }; + edgeOutcomeCounts: { [p: string]: { [p: string]: number } }; + maxEdgeCount: number; + ratioEdges: { [p: string]: number }; + edgeCounts: { [p: string]: number }; + topSequences: SequenceCount[]; +} => { + const totalNodeEdges: { [key: string]: number } = {}; + const edgeOutcomeCounts: { [key: string]: { [outcome: string]: number } } = {}; + let maxEdgeCount = 0; + const ratioEdges: { [key: string]: number } = {}; + const edgeCounts: { [key: string]: number } = {}; + + const top5Sequences = getTopSequences(stepSequences, 5) + + // Process edges for all sequences + Object.keys(stepSequences).forEach((sessionId) => { + const steps = stepSequences[sessionId]; + // console.log(steps) + const outcomes = outcomeSequences[sessionId]; + + if (steps.length < 2) return; + + for (let i = 0; i < steps.length - 1; i++) { + const currentStep = steps[i]; + const nextStep = steps[i + 1]; + const outcome = outcomes[i + 1]; + + const edgeKey = `${currentStep}->${nextStep}`; + edgeCounts[edgeKey] = (edgeCounts[edgeKey] || 0) + 1; + edgeOutcomeCounts[edgeKey] = edgeOutcomeCounts[edgeKey] || {}; + edgeOutcomeCounts[edgeKey][outcome] = (edgeOutcomeCounts[edgeKey][outcome] || 0) + 1; + totalNodeEdges[currentStep] = (totalNodeEdges[currentStep] || 0) + 1; + + // Track the maximum edge count + if (edgeCounts[edgeKey] > maxEdgeCount) { + maxEdgeCount = edgeCounts[edgeKey]; + } + } + }); + + Object.keys(edgeCounts).forEach((edge) => { + const [start] = edge.split('->'); + ratioEdges[edge] = edgeCounts[edge] / (totalNodeEdges[start] || 0); + }); + + return {edgeCounts, totalNodeEdges, ratioEdges, edgeOutcomeCounts, maxEdgeCount, topSequences: top5Sequences}; +}; + +// export function getInitialSelection(top5Sequences:SequenceCount[]){ +// return top5Sequences[0].sequence +// } +// Function to normalize edge thicknesses based on their ratio +export function normalizeThicknessesRatios( + ratioEdges: { [key: string]: number }, + maxThickness: number +): { [key: string]: number } { + const normalized: { [key: string]: number } = {}; + const maxRatio = Math.max(...Object.values(ratioEdges), 1); // Avoid division by zero + + // Scale edge thicknesses to a maximum value + Object.keys(ratioEdges).forEach((edge) => { + const ratio = ratioEdges[edge]; + normalized[edge] = (ratio / maxRatio) * maxThickness; + }); + + return normalized; +} + +// // Function to normalize edge thicknesses based the full graph +export function normalizeThicknesses( + edgeCounts: { [key: string]: number }, + maxEdgeCount: number, + maxThickness: number +): { [key: string]: number } { + const normalized: { [key: string]: number } = {}; + + Object.keys(edgeCounts).forEach((edge) => { + const count = edgeCounts[edge]; + normalized[edge] = (count / maxEdgeCount) * maxThickness; + }); + + return normalized; +} + + +// Function to calculate the color of a node based on its rank in the most common sequence +export function calculateColor(rank: number, totalSteps: number): string { + const ratio = rank / totalSteps; + + const white = {r: 255, g: 255, b: 255}; + const lightBlue = {r: 0, g: 166, b: 255}; + + const r = Math.round(white.r * (1 - ratio) + lightBlue.r * ratio); + const g = Math.round(white.g * (1 - ratio) + lightBlue.g * ratio); + const b = Math.round(white.b * (1 - ratio) + lightBlue.b * ratio); + + const toHex = (value: number) => value.toString(16).padStart(2, '0'); + const color = `#${toHex(r)}${toHex(g)}${toHex(b)}`; + + return color; +} + +// Function to calculate the color of an edge based on its outcome distribution +function calculateEdgeColors(outcomes: { [outcome: string]: number }): string { + const colorMap: { [key: string]: string } = { + 'ERROR': '#ff0000', // Red + 'OK': '#00ff00', // Green + 'INITIAL_HINT': '#0000ff', // Blue + 'HINT_LEVEL_CHANGE': '#0000ff', // Blue + 'JIT': '#ffff00', // Yellow + 'FREEBIE_JIT': '#ffff00' // Yellow + }; + + if (Object.keys(outcomes).length === 0) { + return '#00000000'; // Transparent black + } + + const totalCount = Object.values(outcomes).reduce((sum, count) => sum + count, 0); + let weightedR = 0, weightedG = 0, weightedB = 0; + + Object.entries(outcomes).forEach(([outcome, count]) => { + const color = colorMap[outcome] || '#000000'; // Default to black if outcome is not found + const [r, g, b] = [1, 3, 5].map(i => parseInt(color.slice(i, i + 2), 16)); // Extract RGB values + const weight = count / totalCount; + weightedR += r * weight; + weightedG += g * weight; + weightedB += b * weight; + }); + + // Convert RGB values to hex and add alpha transparency + return `#${Math.round(weightedR).toString(16).padStart(2, '0')}${Math.round(weightedG).toString(16).padStart(2, '0')}${Math.round(weightedB).toString(16).padStart(2, '0')}90`; +} + +// Function to generate a Graphviz DOT string for visualization +export function generateDotString( + normalizedThicknesses: { [key: string]: number }, + // mostCommonSequence: string[], + ratioEdges: { [key: string]: number }, + edgeOutcomeCounts: EdgeCounts['edgeOutcomeCounts'], + edgeCounts: EdgeCounts['edgeCounts'], + totalNodeEdges: EdgeCounts['totalNodeEdges'], + threshold: number, + min_visits: number, + selectedSequence: SequenceCount["sequence"] +): string { + if (!selectedSequence || selectedSequence.length === 0) { + return 'digraph G {\n"Error" [label="No valid sequences found to display."];\n}'; + } + console.log("TOTAL STEP NUMBER: " + selectedSequence.length) + // const stepsInSelectedSequence = selectedSequence//.split('->'); + // console.log(mostCommonSequence) + // console.log("selectedSequenceR" + stepsInSelectedSequence) + // console.log(selectedSequence[stepsInSelectedSequence]) + // Create node definitions in the DOT string + let dotString = 'digraph G {\n'; + + let totalSteps = selectedSequence.length//stepsInSelectedSequence.length; + console.log("totalSteps" + totalSteps) + for (let rank = 0; rank < totalSteps; rank++) { + const step = selectedSequence[rank]; + const color = calculateColor(rank, totalSteps); + const node_tooltip = `Rank:\n\t\t ${rank + 1}\nColor:\n\t\t ${color}`; + + dotString += ` "${step}" [rank=${rank + 1}, style=filled, fillcolor="${color}", tooltip="${node_tooltip}"];\n`; + + } + +// Create edge definitions in the DOT string based on normalized thickness and thresholds + for (const edge of Object.keys(normalizedThicknesses)) { + if (normalizedThicknesses[edge] >= threshold) { + const [currentStep, nextStep] = edge.split('->'); + const thickness = normalizedThicknesses[edge]; + const outcomes = edgeOutcomeCounts[edge] || {}; + const edgeCount = edgeCounts[edge] || 0; + const totalCount = totalNodeEdges[currentStep] || 0; + const color = calculateEdgeColors(outcomes); + const outcomesStr = Object.entries(outcomes) + .map(([outcome, count]) => `${outcome}: ${count}`) + .join('\n\t\t '); + + if (edgeCount > min_visits) { + const tooltip = `${currentStep} to ${nextStep}\n` + + `- Edge Count: \n\t\t ${edgeCount}\n` + + `- Total Count for ${currentStep}: \n\t\t${totalCount}\n` + + `- Ratio: \n\t\t${((ratioEdges[edge] || 0) * 100).toFixed(2)}% of students at ${currentStep} go to ${nextStep}\n` + + `- Outcomes: \n\t\t ${outcomesStr}\n` + + `- Color Codes: \n\t\t Hex: ${color}\n\t\t RGB: ${[parseInt(color.substring(1, 3), 16), parseInt(color.substring(3, 5), 16), parseInt(color.substring(5, 7), 16)]}`; + + dotString += ` "${currentStep}" -> "${nextStep}" [penwidth=${thickness}, color="${color}", tooltip="${tooltip}"];\n`; + } + } + } + + dotString += '}'; + return dotString; +} \ No newline at end of file diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx new file mode 100644 index 0000000..fbf48ec --- /dev/null +++ b/src/components/Loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+

Loading...

+
+
+ ) +} \ No newline at end of file diff --git a/src/components/SequenceSelector.tsx b/src/components/SequenceSelector.tsx new file mode 100644 index 0000000..5a5b815 --- /dev/null +++ b/src/components/SequenceSelector.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {SequenceCount} from "@/Context"; + +interface SequenceSelectorProps { + sequences: SequenceCount[]; + selectedSequence: string[] | undefined; + onSequenceSelect: (sequence: string[]) => void; +} + +const SequenceSelector: React.FC = ({ + sequences, + selectedSequence, + onSequenceSelect, + }) => { + + if (sequences == null) { + return
No sequences available
; // Display a message when no sequences are present + } + + // sequences.map((seq: SequenceCount) => { + // const count: number = seq.count + // const localSequence: string[] = seq.sequence + // localSequence.map((s: string) => { + // console.log(s) + // }) + // }) + return ( +
+ + +
+ ); +}; + +export default SequenceSelector; diff --git a/src/components/Upload.tsx b/src/components/Upload.tsx new file mode 100644 index 0000000..5da7667 --- /dev/null +++ b/src/components/Upload.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +interface UploadProps { + onDataProcessed: (csvData: string) => void; +} + +const Upload: React.FC = ({ onDataProcessed }) => { + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const csvData = e.target?.result as string; + onDataProcessed(csvData); + }; + reader.readAsText(file); + } + }; + + return ( +
+ +
+ ); +}; + +export default Upload; \ No newline at end of file diff --git a/src/components/errorBoundary.tsx b/src/components/errorBoundary.tsx new file mode 100644 index 0000000..908cca0 --- /dev/null +++ b/src/components/errorBoundary.tsx @@ -0,0 +1,26 @@ +import React, { Component, ReactNode } from 'react'; + +class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("Error caught by Error Boundary:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + return

Something went wrong while rendering the graph.

; + } + + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/src/components/selfLoopSwitch.tsx b/src/components/selfLoopSwitch.tsx new file mode 100644 index 0000000..7054ac1 --- /dev/null +++ b/src/components/selfLoopSwitch.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import './../Switch.css'; // Import the CSS for styling + +interface SwitchProps { + isOn: boolean; + handleToggle: () => void; +} + +const Switch: React.FC = ({ isOn, handleToggle }) => { + return ( +
+ +
+
+
+
+ ); +}; +export default Switch; \ No newline at end of file diff --git a/src/components/slider.tsx b/src/components/slider.tsx new file mode 100644 index 0000000..e673f36 --- /dev/null +++ b/src/components/slider.tsx @@ -0,0 +1,84 @@ +// import React from 'react'; +// +// interface SliderProps { +// min: number; +// max: number; +// step?: number; +// value: number; +// onChange: (value: number) => void; +// } +// +// const Slider: React.FC = ({ min, max, step = 1, value, onChange }) => { +// const handleChange = (event: React.ChangeEvent) => { +// onChange(Number(event.target.value)); +// }; +// +// return ( +//
+// +//

Minimum # of Edge Visits to Display: {value}

+//
+// ); +// }; +// +// export default Slider; + + +import React from 'react'; + +interface SliderProps { + min: number; + max: number; + step?: number; + value: number; + onChange: (value: number) => void; +} + +const Slider: React.FC = ({min, max, step = 1, value, onChange}) => { + const handleSliderChange = (event: React.ChangeEvent) => { + const newValue = Number(event.target.value); + onChange(newValue); + }; + + const handleInputChange = (event: React.ChangeEvent) => { + const newValue = Number(event.target.value); + if (newValue >= min && newValue <= max) { + onChange(newValue); + } + }; + + return ( +
+

Minimum # of Edge Visits to Display: {value}

+ + + + +
+ ); +}; + +export default Slider; diff --git a/src/lib/routes.tsx b/src/lib/routes.tsx new file mode 100644 index 0000000..e7d8432 --- /dev/null +++ b/src/lib/routes.tsx @@ -0,0 +1,13 @@ +import App from "@/App"; +import Debug from "@/components/Debug"; + +export const routes = [ + { + path: "/", + element: , + }, + { + path: "/debug", + element: + } + ] \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index 4233d0b..22e48e9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -21,7 +21,6 @@ export type DataSet1 = { "Attempt At Step": number; "Is Last Attempt": boolean | null; Outcome: "OK" | "JIT" | "ERROR" | "INITIAL_HINT" | "HINT_LEVEL_CHANGE" | "FREEBIE_JIT" - //"OK" | "BUG" | "INITIAL_HINT" | "HINT_LEVEL_CHANGE" | "ERROR"; Selection: "Done Button" | null; Action: "Attempt" | "Done" | "Hint Request" | "Hint Level Change"; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 689f0b0..4900246 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -18,11 +18,6 @@ const validation = Joi.array().items( }).unknown() ); -type ValidatorResult = { - code: string; - message: string; -} - export function parseData(readerResult: string | ArrayBuffer | null, delimiter: string = "\t"): GlobalDataType[] | null { if (!readerResult) { diff --git a/src/main.tsx b/src/main.tsx index cb22913..cc07634 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import App from './App.tsx' +// import App from './App.tsx' import './index.css' import { QueryClient, @@ -8,16 +8,20 @@ import { } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { Provider } from './Context.tsx' - +import { + createBrowserRouter, + RouterProvider, +} from "react-router-dom"; +import { routes } from './lib/routes.tsx' const queryClient = new QueryClient() +const router = createBrowserRouter(routes) + ReactDOM.createRoot(document.getElementById('root')!).render( - - - + diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..41662ee --- /dev/null +++ b/vercel.json @@ -0,0 +1,6 @@ +{ + "routes": [ + { "handle": "filesystem" }, + { "src": "/(.*)", "dest": "/index.html" } + ] + } \ No newline at end of file