From 6f79f7b1004512dd835f1c1ce8dd4c0890ebb66b Mon Sep 17 00:00:00 2001 From: Ethan Shafran Moltz Date: Thu, 27 Jun 2024 09:51:06 -0400 Subject: [PATCH 01/25] added router for routes --- bun.lockb | Bin 339201 -> 340432 bytes package.json | 3 ++- src/components/Debug.tsx | 7 +++++++ src/components/DropZone.tsx | 17 +++++++++++++++-- src/lib/utils.ts | 5 ----- src/main.tsx | 21 +++++++++++++++++---- vercel.json | 6 ++++++ 7 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 src/components/Debug.tsx create mode 100644 vercel.json diff --git a/bun.lockb b/bun.lockb index d2b09d5a00849d40a7dd6cc06d717bb97e03db35..8abce277a9c775bab4c93a882d9a073cb2a253ec 100755 GIT binary patch delta 58113 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 a8SlW~mhGAKst3`rHB#%;UhAa91^*8$1V23h delta 57140 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 + Debug Page + + ) +} \ No newline at end of file diff --git a/src/components/DropZone.tsx b/src/components/DropZone.tsx index 8e3a80c..ba71f71 100644 --- a/src/components/DropZone.tsx +++ b/src/components/DropZone.tsx @@ -10,6 +10,7 @@ interface DropZoneProps { 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"]; @@ -63,7 +64,8 @@ export default function DropZone({ afterDrop, onLoadingChange }: DropZoneProps) onLoadingChange(false); }; reader.readAsText(file); - console.log("File: ", file); + onLoadingChange(false); + // console.log("File: ", file); }); }, [fileType, afterDrop, onLoadingChange]); @@ -77,7 +79,17 @@ export default function DropZone({ afterDrop, onLoadingChange }: DropZoneProps) const { getRootProps, getInputProps, isDragActive, isFocused, isDragReject } = useDropzone({ onDrop, accept: acceptedFileTypes, - // validator: validateData + validator: (file) => { + // returns FileError | Array. | null + if (!acceptedFileTypes[file.type]) { + + return { + code: 'file-invalid-type', + message: 'Invalid file type', + } + } + return null; + } }); @@ -133,6 +145,7 @@ export default function DropZone({ afterDrop, onLoadingChange }: DropZoneProps)

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

} + {isDragReject &&

Invalid file type

}
{errorMessage &&

{errorMessage}

} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 67db809..b66e007 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -16,11 +16,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..e3bde4b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,16 +8,29 @@ 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 Debug from './components/Debug.tsx' const queryClient = new QueryClient() +const router = createBrowserRouter([ + { + path: "/", + element: , + }, + { + path: "/debug", + element: + } +]) + 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 From 366e219e38712bd13867fc9c9075193a00be0e6e Mon Sep 17 00:00:00 2001 From: Ethan Shafran Moltz Date: Thu, 27 Jun 2024 10:19:33 -0400 Subject: [PATCH 02/25] larger arrows for visibility --- src/components/DirectedGraph.tsx | 36 ++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/DirectedGraph.tsx b/src/components/DirectedGraph.tsx index dd2919f..7aa3bc0 100644 --- a/src/components/DirectedGraph.tsx +++ b/src/components/DirectedGraph.tsx @@ -12,6 +12,7 @@ import ToggleTool from "@/components/ToggleTool"; // TODO make sure the node info is changed +type TODO = any interface DirectedGraphProps { graphData: GraphData; } @@ -27,6 +28,7 @@ export default function DirectedGraph({ graphData }: DirectedGraphProps) { 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 [arrowSize, setArrowSize] = useState(30); const [contextMenu, setContextMenu] = useState(initialContextMenuControls); @@ -165,7 +167,7 @@ export default function DirectedGraph({ graphData }: DirectedGraphProps) { // 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. + }, 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); @@ -427,7 +429,37 @@ export default function DirectedGraph({ graphData }: DirectedGraphProps) { ctx.fillStyle = 'black'; ctx.fillText(label, node.x!, node.y! + labelOffsetY); }} - linkDirectionalArrowLength={20} + // 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 || ''} From 433c3f12f498688bfcb7645a3cbc3a498ac0bfcd Mon Sep 17 00:00:00 2001 From: Ethan Shafran Moltz Date: Thu, 27 Jun 2024 10:27:13 -0400 Subject: [PATCH 03/25] quick bug fixes --- src/components/DirectedGraph.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/DirectedGraph.tsx b/src/components/DirectedGraph.tsx index 7aa3bc0..f556a83 100644 --- a/src/components/DirectedGraph.tsx +++ b/src/components/DirectedGraph.tsx @@ -12,7 +12,6 @@ import ToggleTool from "@/components/ToggleTool"; // TODO make sure the node info is changed -type TODO = any interface DirectedGraphProps { graphData: GraphData; } @@ -28,7 +27,6 @@ export default function DirectedGraph({ graphData }: DirectedGraphProps) { 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 [arrowSize, setArrowSize] = useState(30); const [contextMenu, setContextMenu] = useState(initialContextMenuControls); From 282c625f158cbf7fd8d76c881e52b20ebf7b65a0 Mon Sep 17 00:00:00 2001 From: Ethan Shafran Moltz Date: Thu, 27 Jun 2024 10:29:24 -0400 Subject: [PATCH 04/25] added routes to separate file for organization purposes --- src/lib/routes.tsx | 13 +++++++++++++ src/main.tsx | 13 ++----------- 2 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 src/lib/routes.tsx 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/main.tsx b/src/main.tsx index e3bde4b..f19f1e7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,19 +12,10 @@ import { createBrowserRouter, RouterProvider, } from "react-router-dom"; -import Debug from './components/Debug.tsx' +import { routes } from './lib/routes.tsx' const queryClient = new QueryClient() -const router = createBrowserRouter([ - { - path: "/", - element: , - }, - { - path: "/debug", - element: - } -]) +const router = createBrowserRouter(routes) ReactDOM.createRoot(document.getElementById('root')!).render( From 2acb5956f700c0824650957491ecf5ac3178d5e8 Mon Sep 17 00:00:00 2001 From: Ethan Shafran Moltz Date: Thu, 27 Jun 2024 10:53:10 -0400 Subject: [PATCH 05/25] fixed error where .tsv files were not accepted --- bun.lockb | Bin 340432 -> 340432 bytes src/App.tsx | 8 ++------ src/components/Debug.tsx | 21 ++++++++++++++++++--- src/components/DropZone.tsx | 8 +++++--- src/components/Loading.tsx | 9 +++++++++ 5 files changed, 34 insertions(+), 12 deletions(-) create mode 100644 src/components/Loading.tsx diff --git a/bun.lockb b/bun.lockb index 8abce277a9c775bab4c93a882d9a073cb2a253ec..8516c27b0429b3b3fd7f7609d5ca4bcc8bb79de6 100755 GIT binary patch delta 34 qcmcccSmeTEk%kt=7N#xC<&_+aab|i(CVB?#^_9%q>nm9TcK`tFjSP+e delta 34 lcmcccSmeTEk%kt=7N#xC<&_*v3=q&>U&*|^zLF(y2LRT$3kd)K diff --git a/src/App.tsx b/src/App.tsx index 4831e4a..01f3827 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import DropZone from './components/DropZone'; import { Button } from './components/ui/button'; import { Context } from './Context'; import { processDataShopData } from './lib/dataProcessingUtils'; +import Loading from './components/Loading'; function App() { @@ -46,14 +47,9 @@ function App() {
- { loading ? -
-
-

Loading...

-
-
+ : ( showDropZone && ( diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index ddd96bc..715247f 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -1,7 +1,22 @@ -export default function Debug(){ - return( +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 ( <> - Debug Page + + ) } \ No newline at end of file diff --git a/src/components/DropZone.tsx b/src/components/DropZone.tsx index ba71f71..a1e9e2f 100644 --- a/src/components/DropZone.tsx +++ b/src/components/DropZone.tsx @@ -44,7 +44,7 @@ export default function DropZone({ afterDrop, onLoadingChange }: DropZoneProps) break; } const array: GlobalDataType[] | null = parseData(textStr, delimiter); - console.log("Array: ", array); + console.log("Array from file: ", array); // array is null when there is an error in the file structure or content if (!array) { @@ -71,8 +71,10 @@ export default function DropZone({ afterDrop, onLoadingChange }: DropZoneProps) }, [fileType, afterDrop, onLoadingChange]); const acceptedFileTypes: Accept = { - 'text/plain': ['.txt', '.csv', '.tsv', '.json', '.tsv', '.pipe'], - } + 'text/tab-separated-values': ['.tsv'], + 'text/csv': ['.csv'], + 'text/plain': ['.txt', '.csv', '.tsv', '.json', '.pipe'] + }; 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 From fb7ed07887331cbce2fc71368584b6a2dc362a7e Mon Sep 17 00:00:00 2001 From: Ethan Shafran Moltz Date: Wed, 14 Aug 2024 14:20:56 -0400 Subject: [PATCH 06/25] graph viz prep for Tali --- bun.lockb | Bin 340432 -> 345752 bytes package.json | 1 + src/App.tsx | 118 +++++++++++++++--------------- src/components/GraphvizParent.tsx | 33 +++++++++ src/components/errorBoundary.tsx | 26 +++++++ 5 files changed, 119 insertions(+), 59 deletions(-) create mode 100644 src/components/GraphvizParent.tsx create mode 100644 src/components/errorBoundary.tsx diff --git a/bun.lockb b/bun.lockb index 8516c27b0429b3b3fd7f7609d5ca4bcc8bb79de6..e954d748b4f1e2cae4d1e89cf55db935abd0806c 100755 GIT binary patch delta 55783 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𝔖71vjh$C*=M{47 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 + const {resetData, setGraphData, setLoading, data, setData, graphData, loading} = useContext(Context) + const [showDropZone, setShowDropZone] = useState(true) - const handleData = (data: GlobalDataType[]) => { - setData(data) - setShowDropZone(false) - } + const handleData = (data: GlobalDataType[]) => { + setData(data) + setShowDropZone(false) + } - const handleLoading = (loading: boolean) => { - setLoading(loading) - } + const handleLoading = (loading: boolean) => { + setLoading(loading) + } - useEffect(() => { - if (data) { - const graphData: GraphData = processDataShopData(data) - setGraphData(graphData) + useEffect(() => { + if (data) { + const graphData: GraphData = processDataShopData(data) + setGraphData(graphData) - } - }, [data]) + } + }, [data]) - return ( - <> -
- {/* */} - + return ( + <> +
+ {/* */} + -
- { - loading ? - - : - ( - showDropZone && ( -
- -
- ) +
+ { + loading ? + + : + ( + showDropZone && ( +
+ +
+ ) - ) + ) - } + } - { - graphData && ( - <> - {/* Add suspense, lazy? */} - - - ) - } + { + graphData && ( + <> + {/* TODO: Swap DirectedGraph for your new component */} + + + ) + } -
-
- - ) +
+
+ + ) } export default App diff --git a/src/components/GraphvizParent.tsx b/src/components/GraphvizParent.tsx new file mode 100644 index 0000000..3a044f2 --- /dev/null +++ b/src/components/GraphvizParent.tsx @@ -0,0 +1,33 @@ +import {useState} from "react"; +import ErrorBoundary from "@/components/errorBoundary.tsx"; +import Graphviz from 'graphviz-react'; +interface GraphvizParentProps { + csvData: string +} + +export default function GraphvizParent({csvData}: GraphvizParentProps) { + const [dotString, setDotString] = useState(''); + const [filteredDotString, setFilteredDotString] = useState(''); + + const [filter, setFilter] = useState(''); // State for the selected filter + const [csvData, setCsvData] = useState(''); // State to store raw CSV data + const [selfLoops, setSelfLoops] = useState(true) + + + // logic here + + + return ( + <> + +
+ + {dotString && } + + {filteredDotString && + } +
+
+ + ) +} \ 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 From 469a1d060ef04657bc5326f95c158178f145352a Mon Sep 17 00:00:00 2001 From: talizacks <55198700+talizacks@users.noreply.github.com> Date: Thu, 15 Aug 2024 10:51:39 -0500 Subject: [PATCH 07/25] Graphviz implementation in PAT! --- src/App.tsx | 107 ++++-------- src/Switch.css | 47 ++++++ src/components/FilterComponent.tsx | 24 +++ src/components/GraphvizParent.tsx | 112 +++++++++++-- src/components/Upload.tsx | 27 ++++ src/components/graphvizProcessing.ts | 233 +++++++++++++++++++++++++++ src/components/selfLoopSwitch.tsx | 19 +++ src/components/slider.tsx | 32 ++++ src/lib/types.ts | 3 +- 9 files changed, 515 insertions(+), 89 deletions(-) create mode 100644 src/Switch.css create mode 100644 src/components/FilterComponent.tsx create mode 100644 src/components/Upload.tsx create mode 100644 src/components/graphvizProcessing.ts create mode 100644 src/components/selfLoopSwitch.tsx create mode 100644 src/components/slider.tsx diff --git a/src/App.tsx b/src/App.tsx index 6b2959f..cc768c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,81 +1,34 @@ 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 { NavBar } from './components/NavBar'; -import {Button} from './components/ui/button'; -import {Context} from './Context'; -import {processDataShopData} from './lib/dataProcessingUtils'; -import Loading from './components/Loading'; - -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) - } - +import React, {useEffect, useState} from 'react'; +import Upload from "@/components/Upload.tsx"; +import GraphvizParent from "@/components/GraphvizParent.tsx"; +// import GraphContainer from './components/GraphContainer'; +import FilterComponent from './components/FilterComponent.tsx'; +import SelfLoopSwitch from './components/selfLoopSwitch.tsx'; +import Slider from './components/slider.tsx'; + +const App: React.FC = () => { + const [csvData, setCsvData] = useState(''); + const [filter, setFilter] = useState(''); + const [selfLoops, setSelfLoops] = useState(true); + const [minVisits, setMinVisits] = useState(30); + + const handleToggle = () => setSelfLoops(!selfLoops); + const handleSlider = (value: number) => setMinVisits(value); + const handleDataProcessed = (uploadedCsvData: string) => setCsvData(uploadedCsvData); useEffect(() => { - if (data) { - const graphData: GraphData = processDataShopData(data) - setGraphData(graphData) - - } - }, [data]) + }, []); return ( - <> -
- {/* */} - - -
- { - loading ? - - : - ( - showDropZone && ( -
- -
- ) - - ) - - } - - - { - graphData && ( - <> - {/* TODO: Swap DirectedGraph for your new component */} - - - ) - } - -
-
- - ) -} - -export default App +
+

Path Analysis Tool

+ + + + + +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/src/Switch.css b/src/Switch.css new file mode 100644 index 0000000..10e312c --- /dev/null +++ b/src/Switch.css @@ -0,0 +1,47 @@ +/* src/Switch.css */ +.switch-container { + display: inline-block; + cursor: pointer; +} + +.switch { + width: 50px; + height: 25px; + border-radius: 25px; + background-color: grey; + position: relative; + left: 0px; + top: 0px; + transition: background-color 0.2s; +} + +.true { + background-color: #80d580; +} + +.false { + background-color: #b20da7; +} + +.switch-handle { + width: 23px; + height: 23px; + border-radius: 50%; + background-color: white; + position: relative; + top: 1px; + left: 1px; + transition: left 0.1s; +} + +.true .switch-handle { + left: 26px; +} + +.switch-display{ /*Not working*/ + display: none; +} + +.switch.disabled{ /*Not working*/ + visibility: hidden; +} 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 index 3a044f2..4be6159 100644 --- a/src/components/GraphvizParent.tsx +++ b/src/components/GraphvizParent.tsx @@ -1,33 +1,123 @@ -import {useState} from "react"; +import {useEffect, useState} from "react"; import ErrorBoundary from "@/components/errorBoundary.tsx"; import Graphviz from 'graphviz-react'; +import { + loadAndSortData, + createStepSequences, + createOutcomeSequences, + countEdges, + normalizeThicknesses, + generateDotString +} from './graphvizProcessing'; + interface GraphvizParentProps { - csvData: string + csvData: string; + filter: string; + selfLoops: boolean; + minVisits: number; } -export default function GraphvizParent({csvData}: GraphvizParentProps) { +const GraphvizParent: React.FC = ({ csvData, filter, selfLoops, minVisits }) => { const [dotString, setDotString] = useState(''); const [filteredDotString, setFilteredDotString] = useState(''); - const [filter, setFilter] = useState(''); // State for the selected filter - const [csvData, setCsvData] = useState(''); // State to store raw CSV data - const [selfLoops, setSelfLoops] = useState(true) + // Process the CSV data initially and when filter changes + useEffect(() => { + if (!csvData) return; // Skip if no CSV data is available + + const sortedData = loadAndSortData(csvData); + + // Generate the unfiltered graph + const stepSequences = createStepSequences(sortedData, selfLoops); + console.log(stepSequences) + const outcomeSequences = createOutcomeSequences(sortedData); + + const {edgeCounts, totalNodeEdges, ratioEdges, edgeOutcomeCounts} = countEdges(stepSequences, outcomeSequences); + + const normalizedThicknesses = normalizeThicknesses(ratioEdges, 10); + + const mostCommonSequenceKey = Object.keys(stepSequences) + .reduce((a, b) => stepSequences[a].length > stepSequences[b].length ? a : b); + + const mostCommonSequence = stepSequences[mostCommonSequenceKey]; + const dotStr = generateDotString( + normalizedThicknesses, + mostCommonSequence, + ratioEdges, + edgeOutcomeCounts, + edgeCounts, + totalNodeEdges, + 1, + minVisits + ); + setDotString(dotStr); + console.log(dotString) + // Generate the filtered graph if a filter is set + if (filter) { + //TODO: Add qualifier to show difference between two graphs (maybe border color to show if + // something increased or decreased) + //TODO: Make order/ranking same in both graphs + const filteredData = sortedData.filter(row => row['CF (Workspace Progress Status)'] === filter); + console.log(filteredData); + + const filteredStepSequences = createStepSequences(filteredData, selfLoops); + console.log(filteredStepSequences) + const filteredOutcomeSequences = createOutcomeSequences(filteredData); + + const { + edgeCounts: filteredEdgeCounts, totalNodeEdges: filteredTotalNodeEdges, + ratioEdges: filteredRatioEdges, edgeOutcomeCounts: filteredEdgeOutcomeCounts + } + = countEdges(filteredStepSequences, filteredOutcomeSequences); + + const filteredNormalizedThicknesses = normalizeThicknesses(filteredRatioEdges, 10); + + const filteredMostCommonSequenceKey = Object.keys(filteredStepSequences) + .reduce((a, b) => filteredStepSequences[a].length > filteredStepSequences[b].length ? a : b); + + const filteredMostCommonSequence = filteredStepSequences[filteredMostCommonSequenceKey]; + const filteredDotStr = generateDotString( + filteredNormalizedThicknesses, + filteredMostCommonSequence, + filteredRatioEdges, + filteredEdgeOutcomeCounts, + filteredEdgeCounts, + filteredTotalNodeEdges, + 1, + minVisits + ); + let edge: string; + for (edge in edgeCounts) { + if (edgeCounts[edge] != filteredEdgeCounts[edge]) { + console.log(edge, filteredEdgeCounts[edge] - edgeCounts[edge]) + if (isNaN(filteredEdgeCounts[edge] - edgeCounts[edge])) { + console.log("NaN: " + edge, filteredEdgeCounts[edge], edgeCounts[edge]) + } + } + } + // console.log(filteredDotStr) + setFilteredDotString(filteredDotStr); + console.log(filteredDotString) + } else { + setFilteredDotString(null); // Clear filtered graph if no filter is set + } + + }, [csvData, filter, selfLoops, minVisits]); - // logic here return ( - <> +
- {dotString && } {filteredDotString && }
- +
) -} \ No newline at end of file +} +export default GraphvizParent \ No newline at end of file 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/graphvizProcessing.ts b/src/components/graphvizProcessing.ts new file mode 100644 index 0000000..14381bd --- /dev/null +++ b/src/components/graphvizProcessing.ts @@ -0,0 +1,233 @@ +import Papa from 'papaparse'; + +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[] => { + const parsedData = Papa.parse(csvData, { + header: true, + skipEmptyLines: true + }).data; + + // Step 2: Transform data to replace NaN values + const transformedData = parsedData.map(row => { + return { + 'Session Id': row['Session Id'], + 'Time': row['Time'], + 'Step Name': row['Step Name'] || 'DoneButton', //TODO: Nan only shows up on selection "Done Button" + 'Outcome': row['Outcome'], + 'CF (Workspace Progress Status)': row['CF (Workspace Progress Status)'], + }; + }); + + + 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']); + }); +}; + + +export const createStepSequences = (sortedData: CSVRow[], selfLoops:boolean): { [key: string]: string[] } => { + return sortedData.reduce((acc, row) => { + const sessionId = row['Session Id']; + if (!acc[sessionId]) { + acc[sessionId] = []; + } + const stepName = row['Step Name']; + + if (!selfLoops) { + if (!acc[sessionId].includes(stepName)) { + acc[sessionId].push(stepName); + } + } else { + acc[sessionId].push(stepName); + } + + // console.log(sessionId, acc[sessionId]) + return acc; + }, {} as { [key: string]: string[] }); +}; + +// Function to create outcome sequences +export const createOutcomeSequences = (sortedData: CSVRow[]): { [key: string]: string[] } => { + // console.log(sortedData) + 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[] }); +}; + +interface EdgeCounts { + edgeCounts: { [key: string]: number }; + totalNodeEdges: { [key: string]: number }; + ratioEdges: { [key: string]: number }; + edgeOutcomeCounts: { [key: string]: { [outcome: string]: number } }; +} + +// Function to count edges +export const countEdges = ( + stepSequences: { [key: string]: string[] }, + outcomeSequences: { [key: string]: string[] } +): EdgeCounts => { + const edgeCounts: { [key: string]: number } = {}; + const totalNodeEdges: { [key: string]: number } = {}; + const ratioEdges: { [key: string]: number } = {}; + const edgeOutcomeCounts: { [key: string]: { [outcome: string]: number } } = {}; + + Object.keys(stepSequences).forEach((sessionId) => { + const steps = stepSequences[sessionId]; + 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; + } + }); + + Object.keys(edgeCounts).forEach((edge) => { + const [start] = edge.split('->'); + ratioEdges[edge] = edgeCounts[edge] / (totalNodeEdges[start] || 0); + }); + // console.log("edgeOutcomeCounts: ", edgeOutcomeCounts, "\nratioEdges: ", ratioEdges ) + return { edgeCounts, totalNodeEdges, ratioEdges, edgeOutcomeCounts }; +}; + +export function normalizeThicknesses( + 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 + + Object.keys(ratioEdges).forEach((edge) => { + const ratio = ratioEdges[edge]; + + + normalized[edge] = (ratio / maxRatio) * maxThickness; + }); + + return normalized; +} + +function calculateColor(rank: number, totalSteps: number): string { + // Calculate the ratio between 0 and 1 + const ratio = rank / totalSteps; + + const white = { r: 255, g: 255, b: 255 }; // White color + const lightBlue = { r: 0, g: 166, b: 255 }; // Light Blue color + + // Interpolate between white and light blue + 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); + + // Convert RGB to hexadecimal + const toHex = (value: number) => value.toString(16).padStart(2, '0'); + const color = `#${toHex(r)}${toHex(g)}${toHex(b)}`; + + + // console.log("Color: ", r, g, b, toHex(r), toHex(g), toHex(b), color); + + return color; +} + +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`; +} + +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, +): string { + let dotString = 'digraph G {\n'; + const totalSteps = mostCommonSequence.length; + // console.log(mostCommonSequence, totalSteps) + for (let rank = 0; rank < totalSteps; rank++) { + const step = mostCommonSequence[rank]; + const color = calculateColor(rank + 1, 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`; + } + + for (const edge of Object.keys(normalizedThicknesses)) { + if (normalizedThicknesses[edge] >= threshold) { + const [currentStep, nextStep] = edge.split('->');// as EdgeKey; + 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; +} 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..7ece586 --- /dev/null +++ b/src/components/slider.tsx @@ -0,0 +1,32 @@ +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 for Visualization: {value}

+
+ ); +}; + +export default Slider; diff --git a/src/lib/types.ts b/src/lib/types.ts index a2116c7..22e48e9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -20,7 +20,8 @@ export type DataSet1 = { "Step Name": string | null; "Attempt At Step": number; "Is Last Attempt": boolean | null; - Outcome: "OK" | "BUG" | "INITIAL_HINT" | "HINT_LEVEL_CHANGE" | "ERROR"; + Outcome: "OK" | "JIT" | "ERROR" | "INITIAL_HINT" | "HINT_LEVEL_CHANGE" | "FREEBIE_JIT" + Selection: "Done Button" | null; Action: "Attempt" | "Done" | "Hint Request" | "Hint Level Change"; Input: string | null; From c2b471f20c84c6e66b7c42c93b86ec7c3bd3b54e Mon Sep 17 00:00:00 2001 From: talizacks <55198700+talizacks@users.noreply.github.com> Date: Thu, 15 Aug 2024 10:52:01 -0500 Subject: [PATCH 08/25] Graphviz implementation in PAT! --- src/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index cc768c2..2eb3d1f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,6 @@ import './App.css'; import React, {useEffect, useState} from 'react'; import Upload from "@/components/Upload.tsx"; import GraphvizParent from "@/components/GraphvizParent.tsx"; -// import GraphContainer from './components/GraphContainer'; import FilterComponent from './components/FilterComponent.tsx'; import SelfLoopSwitch from './components/selfLoopSwitch.tsx'; import Slider from './components/slider.tsx'; From 016751fe4d816cb77a411dacc676ffa9ae7bf761 Mon Sep 17 00:00:00 2001 From: talizacks <55198700+talizacks@users.noreply.github.com> Date: Wed, 21 Aug 2024 15:40:10 -0500 Subject: [PATCH 09/25] Added dropdown to pick sequence to color by --- src/App.tsx | 29 ++- src/GraphvizContainer.css | 21 +++ src/components/GraphvizParent.tsx | 159 ++++++++++------ ...vizProcessing.ts => GraphvizProcessing.ts} | 177 +++++++++++++----- src/components/SequenceSelector.tsx | 31 +++ src/components/slider.tsx | 100 +++++++--- 6 files changed, 377 insertions(+), 140 deletions(-) create mode 100644 src/GraphvizContainer.css rename src/components/{graphvizProcessing.ts => GraphvizProcessing.ts} (52%) create mode 100644 src/components/SequenceSelector.tsx diff --git a/src/App.tsx b/src/App.tsx index 2eb3d1f..881c7c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,31 +1,42 @@ import './App.css'; -import React, {useEffect, useState} from 'react'; +import React, {useCallback, 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"; const App: React.FC = () => { const [csvData, setCsvData] = useState(''); const [filter, setFilter] = useState(''); const [selfLoops, setSelfLoops] = useState(true); const [minVisits, setMinVisits] = useState(30); - + const [topSequences, setTopSequences] = useState([]); + const [selectedSequence, setSelectedSequence] = useState(topSequences[0]); + const handleSelectSequence = (sequence: string) => { + setSelectedSequence(selectedSequence); + // Add your logic here to update the node coloring based on the selected sequence + console.log(`Selected sequence: ${sequence}`); + }; const handleToggle = () => setSelfLoops(!selfLoops); const handleSlider = (value: number) => setMinVisits(value); const handleDataProcessed = (uploadedCsvData: string) => setCsvData(uploadedCsvData); - useEffect(() => { + const handleTopSequencesUpdate = useCallback((sequences: string[]) => { + setTopSequences(sequences); + console.log("AHHHH: "+topSequences) + }, [topSequences]) - }, []); return (

Path Analysis Tool

- - - - - + + + + + +
); }; diff --git a/src/GraphvizContainer.css b/src/GraphvizContainer.css new file mode 100644 index 0000000..a3ea593 --- /dev/null +++ b/src/GraphvizContainer.css @@ -0,0 +1,21 @@ +.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 */ +} + +.graphs { + display: flex; + flex-direction: row; /* Ensure the graphs are aligned in a row */ + justify-content: space-around; + align-items: center; + gap: 20px; + width: 100%; /* Ensure the graphs container takes full width */ +} + +.graphs > div { + flex: 1; /* Allow the graphs to scale according to available space */ + max-width: 600px; /* Set a max-width for each graph */ +} diff --git a/src/components/GraphvizParent.tsx b/src/components/GraphvizParent.tsx index 4be6159..7bd9734 100644 --- a/src/components/GraphvizParent.tsx +++ b/src/components/GraphvizParent.tsx @@ -7,117 +7,156 @@ import { createOutcomeSequences, countEdges, normalizeThicknesses, - generateDotString -} from './graphvizProcessing'; + generateDotString, getTopSequences, +} from './GraphvizProcessing'; +import '../GraphvizContainer.css'; +import SequenceSelector from './SequenceSelector'; interface GraphvizParentProps { csvData: string; filter: string; selfLoops: boolean; minVisits: number; + onTopSequencesUpdate: (sequences: string[]) => void; + // selectedSequence: string; } -const GraphvizParent: React.FC = ({ csvData, filter, selfLoops, minVisits }) => { +const GraphvizParent: React.FC = ({ + csvData, + filter, + selfLoops, + minVisits, + onTopSequencesUpdate, + // selectedSequence, + }) => { const [dotString, setDotString] = useState(''); const [filteredDotString, setFilteredDotString] = useState(''); + const [topSequences, setTopSequences] = useState([]); + const [selectedSequence, setSelectedSequence] = useState(''); - - // Process the CSV data initially and when filter changes useEffect(() => { - if (!csvData) return; // Skip if no CSV data is available + if (!csvData) return; // Skip processing if no CSV data is available + // Step 1: Load and sort the data const sortedData = loadAndSortData(csvData); - // Generate the unfiltered graph + // Step 2: Generate the sequences for steps and outcomes const stepSequences = createStepSequences(sortedData, selfLoops); - console.log(stepSequences) const outcomeSequences = createOutcomeSequences(sortedData); - const {edgeCounts, totalNodeEdges, ratioEdges, edgeOutcomeCounts} = countEdges(stepSequences, outcomeSequences); - - const normalizedThicknesses = normalizeThicknesses(ratioEdges, 10); - - const mostCommonSequenceKey = Object.keys(stepSequences) - .reduce((a, b) => stepSequences[a].length > stepSequences[b].length ? a : b); - - const mostCommonSequence = stepSequences[mostCommonSequenceKey]; + // Step 3: Count edges and normalize thicknesses + const { + edgeCounts, + totalNodeEdges, + ratioEdges, + edgeOutcomeCounts, + maxEdgeCount, + top5Sequences + } = countEdges(stepSequences, outcomeSequences); + setTopSequences(top5Sequences) + setSelectedSequence(top5Sequences[0]) + const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); + + // Step 4: Find the most common sequences + // const mostCommonSequenceKey = Object.keys(stepSequences) + // .reduce((a, b) => stepSequences[a].length > stepSequences[b].length ? a : b); + // const mostCommonSequence = stepSequences[mostCommonSequenceKey]; + // const topSequences = Object.keys(stepSequences) + // .sort((a, b) => stepSequences[b].length - stepSequences[a].length) + // .slice(0, 5); + // console.log(stepSequences) + // const top5Sequences = getTopSequences(stepSequences,5) + onTopSequencesUpdate(top5Sequences); + + setTopSequences(top5Sequences) + console.log("GVP67:" + topSequences) + // Call the update function to pass top sequences to App component + // Step 5: Generate the DOT string for the unfiltered graph const dotStr = generateDotString( normalizedThicknesses, - mostCommonSequence, + // mostCommonSequence, ratioEdges, edgeOutcomeCounts, edgeCounts, totalNodeEdges, 1, - minVisits + minVisits, + selectedSequence ); setDotString(dotStr); - console.log(dotString) - // Generate the filtered graph if a filter is set + + // Step 6: Generate the filtered graph if a filter is provided if (filter) { - //TODO: Add qualifier to show difference between two graphs (maybe border color to show if - // something increased or decreased) - //TODO: Make order/ranking same in both graphs const filteredData = sortedData.filter(row => row['CF (Workspace Progress Status)'] === filter); - console.log(filteredData); - const filteredStepSequences = createStepSequences(filteredData, selfLoops); - console.log(filteredStepSequences) const filteredOutcomeSequences = createOutcomeSequences(filteredData); const { - edgeCounts: filteredEdgeCounts, totalNodeEdges: filteredTotalNodeEdges, - ratioEdges: filteredRatioEdges, edgeOutcomeCounts: filteredEdgeOutcomeCounts - } - = countEdges(filteredStepSequences, filteredOutcomeSequences); - - const filteredNormalizedThicknesses = normalizeThicknesses(filteredRatioEdges, 10); - - const filteredMostCommonSequenceKey = Object.keys(filteredStepSequences) - .reduce((a, b) => filteredStepSequences[a].length > filteredStepSequences[b].length ? a : b); - - const filteredMostCommonSequence = filteredStepSequences[filteredMostCommonSequenceKey]; + edgeCounts: filteredEdgeCounts, + totalNodeEdges: filteredTotalNodeEdges, + ratioEdges: filteredRatioEdges, + edgeOutcomeCounts: filteredEdgeOutcomeCounts, + maxEdgeCount: filteredMaxEdgeCount, + top5Sequences, + } = countEdges(filteredStepSequences, filteredOutcomeSequences); + + const filteredNormalizedThicknesses = normalizeThicknesses(filteredEdgeCounts, filteredMaxEdgeCount, 10); + // const filteredMostCommonSequenceKey = Object.keys(filteredStepSequences) + // .reduce((a, b) => filteredStepSequences[a].length > filteredStepSequences[b].length ? a : b); + // const filteredMostCommonSequence = filteredStepSequences[filteredMostCommonSequenceKey]; + + // Generate the DOT string for the filtered graph const filteredDotStr = generateDotString( filteredNormalizedThicknesses, - filteredMostCommonSequence, + // filteredMostCommonSequence, filteredRatioEdges, filteredEdgeOutcomeCounts, filteredEdgeCounts, filteredTotalNodeEdges, 1, - minVisits + minVisits, + selectedSequence ); - let edge: string; - for (edge in edgeCounts) { - if (edgeCounts[edge] != filteredEdgeCounts[edge]) { - console.log(edge, filteredEdgeCounts[edge] - edgeCounts[edge]) - if (isNaN(filteredEdgeCounts[edge] - edgeCounts[edge])) { - console.log("NaN: " + edge, filteredEdgeCounts[edge], edgeCounts[edge]) - } - } - } - // console.log(filteredDotStr) + setFilteredDotString(filteredDotStr); - console.log(filteredDotString) } else { setFilteredDotString(null); // Clear filtered graph if no filter is set } - }, [csvData, filter, selfLoops, minVisits]); - + }, [csvData, filter, selfLoops, minVisits, selectedSequence]); + const handleSequenceSelect = (sequence: string) => { + setSelectedSequence(sequence); + }; return ( -
+
+ {/**/} -
- {dotString && } - - {filteredDotString && - } +
+ +
+ {dotString && ( + + )} + {filteredDotString && ( + + )} +
- ) + ); } -export default GraphvizParent \ No newline at end of file + +export default GraphvizParent; diff --git a/src/components/graphvizProcessing.ts b/src/components/GraphvizProcessing.ts similarity index 52% rename from src/components/graphvizProcessing.ts rename to src/components/GraphvizProcessing.ts index 14381bd..d1d5200 100644 --- a/src/components/graphvizProcessing.ts +++ b/src/components/GraphvizProcessing.ts @@ -1,4 +1,5 @@ import Papa from 'papaparse'; +import {get} from "lodash"; interface CSVRow { 'Session Id': string; @@ -6,28 +7,28 @@ interface CSVRow { 'Step Name': string; 'Outcome': string; 'CF (Workspace Progress Status)': string; - } // Function to load and sort data export const loadAndSortData = (csvData: string): CSVRow[] => { - const parsedData = Papa.parse(csvData, { + // Step 1: Parse the CSV data using PapaParse + const parsedData = Papa.parse(csvData, { header: true, skipEmptyLines: true }).data; - // Step 2: Transform data to replace NaN values + // 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', //TODO: Nan only shows up on selection "Done Button" + '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(); @@ -36,8 +37,9 @@ export const loadAndSortData = (csvData: string): CSVRow[] => { }); }; - -export const createStepSequences = (sortedData: CSVRow[], selfLoops:boolean): { [key: string]: string[] } => { +// 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]) { @@ -45,22 +47,18 @@ export const createStepSequences = (sortedData: CSVRow[], selfLoops:boolean): { } const stepName = row['Step Name']; - if (!selfLoops) { - if (!acc[sessionId].includes(stepName)) { - acc[sessionId].push(stepName); - } - } else { + // 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); } - // console.log(sessionId, acc[sessionId]) return acc; }, {} as { [key: string]: string[] }); }; -// Function to create outcome sequences +// Function to create outcome sequences from sorted data export const createOutcomeSequences = (sortedData: CSVRow[]): { [key: string]: string[] } => { - // console.log(sortedData) + // Iterate over sorted data to build outcome sequences return sortedData.reduce((acc, row) => { const sessionId = row['Session Id']; if (!acc[sessionId]) { @@ -71,6 +69,49 @@ export const createOutcomeSequences = (sortedData: CSVRow[]): { [key: string]: s }, {} as { [key: string]: string[] }); }; +// export function getTopSequences(stepSequences: any, topN: number = 5) { +// // const topSequences = Object.values(stepSequences) +// // .sort((a, b) => stepSequences[b].length - stepSequences[a].length) +// // .slice(0, topN); +// +// const sequenceCounts = Object.entries(stepSequences).reduce((acc: any, [key, value]) => { +// acc[key] = value.count; +// return acc; +// }, {}); +// console.log("counts: " + sequenceCounts[stepSequences[0]]) +// const sortedSequences = Object.entries(sequenceCounts) +// .sort(([, a], [, b]) => b - a) +// .slice(0, topN); +// console.log("sorted sequences: " + sequenceCounts[sortedSequences[0]]) +// // return sortedSequences.map(([sequence]) => sequence); +// return topSequences +// } +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((sequenceList: string[]) => { + const sequenceKey = JSON.stringify(sequenceList); // Convert the list to a string key + + 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); + + // Instead of returning the actual sequences, return "Count 1" through "Count 5" + // const topSequences = sortedSequences.map((sequence, index) => `Count ${index + 1}`); + const topSequences = sortedSequences.map(([sequenceKey, index]) => JSON.parse(sequenceKey, 'Count ${index + 1}')); + return topSequences; +} + interface EdgeCounts { edgeCounts: { [key: string]: number }; totalNodeEdges: { [key: string]: number }; @@ -78,18 +119,30 @@ interface EdgeCounts { edgeOutcomeCounts: { [key: string]: { [outcome: string]: number } }; } -// Function to count edges +// Function to count edges between steps export const countEdges = ( stepSequences: { [key: string]: string[] }, outcomeSequences: { [key: string]: string[] } -): EdgeCounts => { +): { + totalNodeEdges: { [p: string]: number }; + edgeOutcomeCounts: { [p: string]: { [p: string]: number } }; + maxEdgeCount: number; + ratioEdges: { [p: string]: number }; + edgeCounts: { [p: string]: number }; + top5Sequences: string[]; +} => { const edgeCounts: { [key: string]: number } = {}; const totalNodeEdges: { [key: string]: number } = {}; const ratioEdges: { [key: string]: number } = {}; const edgeOutcomeCounts: { [key: string]: { [outcome: string]: number } } = {}; + let maxEdgeCount = 0; + + 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; @@ -104,6 +157,11 @@ export const countEdges = ( 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]; + } } }); @@ -111,49 +169,63 @@ export const countEdges = ( const [start] = edge.split('->'); ratioEdges[edge] = edgeCounts[edge] / (totalNodeEdges[start] || 0); }); - // console.log("edgeOutcomeCounts: ", edgeOutcomeCounts, "\nratioEdges: ", ratioEdges ) - return { edgeCounts, totalNodeEdges, ratioEdges, edgeOutcomeCounts }; + + return {edgeCounts, totalNodeEdges, ratioEdges, edgeOutcomeCounts, maxEdgeCount, top5Sequences}; }; + +// // Function to normalize edge thicknesses based on their ratio +// export function normalizeThicknesses( +// 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( - ratioEdges: { [key: string]: number }, + edgeCounts: { [key: string]: number }, + maxEdgeCount: number, maxThickness: number ): { [key: string]: number } { const normalized: { [key: string]: number } = {}; - const maxRatio = Math.max(...Object.values(ratioEdges), 1); // Avoid division by zero - Object.keys(ratioEdges).forEach((edge) => { - const ratio = ratioEdges[edge]; - - - normalized[edge] = (ratio / maxRatio) * maxThickness; + 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 function calculateColor(rank: number, totalSteps: number): string { - // Calculate the ratio between 0 and 1 const ratio = rank / totalSteps; - const white = { r: 255, g: 255, b: 255 }; // White color - const lightBlue = { r: 0, g: 166, b: 255 }; // Light Blue color + const white = {r: 255, g: 255, b: 255}; + const lightBlue = {r: 0, g: 166, b: 255}; - // Interpolate between white and light blue 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); - // Convert RGB to hexadecimal const toHex = (value: number) => value.toString(16).padStart(2, '0'); const color = `#${toHex(r)}${toHex(g)}${toHex(b)}`; - - // console.log("Color: ", r, g, b, toHex(r), toHex(g), toHex(b), color); - 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 @@ -184,30 +256,38 @@ function calculateEdgeColors(outcomes: { [outcome: string]: number }): string { 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[], + // mostCommonSequence: string[], ratioEdges: { [key: string]: number }, edgeOutcomeCounts: EdgeCounts['edgeOutcomeCounts'], edgeCounts: EdgeCounts['edgeCounts'], totalNodeEdges: EdgeCounts['totalNodeEdges'], threshold: number, min_visits: number, + selectedSequence: string ): string { + const stepsInSelectedSequence = selectedSequence//.split('->'); + // console.log(mostCommonSequence) + console.log("selectedSequence" + selectedSequence) + // console.log(selectedSequence[stepsInSelectedSequence]) + const totalSteps = selectedSequence.length; + console.log("totalSteps" + totalSteps) + // Create node definitions in the DOT string let dotString = 'digraph G {\n'; - const totalSteps = mostCommonSequence.length; - // console.log(mostCommonSequence, totalSteps) for (let rank = 0; rank < totalSteps; rank++) { - const step = mostCommonSequence[rank]; - const color = calculateColor(rank + 1, totalSteps); + const step = stepsInSelectedSequence[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('->');// as EdgeKey; + const [currentStep, nextStep] = edge.split('->'); const thickness = normalizedThicknesses[edge]; const outcomes = edgeOutcomeCounts[edge] || {}; const edgeCount = edgeCounts[edge] || 0; @@ -216,18 +296,21 @@ export function generateDotString( const outcomesStr = Object.entries(outcomes) .map(([outcome, count]) => `${outcome}: ${count}`) .join('\n\t\t '); - if (edgeCount > min_visits){ + + 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)]}`; + + `- 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; } + diff --git a/src/components/SequenceSelector.tsx b/src/components/SequenceSelector.tsx new file mode 100644 index 0000000..df0e9fd --- /dev/null +++ b/src/components/SequenceSelector.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +interface SequenceSelectorProps { + sequences: string[]; + selectedSequence: string; + onSequenceSelect: (sequence: string) => void; +} + +const SequenceSelector: React.FC = ({ + sequences, + selectedSequence, + onSequenceSelect, +}) => { + if (sequences.length === 0) { + return
No sequences available
; // Display a message when no sequences are present + } + + return ( +
+ +
+ ); +}; + +export default SequenceSelector; diff --git a/src/components/slider.tsx b/src/components/slider.tsx index 7ece586..f495e8b 100644 --- a/src/components/slider.tsx +++ b/src/components/slider.tsx @@ -1,32 +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; + 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 for Visualization: {value}

-
- ); +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; From 0a3b4742d6c55f98e5606a11b167d19b40d6d5b6 Mon Sep 17 00:00:00 2001 From: talizacks <55198700+talizacks@users.noreply.github.com> Date: Fri, 23 Aug 2024 13:47:41 -0500 Subject: [PATCH 10/25] Sequence coloring isn't working but everything (i think) is close to working again --- src/App.tsx | 45 +- src/GraphvizContainer.css | 3 + src/components/GraphvizParent.tsx | 662 +++++++++++++++++++++++---- src/components/GraphvizProcessing.ts | 390 ++++++++++++++-- src/components/SequenceSelector.tsx | 4 +- 5 files changed, 966 insertions(+), 138 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 881c7c8..f247273 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import './App.css'; -import React, {useCallback, useState} from 'react'; +import React, { useCallback, useState } from 'react'; import Upload from "@/components/Upload.tsx"; import GraphvizParent from "@/components/GraphvizParent.tsx"; import FilterComponent from './components/FilterComponent.tsx'; @@ -11,34 +11,41 @@ const App: React.FC = () => { const [csvData, setCsvData] = useState(''); const [filter, setFilter] = useState(''); const [selfLoops, setSelfLoops] = useState(true); - const [minVisits, setMinVisits] = useState(30); - const [topSequences, setTopSequences] = useState([]); - const [selectedSequence, setSelectedSequence] = useState(topSequences[0]); - const handleSelectSequence = (sequence: string) => { - setSelectedSequence(selectedSequence); - // Add your logic here to update the node coloring based on the selected sequence + const [minVisits, setMinVisits] = useState(10); + const [topSequences, setTopSequences] = useState([]); + const [selectedSequence, setSelectedSequence] = useState(topSequences[0]); + + const handleSelectSequence = (selectedSequence: string[]) => { + setSelectedSequence(selectedSequence); // Fix: Use the correct parameter to update the state console.log(`Selected sequence: ${sequence}`); }; + const handleToggle = () => setSelfLoops(!selfLoops); const handleSlider = (value: number) => setMinVisits(value); const handleDataProcessed = (uploadedCsvData: string) => setCsvData(uploadedCsvData); - const handleTopSequencesUpdate = useCallback((sequences: string[]) => { - setTopSequences(sequences); - console.log("AHHHH: "+topSequences) - }, [topSequences]) + + // Fix: Remove `topSequences` dependency from `useCallback` to avoid unnecessary re-creations + // const handleTopSequencesUpdate = useCallback((sequences: string[][]) => { + // setTopSequences(sequences); + // }, [selectedSequence]); return (

Path Analysis Tool

- - - - - - + + + + + +
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/GraphvizContainer.css b/src/GraphvizContainer.css index a3ea593..0986275 100644 --- a/src/GraphvizContainer.css +++ b/src/GraphvizContainer.css @@ -4,6 +4,9 @@ align-items: center; flex-direction: row; /* Change to row to align items horizontally */ width: 100%; /* Ensure the container takes full width */ + margin-top: -20px; /* Move the container up by 20px */ + position: relative; + transform: translateY(-20px) translateX(30px); /* Move the container up by 20px */ } .graphs { diff --git a/src/components/GraphvizParent.tsx b/src/components/GraphvizParent.tsx index 7bd9734..a086925 100644 --- a/src/components/GraphvizParent.tsx +++ b/src/components/GraphvizParent.tsx @@ -1,24 +1,505 @@ -import {useEffect, useState} from "react"; -import ErrorBoundary from "@/components/errorBoundary.tsx"; -import Graphviz from 'graphviz-react'; +// import {useEffect, useState} from "react"; +// import ErrorBoundary from "@/components/errorBoundary.tsx"; +// import Graphviz from 'graphviz-react'; +// import { +// loadAndSortData, +// createStepSequences, +// createOutcomeSequences, +// countEdges, +// normalizeThicknesses, +// generateDotString, calculateColor +// } from './GraphvizProcessing'; +// import '../GraphvizContainer.css'; +// +// // import SequenceSelector from './SequenceSelector'; +// +// interface GraphvizParentProps { +// csvData: string; +// filter: string; +// selfLoops: boolean; +// minVisits: number; +// onTopSequencesUpdate: (sequences: string[]) => void; +// // selectedSequence: string; +// } +// +// const GraphvizParent: React.FC = ({ +// csvData, +// filter, +// selfLoops, +// minVisits, +// onTopSequencesUpdate, +// // selectedSequence, +// }) => { +// const [dotString, setDotString] = useState(''); +// const [filteredDotString, setFilteredDotString] = useState(''); +// const [topSequences, setTopSequences] = useState([]); +// const [selectedSequence, setSelectedSequence] = useState([]); +// +// // Function to update the node colors based on the selected sequence +// +// const applySequenceColors = (dotStr: string, sequence: string[]) => { +// return dotStr.replace(/(\w+ \[)(.*?\])/g, (match, p1, p2) => { +// const nodeName = match.match(/(\w+) \[/)?.[1]; +// +// // Find the rank of the node in the selected sequence +// const rank = sequence.indexOf(nodeName!) + 1; // +1 because index is 0-based +// const totalSteps = sequence.length; +// +// // Only calculate color if the node exists in the sequence +// if (rank > 0) { +// const color = calculateColor(rank, totalSteps); +// return `${p1}style=filled, fillcolor="${color}", ${p2}`; +// } else { +// return match; // return the original string if node is not in sequence +// } +// }); +// }; +// +// +// useEffect(() => { +// if (!csvData) return; // Skip processing if no CSV data is available +// +// // Step 1: Load and sort the data +// const sortedData = loadAndSortData(csvData); +// +// // Step 2: Generate the sequences for steps and outcomes +// const stepSequences = createStepSequences(sortedData, selfLoops); +// const outcomeSequences = createOutcomeSequences(sortedData); +// +// // Step 3: Count edges and normalize thicknesses +// const { +// edgeCounts, +// totalNodeEdges, +// ratioEdges, +// edgeOutcomeCounts, +// maxEdgeCount, +// top5Sequences +// } = countEdges(stepSequences, outcomeSequences); +// setTopSequences(top5Sequences) +// console.log('TEST: ' + top5Sequences) +// setSelectedSequence(top5Sequences[0]) +// const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); +// +// // Step 4: Find the most common sequences +// // const mostCommonSequenceKey = Object.keys(stepSequences) +// // .reduce((a, b) => stepSequences[a].length > stepSequences[b].length ? a : b); +// // const mostCommonSequence = stepSequences[mostCommonSequenceKey]; +// // const topSequences = Object.keys(stepSequences) +// // .sort((a, b) => stepSequences[b].length - stepSequences[a].length) +// // .slice(0, 5); +// // console.log(stepSequences) +// // const top5Sequences = getTopSequences(stepSequences,5) +// onTopSequencesUpdate(top5Sequences); +// +// setTopSequences(top5Sequences) +// console.log("GVP67:" + topSequences) +// // Call the update function to pass top sequences to App component +// // Step 5: Generate the DOT string for the unfiltered graph +// let dotStr = generateDotString( +// normalizedThicknesses, +// // mostCommonSequence, +// ratioEdges, +// edgeOutcomeCounts, +// edgeCounts, +// totalNodeEdges, +// 1, +// minVisits, +// selectedSequence +// ); +// // Step 5: Apply initial color based on the selected sequence +// +// if (selectedSequence) { +// +// dotStr = applySequenceColors(dotStr, selectedSequence); +// +// } +// +// setDotString(dotStr); +// +// // Step 6: Generate the filtered graph if a filter is provided +// if (filter) { +// 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, +// top5Sequences, +// } = countEdges(filteredStepSequences, filteredOutcomeSequences); +// +// const filteredNormalizedThicknesses = normalizeThicknesses(filteredEdgeCounts, filteredMaxEdgeCount, 10); +// // const filteredMostCommonSequenceKey = Object.keys(filteredStepSequences) +// // .reduce((a, b) => filteredStepSequences[a].length > filteredStepSequences[b].length ? a : b); +// // const filteredMostCommonSequence = filteredStepSequences[filteredMostCommonSequenceKey]; +// +// // Generate the DOT string for the filtered graph +// let filteredDotStr = generateDotString( +// filteredNormalizedThicknesses, +// // filteredMostCommonSequence, +// filteredRatioEdges, +// filteredEdgeOutcomeCounts, +// filteredEdgeCounts, +// filteredTotalNodeEdges, +// 1, +// minVisits, +// selectedSequence +// ); +// if (selectedSequence != top5Sequences[0]) { +// filteredDotStr = applySequenceColors(dotStr, selectedSequence); +// } +// setFilteredDotString(filteredDotStr); +// } else { +// setFilteredDotString(null); // Clear filtered graph if no filter is set +// } +// +// }, [csvData, filter, selfLoops, minVisits, selectedSequence]); +// +// const handleSequenceSelect = (sequence: string) => { +// setSelectedSequence(sequence); +// }; +// +// return ( +//
+// {/**/} +// +//
+// +//
+// {dotString && ( +// +// )} +// {filteredDotString && ( +// +// )} +//
+//
+//
+//
+// ); +// } +// +// export default GraphvizParent; + + +// import {useEffect, useState} from "react"; +// import ErrorBoundary from "@/components/errorBoundary.tsx"; +// import Graphviz from 'graphviz-react'; +// import { +// loadAndSortData, +// createStepSequences, +// createOutcomeSequences, +// countEdges, +// normalizeThicknesses, +// generateDotString, calculateColor +// } from './GraphvizProcessing'; +// import '../GraphvizContainer.css'; +// +// interface GraphvizParentProps { +// csvData: string; +// filter: string; +// selfLoops: boolean; +// minVisits: number; +// onTopSequencesUpdate: (sequences: string[]) => void; +// } +// +// const GraphvizParent: React.FC = ({ +// csvData, +// filter, +// selfLoops, +// minVisits, +// onTopSequencesUpdate, +// }) => { +// const [dotString, setDotString] = useState(''); +// const [filteredDotString, setFilteredDotString] = useState(null); +// const [topSequences, setTopSequences] = useState([]); +// const [selectedSequence, setSelectedSequence] = useState([]); +// +// const applySequenceColors = (dotStr: string, sequence: string[]) => { +// return dotStr.replace(/(\w+ \[)(.*?\])/g, (match, p1, p2) => { +// const nodeName = match.match(/(\w+) \[/)?.[1]; +// const rank = sequence.indexOf(nodeName!) + 1; +// const totalSteps = sequence.length; +// +// if (rank > 0) { +// const color = calculateColor(rank, totalSteps); +// return `${p1}style=filled, fillcolor="${color}", ${p2}`; +// } else { +// return match; +// } +// }); +// }; +// +// useEffect(() => { +// if (!csvData) return; +// +// const sortedData = loadAndSortData(csvData); +// const stepSequences = createStepSequences(sortedData, selfLoops); +// const outcomeSequences = createOutcomeSequences(sortedData); +// +// const { +// edgeCounts, +// totalNodeEdges, +// ratioEdges, +// edgeOutcomeCounts, +// maxEdgeCount, +// top5Sequences, +// } = countEdges(stepSequences, outcomeSequences); +// +// setTopSequences(top5Sequences); +// setSelectedSequence(top5Sequences[0][0]); +// +// const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); +// +// let dotStr = generateDotString( +// normalizedThicknesses, +// ratioEdges, +// edgeOutcomeCounts, +// edgeCounts, +// totalNodeEdges, +// 1, +// minVisits, +// top5Sequences[0] // Use the first sequence directly here +// ); +// +// dotStr = applySequenceColors(dotStr, top5Sequences[0]); +// setDotString(dotStr); +// +// if (filter) { +// 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, +// top5Sequences: filteredTop5Sequences, +// } = countEdges(filteredStepSequences, filteredOutcomeSequences); +// +// const filteredNormalizedThicknesses = normalizeThicknesses(filteredEdgeCounts, filteredMaxEdgeCount, 10); +// +// let filteredDotStr = generateDotString( +// filteredNormalizedThicknesses, +// filteredRatioEdges, +// filteredEdgeOutcomeCounts, +// filteredEdgeCounts, +// filteredTotalNodeEdges, +// 1, +// minVisits, +// selectedSequence // Apply selected sequence here +// ); +// +// filteredDotStr = applySequenceColors(filteredDotStr, selectedSequence); +// setFilteredDotString(filteredDotStr); +// } else { +// setFilteredDotString(null); +// } +// +// }, [csvData, filter, selfLoops, minVisits]); +// +// return ( +//
+// +//
+// {dotString && ( +// +// )} +// {filteredDotString && ( +// +// )} +//
+//
+//
+// ); +// } +// +// export default GraphvizParent; + +// import React, {useEffect, useState} from 'react'; +// import { +// loadAndSortData, +// createStepSequences, +// createOutcomeSequences, +// countEdges, +// normalizeThicknesses, +// generateDotString, +// calculateColor +// } from './GraphvizProcessing'; +// import ErrorBoundary from "@/components/errorBoundary.tsx"; +// import Graphviz from "graphviz-react"; +// +// +// const GraphvizParent: React.FC = () => { +// const [selectedSequence, setSelectedSequence] = useState([]); +// const [dotString, setDotString] = useState(''); +// const [filteredDotString, setFilteredDotString] = useState(null); // Use null for no filter +// const [edgeCounts, setEdgeCounts] = useState({}); +// const [maxEdgeCount, setMaxEdgeCount] = useState(0); +// const [ratioEdges, setRatioEdges] = useState({}); +// const [edgeOutcomeCounts, setEdgeOutcomeCounts] = useState({}); +// const [totalNodeEdges, setTotalNodeEdges] = useState< {[p: string]: number; }>({}); +// const [filterCriteria, setFilterCriteria] = useState(null); // Track filter criteria +// const [csvData, setCsvData] = useState(''); // State for CSV data +// const [selfLoops, setSelfLoops] = useState(false); // State for self-loops +// const [minVisits, setMinVisits] = useState(0); // State for minimum visits +// +// const applySequenceColors = (dotStr: string, sequence: string[]) => { +// return dotStr.replace(/(\w+ \[)(.*?\])/g, (match, p1, p2) => { +// const nodeName = match.match(/(\w+) \[/)?.[1]; +// const rank = sequence.indexOf(nodeName!) + 1; +// const totalSteps = sequence.length; +// +// if (rank > 0) { +// const color = calculateColor(rank, totalSteps); +// return `${p1}style=filled, fillcolor="${color}", ${p2}`; +// } else { +// return match; +// } +// }); +// }; +// useEffect(() => { +// const fetchData = async () => { +// if (!csvData) return; +// else setCsvData(csvData); +// const sortedData = loadAndSortData(csvData); +// const stepSequences = createStepSequences(sortedData, selfLoops); +// const outcomeSequences = createOutcomeSequences(sortedData); +// +// const { edgeCounts, totalNodeEdges, ratioEdges, edgeOutcomeCounts, maxEdgeCount, top5Sequences } = countEdges(stepSequences, outcomeSequences); +// +// setEdgeCounts(edgeCounts); +// setTotalNodeEdges(totalNodeEdges); +// setRatioEdges(ratioEdges); +// setEdgeOutcomeCounts(edgeOutcomeCounts); +// setMaxEdgeCount(maxEdgeCount); +// +// // Set the initial selected sequence +// const initialSequence = top5Sequences[0][0]; // Adjust based on your top sequences structure +// setSelectedSequence(initialSequence); +// +// // Generate the initial DOT string +// const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); +// let newDotString = generateDotString(normalizedThicknesses, ratioEdges, edgeOutcomeCounts, edgeCounts, totalNodeEdges, 0, minVisits, initialSequence); +// newDotString = applySequenceColors(newDotString, initialSequence); +// setDotString(newDotString); +// }; +// +// fetchData(); +// }, []); +// +// // Effect to regenerate the non-filtered DOT string when dependencies change +// useEffect(() => { +// const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); +// let newDotString = generateDotString(normalizedThicknesses, ratioEdges, edgeOutcomeCounts, edgeCounts, totalNodeEdges, 0, minVisits, selectedSequence); +// newDotString = applySequenceColors(newDotString, selectedSequence); +// setDotString(newDotString); +// }, [selectedSequence, edgeCounts, maxEdgeCount, ratioEdges, edgeOutcomeCounts, totalNodeEdges, minVisits, selfLoops]); +// +// // Effect to regenerate filtered DOT string when filterCriteria or selfLoops change +// useEffect(() => { +// if (filterCriteria) { +// const filteredData = csvData.filter(row => row['CF (Workspace Progress Status)'] === filterCriteria); +// 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); +// +// let filteredDotStr = generateDotString( +// filteredNormalizedThicknesses, +// filteredRatioEdges, +// filteredEdgeOutcomeCounts, +// filteredEdgeCounts, +// filteredTotalNodeEdges, +// 1, +// minVisits, +// selectedSequence // Apply selected sequence here +// ); +// +// filteredDotStr = applySequenceColors(filteredDotStr, selectedSequence); +// setFilteredDotString(filteredDotStr); +// } else { +// setFilteredDotString(null); // Set to null if no filter is applied +// } +// }, [csvData, filterCriteria, selfLoops, minVisits, selectedSequence]); +// +// +// return ( +//
+// +//
+// {dotString && ( +// +// )} +// {filteredDotString && ( +// +// )} +//
+//
+//
+// ); +// } +// +// export default GraphvizParent; + +import React, {useEffect, useState} from 'react'; import { - loadAndSortData, + generateDotString, + normalizeThicknesses, + countEdges, createStepSequences, createOutcomeSequences, - countEdges, - normalizeThicknesses, - generateDotString, getTopSequences, + loadAndSortData } from './GraphvizProcessing'; -import '../GraphvizContainer.css'; -import SequenceSelector from './SequenceSelector'; +import Graphviz from "graphviz-react"; +import ErrorBoundary from "@/components/errorBoundary.tsx"; interface GraphvizParentProps { csvData: string; - filter: string; + filter: string | null; selfLoops: boolean; minVisits: number; - onTopSequencesUpdate: (sequences: string[]) => void; - // selectedSequence: string; + selectedSequence: string[]; +} + +export interface SequenceCount { + sequence: string[]; // or whatever type your steps are (e.g., number[]) + count: number; } const GraphvizParent: React.FC = ({ @@ -26,25 +507,19 @@ const GraphvizParent: React.FC = ({ filter, selfLoops, minVisits, - onTopSequencesUpdate, - // selectedSequence, + }) => { - const [dotString, setDotString] = useState(''); - const [filteredDotString, setFilteredDotString] = useState(''); - const [topSequences, setTopSequences] = useState([]); - const [selectedSequence, setSelectedSequence] = useState(''); + const [dotString, setDotString] = useState(null); + const [filteredDotString, setFilteredDotString] = useState(null); + const [top5Sequences, setTop5Sequences] = useState('') + const [selectedSequence, setSelectedSequence] = useState([]) + const [selectedSequenceIndex, setSelectedSequenceIndex] = useState(0); useEffect(() => { - if (!csvData) return; // Skip processing if no CSV data is available - - // Step 1: Load and sort the data const sortedData = loadAndSortData(csvData); - - // Step 2: Generate the sequences for steps and outcomes const stepSequences = createStepSequences(sortedData, selfLoops); const outcomeSequences = createOutcomeSequences(sortedData); - // Step 3: Count edges and normalize thicknesses const { edgeCounts, totalNodeEdges, @@ -53,40 +528,32 @@ const GraphvizParent: React.FC = ({ maxEdgeCount, top5Sequences } = countEdges(stepSequences, outcomeSequences); - setTopSequences(top5Sequences) - setSelectedSequence(top5Sequences[0]) + + const formattedTop5Sequences = top5Sequences.map(([sequenceKey, count]) => ({ + sequence: JSON.parse(sequenceKey), + count, + })); + setTop5Sequences(formattedTop5Sequences); + const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); - // Step 4: Find the most common sequences - // const mostCommonSequenceKey = Object.keys(stepSequences) - // .reduce((a, b) => stepSequences[a].length > stepSequences[b].length ? a : b); - // const mostCommonSequence = stepSequences[mostCommonSequenceKey]; - // const topSequences = Object.keys(stepSequences) - // .sort((a, b) => stepSequences[b].length - stepSequences[a].length) - // .slice(0, 5); - // console.log(stepSequences) - // const top5Sequences = getTopSequences(stepSequences,5) - onTopSequencesUpdate(top5Sequences); - - setTopSequences(top5Sequences) - console.log("GVP67:" + topSequences) - // Call the update function to pass top sequences to App component - // Step 5: Generate the DOT string for the unfiltered graph - const dotStr = generateDotString( + let generatedDotStr = generateDotString( normalizedThicknesses, - // mostCommonSequence, ratioEdges, edgeOutcomeCounts, edgeCounts, totalNodeEdges, 1, minVisits, - selectedSequence + formattedTop5Sequences[selectedSequenceIndex]?.sequence || [] ); - setDotString(dotStr); - // Step 6: Generate the filtered graph if a filter is provided + setDotString(generatedDotStr); + }, [csvData, selfLoops, minVisits, selectedSequence]); + + 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); @@ -97,66 +564,85 @@ const GraphvizParent: React.FC = ({ ratioEdges: filteredRatioEdges, edgeOutcomeCounts: filteredEdgeOutcomeCounts, maxEdgeCount: filteredMaxEdgeCount, - top5Sequences, + top5Sequences } = countEdges(filteredStepSequences, filteredOutcomeSequences); - + const formattedTop5Sequences = top5Sequences.map(([sequenceKey, count]) => ({ + sequence: JSON.parse(sequenceKey), + count, + })); const filteredNormalizedThicknesses = normalizeThicknesses(filteredEdgeCounts, filteredMaxEdgeCount, 10); - // const filteredMostCommonSequenceKey = Object.keys(filteredStepSequences) - // .reduce((a, b) => filteredStepSequences[a].length > filteredStepSequences[b].length ? a : b); - // const filteredMostCommonSequence = filteredStepSequences[filteredMostCommonSequenceKey]; - // Generate the DOT string for the filtered graph - const filteredDotStr = generateDotString( + let filteredDotStr = generateDotString( filteredNormalizedThicknesses, - // filteredMostCommonSequence, filteredRatioEdges, filteredEdgeOutcomeCounts, filteredEdgeCounts, filteredTotalNodeEdges, 1, minVisits, - selectedSequence + formattedTop5Sequences[selectedSequenceIndex]?.sequence || [] ); setFilteredDotString(filteredDotStr); } else { - setFilteredDotString(null); // Clear filtered graph if no filter is set + setFilteredDotString(null); } - }, [csvData, filter, selfLoops, minVisits, selectedSequence]); - - const handleSequenceSelect = (sequence: string) => { - setSelectedSequence(sequence); - }; + // const handleSequenceChange = (event: React.ChangeEvent) => { + // const selectedIndex = parseInt(event.target.value); + // setSelectedSequence(top5Sequences[selectedSequence] || []); + // }; return (
- {/**/} -
- -
- {dotString && ( - - )} - {filteredDotString && ( - - )} -
-
-
-
- ); -} + +
+ {dotString && ( + + )} + {filteredDotString && ( + + )} +
+
+
+)} + ; +// return ( +//
+// +//
+// {dotString && ( +// +// )} +// {filteredDotString && ( +// +// )} +//
+//
+//
+// ); +// } + -export default GraphvizParent; + export default GraphvizParent; \ No newline at end of file diff --git a/src/components/GraphvizProcessing.ts b/src/components/GraphvizProcessing.ts index d1d5200..a7e9343 100644 --- a/src/components/GraphvizProcessing.ts +++ b/src/components/GraphvizProcessing.ts @@ -1,5 +1,5 @@ import Papa from 'papaparse'; -import {get} from "lodash"; +import {SequenceCount} from "@/components/GraphvizParent.tsx"; interface CSVRow { 'Session Id': string; @@ -91,9 +91,10 @@ export function getTopSequences(stepSequences: any, topN: number = 5) { const sequenceCounts: { [sequence: string]: number } = {}; // Iterate over the values (which are lists) of the stepSequences dictionary - Object.values(stepSequences).forEach((sequenceList: string[]) => { - const sequenceKey = JSON.stringify(sequenceList); // Convert the list to a string key + Object.values(stepSequences).forEach((sequence: string[]) => { + const sequenceKey = JSON.stringify(sequence); // Convert the list to a string key + // Count occurrences of each unique sequence if (sequenceCounts[sequenceKey]) { sequenceCounts[sequenceKey]++; } else { @@ -106,12 +107,17 @@ export function getTopSequences(stepSequences: any, topN: number = 5) { .sort(([, countA], [, countB]) => countB - countA) .slice(0, topN); - // Instead of returning the actual sequences, return "Count 1" through "Count 5" - // const topSequences = sortedSequences.map((sequence, index) => `Count ${index + 1}`); - const topSequences = sortedSequences.map(([sequenceKey, index]) => JSON.parse(sequenceKey, 'Count ${index + 1}')); - return topSequences; + // 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(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 }; @@ -129,7 +135,7 @@ export const countEdges = ( maxEdgeCount: number; ratioEdges: { [p: string]: number }; edgeCounts: { [p: string]: number }; - top5Sequences: string[]; + top5Sequences: SequenceCount; } => { const edgeCounts: { [key: string]: number } = {}; const totalNodeEdges: { [key: string]: number } = {}; @@ -137,7 +143,7 @@ export const countEdges = ( const edgeOutcomeCounts: { [key: string]: { [outcome: string]: number } } = {}; let maxEdgeCount = 0; - const top5Sequences = getTopSequences(stepSequences, 5) + const top5Sequences = getTopSequences(stepSequences, 10) // Process edges for all sequences Object.keys(stepSequences).forEach((sessionId) => { @@ -174,22 +180,22 @@ export const countEdges = ( }; -// // Function to normalize edge thicknesses based on their ratio -// export function normalizeThicknesses( -// 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 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( @@ -209,7 +215,7 @@ export function normalizeThicknesses( // Function to calculate the color of a node based on its rank in the most common sequence -function calculateColor(rank: number, totalSteps: number): string { +export function calculateColor(rank: number, totalSteps: number): string { const ratio = rank / totalSteps; const white = {r: 255, g: 255, b: 255}; @@ -266,13 +272,13 @@ export function generateDotString( totalNodeEdges: EdgeCounts['totalNodeEdges'], threshold: number, min_visits: number, - selectedSequence: string + selectedSequence: string[] ): string { const stepsInSelectedSequence = selectedSequence//.split('->'); // console.log(mostCommonSequence) - console.log("selectedSequence" + selectedSequence) + console.log("selectedSequence" + stepsInSelectedSequence) // console.log(selectedSequence[stepsInSelectedSequence]) - const totalSteps = selectedSequence.length; + const totalSteps = stepsInSelectedSequence.length; console.log("totalSteps" + totalSteps) // Create node definitions in the DOT string let dotString = 'digraph G {\n'; @@ -314,3 +320,329 @@ export function generateDotString( return dotString; } +// import Papa from 'papaparse'; +// +// 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) { +// // // const topSequences = Object.values(stepSequences) +// // // .sort((a, b) => stepSequences[b].length - stepSequences[a].length) +// // // .slice(0, topN); +// // +// // const sequenceCounts = Object.entries(stepSequences).reduce((acc: any, [key, value]) => { +// // acc[key] = value.count; +// // return acc; +// // }, {}); +// // console.log("counts: " + sequenceCounts[stepSequences[0]]) +// // const sortedSequences = Object.entries(sequenceCounts) +// // .sort(([, a], [, b]) => b - a) +// // .slice(0, topN); +// // console.log("sorted sequences: " + sequenceCounts[sortedSequences[0]]) +// // // return sortedSequences.map(([sequence]) => sequence); +// // return topSequences +// // } +// 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((sequenceList: string[]) => { +// const sequenceKey = JSON.stringify(sequenceList); // Convert the list to a string key +// +// 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); +// console.log("Sorted w counts: " + sortedSequences) +// console.log(sortedSequences.map(([sequenceKey, count]) => JSON.parse(sequenceKey, sequenceCounts[sequenceKey]))) +// const topSequences = sortedSequences.map(([sequenceKey, count]) => JSON.parse(sequenceKey, sequenceCounts[sequenceKey])); +// return topSequences; +// } +// +// 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 }; +// top5Sequences: string[]; +// } => { +// const edgeCounts: { [key: string]: number } = {}; +// const totalNodeEdges: { [key: string]: number } = {}; +// const ratioEdges: { [key: string]: number } = {}; +// const edgeOutcomeCounts: { [key: string]: { [outcome: string]: number } } = {}; +// let maxEdgeCount = 0; +// +// 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, top5Sequences}; +// }; +// +// +// // // Function to normalize edge thicknesses based on their ratio +// // export function normalizeThicknesses( +// // 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 +// function calculateColor(sequence: string[], 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: string +// ): string { +// // const stepsInSelectedSequence = selectedSequence//.split('->'); +// // console.log(mostCommonSequence) +// // console.log(selectedSequence[stepsInSelectedSequence]) +// const totalSteps = selectedSequence.length; +// +// console.log("selected sequence" + selectedSequence) +// console.log("totalSteps" + totalSteps) +// // Create node definitions in the DOT string +// let dotString = 'digraph G {\n'; +// Object.keys(edgeCounts).forEach((sourceNode) => { +// // Determine the rank of the node in the selected sequence +// const rank = selectedSequence.indexOf(sourceNode) + 1; +// const color = rank > 0 ? calculateColor(selectedSequence, rank, totalSteps) : '#FFFFFF'; // Default to white if not in sequence +// +// // dotString += `"${sourceNode}" [style=filled, fillcolor="${color}"];\n`; +// +// // for (let rank = 0; rank < totalSteps; rank++) { +// // const step = stepsInSelectedSequence[rank]; +// // const color = calculateColor(selectedSequence, rank, totalSteps); +// // console.log(color, step) +// const node_tooltip = `Rank:\n\t\t ${rank + 1}\nColor:\n\t\t ${color}`; +// +// dotString += ` "${sourceNode}" [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; +// } +// + + diff --git a/src/components/SequenceSelector.tsx b/src/components/SequenceSelector.tsx index df0e9fd..a4a471c 100644 --- a/src/components/SequenceSelector.tsx +++ b/src/components/SequenceSelector.tsx @@ -18,8 +18,8 @@ const SequenceSelector: React.FC = ({ return (
setSelectedSequenceIndex(Number(e.target.value))} - value={selectedSequenceIndex}> - {top5Sequences.map((seq, index) => ( - - ))} - -
- {dotString && ( - - )} - {filteredDotString && ( - - )} -
- -
-)} - ; +
+ {dotString && ( + + )} + {filteredDotString && ( + + )} +
+ +
+ ); +} // return ( //
// @@ -645,4 +635,4 @@ const GraphvizParent: React.FC = ({ // } - export default GraphvizParent; \ No newline at end of file +export default GraphvizParent; \ No newline at end of file diff --git a/src/components/SequenceSelector.tsx b/src/components/SequenceSelector.tsx index a4a471c..68ed8ba 100644 --- a/src/components/SequenceSelector.tsx +++ b/src/components/SequenceSelector.tsx @@ -1,7 +1,8 @@ import React from 'react'; +import {SequenceCount} from "@/components/GraphvizParent.tsx"; interface SequenceSelectorProps { - sequences: string[]; + sequences: SequenceCount|string; selectedSequence: string; onSequenceSelect: (sequence: string) => void; } @@ -11,7 +12,7 @@ const SequenceSelector: React.FC = ({ selectedSequence, onSequenceSelect, }) => { - if (sequences.length === 0) { + if (sequences == '') { return
No sequences available
; // Display a message when no sequences are present } @@ -20,7 +21,7 @@ const SequenceSelector: React.FC = ({ From 1aab228641f9172d16ee4bc794befae25abb9abe Mon Sep 17 00:00:00 2001 From: talizacks <55198700+talizacks@users.noreply.github.com> Date: Thu, 29 Aug 2024 10:17:36 -0500 Subject: [PATCH 12/25] Color working but graph not reloading upon new upload --- src/App.tsx | 30 +- src/Context.tsx | 17 +- src/components/GraphvizParent.tsx | 541 +-------------------------- src/components/GraphvizProcessing.ts | 31 +- src/components/SequenceSelector.tsx | 33 +- 5 files changed, 77 insertions(+), 575 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0073db7..d0b040d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,21 +1,22 @@ import './App.css'; -import React, { useCallback, useState } from 'react'; +import React, {useContext, useState} from 'react'; import Upload from "@/components/Upload.tsx"; -import GraphvizParent, {SequenceCount} from "@/components/GraphvizParent.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(10); - const [topSequences, setTopSequences] = useState([]); - const [selectedSequence, setSelectedSequence] = useState(['']); + const {top5Sequences} = useContext(Context); + const [selectedSequence, setSelectedSequence] = useState(null); - const handleSelectSequence = (selectedSequence: string[]) => { + const handleSelectSequence = (selectedSequence: SequenceCount["sequence"]) => { setSelectedSequence(selectedSequence); // Fix: Use the correct parameter to update the state console.log(`Selected sequence: ${selectedSequence}`); }; @@ -24,19 +25,20 @@ const App: React.FC = () => { const handleSlider = (value: number) => setMinVisits(value); const handleDataProcessed = (uploadedCsvData: string) => setCsvData(uploadedCsvData); - // Fix: Remove `topSequences` dependency from `useCallback` to avoid unnecessary re-creations - // const handleTopSequencesUpdate = useCallback((sequences: string[][]) => { - // setTopSequences(sequences); - // }, [selectedSequence]); return (

Path Analysis Tool

- - - - - + + +

{selectedSequence}

+ + + + void; setData: (data: GlobalDataType[] | null) => void; setGraphData: (graphData: GraphData | null) => void; resetData: () => void; + setTop5Sequences: (top5Sequences: SequenceCount[]) => void; } +export interface SequenceCount { + sequence: string[] | null; // or whatever type your steps are (e.g., number[]) + count: number; +} export const Context = createContext({} as ContextInterface); const initialState = { data: null, graphData: null, - loading: false + loading: false, + top5Sequences: null } interface ProviderProps { children: React.ReactNode; } + + export const Provider = ({ children }: ProviderProps) => { const [data, setData] = useState(initialState.data) const [graphData, setGraphData] = useState(initialState.graphData) const [loading, setLoading] = useState(initialState.loading) - + const [top5Sequences, setTop5Sequences] = useState(initialState.top5Sequences) const resetData = () => { setData(null) setGraphData(null) @@ -38,10 +47,12 @@ export const Provider = ({ children }: ProviderProps) => { data, graphData, loading, + top5Sequences, setLoading, setData, setGraphData, - resetData + resetData, + setTop5Sequences }} > {children} diff --git a/src/components/GraphvizParent.tsx b/src/components/GraphvizParent.tsx index d86f153..93e1c4b 100644 --- a/src/components/GraphvizParent.tsx +++ b/src/components/GraphvizParent.tsx @@ -1,483 +1,4 @@ -// import {useEffect, useState} from "react"; -// import ErrorBoundary from "@/components/errorBoundary.tsx"; -// import Graphviz from 'graphviz-react'; -// import { -// loadAndSortData, -// createStepSequences, -// createOutcomeSequences, -// countEdges, -// normalizeThicknesses, -// generateDotString, calculateColor -// } from './GraphvizProcessing'; -// import '../GraphvizContainer.css'; -// -// // import SequenceSelector from './SequenceSelector'; -// -// interface GraphvizParentProps { -// csvData: string; -// filter: string; -// selfLoops: boolean; -// minVisits: number; -// onTopSequencesUpdate: (sequences: string[]) => void; -// // selectedSequence: string; -// } -// -// const GraphvizParent: React.FC = ({ -// csvData, -// filter, -// selfLoops, -// minVisits, -// onTopSequencesUpdate, -// // selectedSequence, -// }) => { -// const [dotString, setDotString] = useState(''); -// const [filteredDotString, setFilteredDotString] = useState(''); -// const [topSequences, setTopSequences] = useState([]); -// const [selectedSequence, setSelectedSequence] = useState([]); -// -// // Function to update the node colors based on the selected sequence -// -// const applySequenceColors = (dotStr: string, sequence: string[]) => { -// return dotStr.replace(/(\w+ \[)(.*?\])/g, (match, p1, p2) => { -// const nodeName = match.match(/(\w+) \[/)?.[1]; -// -// // Find the rank of the node in the selected sequence -// const rank = sequence.indexOf(nodeName!) + 1; // +1 because index is 0-based -// const totalSteps = sequence.length; -// -// // Only calculate color if the node exists in the sequence -// if (rank > 0) { -// const color = calculateColor(rank, totalSteps); -// return `${p1}style=filled, fillcolor="${color}", ${p2}`; -// } else { -// return match; // return the original string if node is not in sequence -// } -// }); -// }; -// -// -// useEffect(() => { -// if (!csvData) return; // Skip processing if no CSV data is available -// -// // Step 1: Load and sort the data -// const sortedData = loadAndSortData(csvData); -// -// // Step 2: Generate the sequences for steps and outcomes -// const stepSequences = createStepSequences(sortedData, selfLoops); -// const outcomeSequences = createOutcomeSequences(sortedData); -// -// // Step 3: Count edges and normalize thicknesses -// const { -// edgeCounts, -// totalNodeEdges, -// ratioEdges, -// edgeOutcomeCounts, -// maxEdgeCount, -// top5Sequences -// } = countEdges(stepSequences, outcomeSequences); -// setTopSequences(top5Sequences) -// console.log('TEST: ' + top5Sequences) -// setSelectedSequence(top5Sequences[0]) -// const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); -// -// // Step 4: Find the most common sequences -// // const mostCommonSequenceKey = Object.keys(stepSequences) -// // .reduce((a, b) => stepSequences[a].length > stepSequences[b].length ? a : b); -// // const mostCommonSequence = stepSequences[mostCommonSequenceKey]; -// // const topSequences = Object.keys(stepSequences) -// // .sort((a, b) => stepSequences[b].length - stepSequences[a].length) -// // .slice(0, 5); -// // console.log(stepSequences) -// // const top5Sequences = getTopSequences(stepSequences,5) -// onTopSequencesUpdate(top5Sequences); -// -// setTopSequences(top5Sequences) -// console.log("GVP67:" + topSequences) -// // Call the update function to pass top sequences to App component -// // Step 5: Generate the DOT string for the unfiltered graph -// let dotStr = generateDotString( -// normalizedThicknesses, -// // mostCommonSequence, -// ratioEdges, -// edgeOutcomeCounts, -// edgeCounts, -// totalNodeEdges, -// 1, -// minVisits, -// selectedSequence -// ); -// // Step 5: Apply initial color based on the selected sequence -// -// if (selectedSequence) { -// -// dotStr = applySequenceColors(dotStr, selectedSequence); -// -// } -// -// setDotString(dotStr); -// -// // Step 6: Generate the filtered graph if a filter is provided -// if (filter) { -// 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, -// top5Sequences, -// } = countEdges(filteredStepSequences, filteredOutcomeSequences); -// -// const filteredNormalizedThicknesses = normalizeThicknesses(filteredEdgeCounts, filteredMaxEdgeCount, 10); -// // const filteredMostCommonSequenceKey = Object.keys(filteredStepSequences) -// // .reduce((a, b) => filteredStepSequences[a].length > filteredStepSequences[b].length ? a : b); -// // const filteredMostCommonSequence = filteredStepSequences[filteredMostCommonSequenceKey]; -// -// // Generate the DOT string for the filtered graph -// let filteredDotStr = generateDotString( -// filteredNormalizedThicknesses, -// // filteredMostCommonSequence, -// filteredRatioEdges, -// filteredEdgeOutcomeCounts, -// filteredEdgeCounts, -// filteredTotalNodeEdges, -// 1, -// minVisits, -// selectedSequence -// ); -// if (selectedSequence != top5Sequences[0]) { -// filteredDotStr = applySequenceColors(dotStr, selectedSequence); -// } -// setFilteredDotString(filteredDotStr); -// } else { -// setFilteredDotString(null); // Clear filtered graph if no filter is set -// } -// -// }, [csvData, filter, selfLoops, minVisits, selectedSequence]); -// -// const handleSequenceSelect = (sequence: string) => { -// setSelectedSequence(sequence); -// }; -// -// return ( -//
-// {/**/} -// -//
-// -//
-// {dotString && ( -// -// )} -// {filteredDotString && ( -// -// )} -//
-//
-//
-//
-// ); -// } -// -// export default GraphvizParent; - - -// import {useEffect, useState} from "react"; -// import ErrorBoundary from "@/components/errorBoundary.tsx"; -// import Graphviz from 'graphviz-react'; -// import { -// loadAndSortData, -// createStepSequences, -// createOutcomeSequences, -// countEdges, -// normalizeThicknesses, -// generateDotString, calculateColor -// } from './GraphvizProcessing'; -// import '../GraphvizContainer.css'; -// -// interface GraphvizParentProps { -// csvData: string; -// filter: string; -// selfLoops: boolean; -// minVisits: number; -// onTopSequencesUpdate: (sequences: string[]) => void; -// } -// -// const GraphvizParent: React.FC = ({ -// csvData, -// filter, -// selfLoops, -// minVisits, -// onTopSequencesUpdate, -// }) => { -// const [dotString, setDotString] = useState(''); -// const [filteredDotString, setFilteredDotString] = useState(null); -// const [topSequences, setTopSequences] = useState([]); -// const [selectedSequence, setSelectedSequence] = useState([]); -// -// const applySequenceColors = (dotStr: string, sequence: string[]) => { -// return dotStr.replace(/(\w+ \[)(.*?\])/g, (match, p1, p2) => { -// const nodeName = match.match(/(\w+) \[/)?.[1]; -// const rank = sequence.indexOf(nodeName!) + 1; -// const totalSteps = sequence.length; -// -// if (rank > 0) { -// const color = calculateColor(rank, totalSteps); -// return `${p1}style=filled, fillcolor="${color}", ${p2}`; -// } else { -// return match; -// } -// }); -// }; -// -// useEffect(() => { -// if (!csvData) return; -// -// const sortedData = loadAndSortData(csvData); -// const stepSequences = createStepSequences(sortedData, selfLoops); -// const outcomeSequences = createOutcomeSequences(sortedData); -// -// const { -// edgeCounts, -// totalNodeEdges, -// ratioEdges, -// edgeOutcomeCounts, -// maxEdgeCount, -// top5Sequences, -// } = countEdges(stepSequences, outcomeSequences); -// -// setTopSequences(top5Sequences); -// setSelectedSequence(top5Sequences[0][0]); -// -// const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); -// -// let dotStr = generateDotString( -// normalizedThicknesses, -// ratioEdges, -// edgeOutcomeCounts, -// edgeCounts, -// totalNodeEdges, -// 1, -// minVisits, -// top5Sequences[0] // Use the first sequence directly here -// ); -// -// dotStr = applySequenceColors(dotStr, top5Sequences[0]); -// setDotString(dotStr); -// -// if (filter) { -// 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, -// top5Sequences: filteredTop5Sequences, -// } = countEdges(filteredStepSequences, filteredOutcomeSequences); -// -// const filteredNormalizedThicknesses = normalizeThicknesses(filteredEdgeCounts, filteredMaxEdgeCount, 10); -// -// let filteredDotStr = generateDotString( -// filteredNormalizedThicknesses, -// filteredRatioEdges, -// filteredEdgeOutcomeCounts, -// filteredEdgeCounts, -// filteredTotalNodeEdges, -// 1, -// minVisits, -// selectedSequence // Apply selected sequence here -// ); -// -// filteredDotStr = applySequenceColors(filteredDotStr, selectedSequence); -// setFilteredDotString(filteredDotStr); -// } else { -// setFilteredDotString(null); -// } -// -// }, [csvData, filter, selfLoops, minVisits]); -// -// return ( -//
-// -//
-// {dotString && ( -// -// )} -// {filteredDotString && ( -// -// )} -//
-//
-//
-// ); -// } -// -// export default GraphvizParent; - -// import React, {useEffect, useState} from 'react'; -// import { -// loadAndSortData, -// createStepSequences, -// createOutcomeSequences, -// countEdges, -// normalizeThicknesses, -// generateDotString, -// calculateColor -// } from './GraphvizProcessing'; -// import ErrorBoundary from "@/components/errorBoundary.tsx"; -// import Graphviz from "graphviz-react"; -// -// -// const GraphvizParent: React.FC = () => { -// const [selectedSequence, setSelectedSequence] = useState([]); -// const [dotString, setDotString] = useState(''); -// const [filteredDotString, setFilteredDotString] = useState(null); // Use null for no filter -// const [edgeCounts, setEdgeCounts] = useState({}); -// const [maxEdgeCount, setMaxEdgeCount] = useState(0); -// const [ratioEdges, setRatioEdges] = useState({}); -// const [edgeOutcomeCounts, setEdgeOutcomeCounts] = useState({}); -// const [totalNodeEdges, setTotalNodeEdges] = useState< {[p: string]: number; }>({}); -// const [filterCriteria, setFilterCriteria] = useState(null); // Track filter criteria -// const [csvData, setCsvData] = useState(''); // State for CSV data -// const [selfLoops, setSelfLoops] = useState(false); // State for self-loops -// const [minVisits, setMinVisits] = useState(0); // State for minimum visits -// -// const applySequenceColors = (dotStr: string, sequence: string[]) => { -// return dotStr.replace(/(\w+ \[)(.*?\])/g, (match, p1, p2) => { -// const nodeName = match.match(/(\w+) \[/)?.[1]; -// const rank = sequence.indexOf(nodeName!) + 1; -// const totalSteps = sequence.length; -// -// if (rank > 0) { -// const color = calculateColor(rank, totalSteps); -// return `${p1}style=filled, fillcolor="${color}", ${p2}`; -// } else { -// return match; -// } -// }); -// }; -// useEffect(() => { -// const fetchData = async () => { -// if (!csvData) return; -// else setCsvData(csvData); -// const sortedData = loadAndSortData(csvData); -// const stepSequences = createStepSequences(sortedData, selfLoops); -// const outcomeSequences = createOutcomeSequences(sortedData); -// -// const { edgeCounts, totalNodeEdges, ratioEdges, edgeOutcomeCounts, maxEdgeCount, top5Sequences } = countEdges(stepSequences, outcomeSequences); -// -// setEdgeCounts(edgeCounts); -// setTotalNodeEdges(totalNodeEdges); -// setRatioEdges(ratioEdges); -// setEdgeOutcomeCounts(edgeOutcomeCounts); -// setMaxEdgeCount(maxEdgeCount); -// -// // Set the initial selected sequence -// const initialSequence = top5Sequences[0][0]; // Adjust based on your top sequences structure -// setSelectedSequence(initialSequence); -// -// // Generate the initial DOT string -// const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); -// let newDotString = generateDotString(normalizedThicknesses, ratioEdges, edgeOutcomeCounts, edgeCounts, totalNodeEdges, 0, minVisits, initialSequence); -// newDotString = applySequenceColors(newDotString, initialSequence); -// setDotString(newDotString); -// }; -// -// fetchData(); -// }, []); -// -// // Effect to regenerate the non-filtered DOT string when dependencies change -// useEffect(() => { -// const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); -// let newDotString = generateDotString(normalizedThicknesses, ratioEdges, edgeOutcomeCounts, edgeCounts, totalNodeEdges, 0, minVisits, selectedSequence); -// newDotString = applySequenceColors(newDotString, selectedSequence); -// setDotString(newDotString); -// }, [selectedSequence, edgeCounts, maxEdgeCount, ratioEdges, edgeOutcomeCounts, totalNodeEdges, minVisits, selfLoops]); -// -// // Effect to regenerate filtered DOT string when filterCriteria or selfLoops change -// useEffect(() => { -// if (filterCriteria) { -// const filteredData = csvData.filter(row => row['CF (Workspace Progress Status)'] === filterCriteria); -// 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); -// -// let filteredDotStr = generateDotString( -// filteredNormalizedThicknesses, -// filteredRatioEdges, -// filteredEdgeOutcomeCounts, -// filteredEdgeCounts, -// filteredTotalNodeEdges, -// 1, -// minVisits, -// selectedSequence // Apply selected sequence here -// ); -// -// filteredDotStr = applySequenceColors(filteredDotStr, selectedSequence); -// setFilteredDotString(filteredDotStr); -// } else { -// setFilteredDotString(null); // Set to null if no filter is applied -// } -// }, [csvData, filterCriteria, selfLoops, minVisits, selectedSequence]); -// -// -// return ( -//
-// -//
-// {dotString && ( -// -// )} -// {filteredDotString && ( -// -// )} -//
-//
-//
-// ); -// } -// -// export default GraphvizParent; - -import React, {useEffect, useState} from 'react'; +import React, {useContext, useEffect, useState} from 'react'; import { generateDotString, normalizeThicknesses, @@ -489,21 +10,16 @@ import { import Graphviz from "graphviz-react"; import ErrorBoundary from "@/components/errorBoundary.tsx"; import '../GraphvizContainer.css'; -import sequenceSelector from "@/components/SequenceSelector.tsx"; -import SequenceSelector from "@/components/SequenceSelector.tsx"; +import {Context, SequenceCount} from "@/Context.tsx"; interface GraphvizParentProps { csvData: string; filter: string | null; selfLoops: boolean; minVisits: number; - selectedSequence: string[]; + selectedSequence: SequenceCount["sequence"] | null; } -export interface SequenceCount { - sequence: string[]; // or whatever type your steps are (e.g., number[]) - count: number; -} const GraphvizParent: React.FC = ({ csvData, @@ -514,8 +30,9 @@ const GraphvizParent: React.FC = ({ }) => { const [dotString, setDotString] = useState(null); const [filteredDotString, setFilteredDotString] = useState(null); - const [top5Sequences, setTop5Sequences] = useState('') - const [selectedSequence, setSelectedSequence] = useState([]) + const [selectedSequence, setSelectedSequence] = useState([]) + const {top5Sequences, setTop5Sequences} = useContext(Context); + // const [selectedSequenceIndex, setSelectedSequenceIndex] = useState(0); useEffect(() => { @@ -529,11 +46,14 @@ const GraphvizParent: React.FC = ({ ratioEdges, edgeOutcomeCounts, maxEdgeCount, - top5Sequences + topSequences } = countEdges(stepSequences, outcomeSequences); - - setTop5Sequences(top5Sequences) - + console.log("Before: " + topSequences) + setTop5Sequences(topSequences) + if (top5Sequences != null) { + console.log("THIS: " + top5Sequences) + setSelectedSequence(top5Sequences[0]["sequence"]) + } const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); let generatedDotStr = generateDotString( @@ -548,8 +68,11 @@ const GraphvizParent: React.FC = ({ ); setDotString(generatedDotStr); + console.log(selectedSequence) + console.log(dotString) }, [csvData, selfLoops, minVisits, selectedSequence]); + useEffect(() => { if (filter) { const sortedData = loadAndSortData(csvData); @@ -579,20 +102,16 @@ const GraphvizParent: React.FC = ({ ); setFilteredDotString(filteredDotStr); + console.log(selectedSequence) + console.log(filteredDotStr) } else { setFilteredDotString(null); } }, [csvData, filter, selfLoops, minVisits, selectedSequence]); - const handleSequenceChange = (event: React.ChangeEvent) => { - const selectedIndex: number = parseInt(event.target.value); - setSelectedSequence(top5Sequences[selectedIndex].sequence || []); - }; - // const handleTop5Sequences = useState(top5Sequences); + return (
- -
{dotString && ( @@ -612,27 +131,5 @@ const GraphvizParent: React.FC = ({
); } -// return ( -//
-// -//
-// {dotString && ( -// -// )} -// {filteredDotString && ( -// -// )} -//
-//
-//
-// ); -// } - export default GraphvizParent; \ No newline at end of file diff --git a/src/components/GraphvizProcessing.ts b/src/components/GraphvizProcessing.ts index a7e9343..8bf5440 100644 --- a/src/components/GraphvizProcessing.ts +++ b/src/components/GraphvizProcessing.ts @@ -1,5 +1,5 @@ import Papa from 'papaparse'; -import {SequenceCount} from "@/components/GraphvizParent.tsx"; +import {SequenceCount} from "@/Context"; interface CSVRow { 'Session Id': string; @@ -69,29 +69,12 @@ export const createOutcomeSequences = (sortedData: CSVRow[]): { [key: string]: s }, {} as { [key: string]: string[] }); }; -// export function getTopSequences(stepSequences: any, topN: number = 5) { -// // const topSequences = Object.values(stepSequences) -// // .sort((a, b) => stepSequences[b].length - stepSequences[a].length) -// // .slice(0, topN); -// -// const sequenceCounts = Object.entries(stepSequences).reduce((acc: any, [key, value]) => { -// acc[key] = value.count; -// return acc; -// }, {}); -// console.log("counts: " + sequenceCounts[stepSequences[0]]) -// const sortedSequences = Object.entries(sequenceCounts) -// .sort(([, a], [, b]) => b - a) -// .slice(0, topN); -// console.log("sorted sequences: " + sequenceCounts[sortedSequences[0]]) -// // return sortedSequences.map(([sequence]) => sequence); -// return topSequences -// } 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: string[]) => { + Object.values(stepSequences).forEach((sequence) => { const sequenceKey = JSON.stringify(sequence); // Convert the list to a string key // Count occurrences of each unique sequence @@ -135,7 +118,7 @@ export const countEdges = ( maxEdgeCount: number; ratioEdges: { [p: string]: number }; edgeCounts: { [p: string]: number }; - top5Sequences: SequenceCount; + topSequences: SequenceCount[]; } => { const edgeCounts: { [key: string]: number } = {}; const totalNodeEdges: { [key: string]: number } = {}; @@ -176,7 +159,7 @@ export const countEdges = ( ratioEdges[edge] = edgeCounts[edge] / (totalNodeEdges[start] || 0); }); - return {edgeCounts, totalNodeEdges, ratioEdges, edgeOutcomeCounts, maxEdgeCount, top5Sequences}; + return {edgeCounts, totalNodeEdges, ratioEdges, edgeOutcomeCounts, maxEdgeCount, topSequences: top5Sequences}; }; @@ -272,13 +255,13 @@ export function generateDotString( totalNodeEdges: EdgeCounts['totalNodeEdges'], threshold: number, min_visits: number, - selectedSequence: string[] + selectedSequence: SequenceCount["sequence"] ): string { const stepsInSelectedSequence = selectedSequence//.split('->'); // console.log(mostCommonSequence) - console.log("selectedSequence" + stepsInSelectedSequence) + console.log("selectedSequenceR" + stepsInSelectedSequence) // console.log(selectedSequence[stepsInSelectedSequence]) - const totalSteps = stepsInSelectedSequence.length; + const totalSteps = selectedSequence.length//stepsInSelectedSequence.length; console.log("totalSteps" + totalSteps) // Create node definitions in the DOT string let dotString = 'digraph G {\n'; diff --git a/src/components/SequenceSelector.tsx b/src/components/SequenceSelector.tsx index 68ed8ba..92d9e5c 100644 --- a/src/components/SequenceSelector.tsx +++ b/src/components/SequenceSelector.tsx @@ -1,30 +1,39 @@ import React from 'react'; -import {SequenceCount} from "@/components/GraphvizParent.tsx"; +import {SequenceCount} from "@/Context"; interface SequenceSelectorProps { - sequences: SequenceCount|string; - selectedSequence: string; - onSequenceSelect: (sequence: string) => void; + sequences: SequenceCount[] | null; + selectedSequence: string[] | null; + onSequenceSelect: (sequence: string[]) => void; } const SequenceSelector: React.FC = ({ - sequences, - selectedSequence, - onSequenceSelect, -}) => { - if (sequences == '') { + 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 (
+
); }; From 6cc64f439879a9bc188a108912661357c35f41d6 Mon Sep 17 00:00:00 2001 From: talizacks <55198700+talizacks@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:25:36 -0500 Subject: [PATCH 13/25] Everything working again except node color still won't update with sequence change --- src/App.tsx | 9 +- src/Context.tsx | 15 +- src/GraphvizContainer.css | 2 +- src/components/GraphvizParent.tsx | 115 +++++---- src/components/GraphvizProcessing.ts | 345 +-------------------------- src/components/SequenceSelector.tsx | 4 +- src/components/slider.tsx | 2 +- src/main.tsx | 2 +- 8 files changed, 101 insertions(+), 393 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d0b040d..3b330a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,8 +17,11 @@ const App: React.FC = () => { const [selectedSequence, setSelectedSequence] = useState(null); const handleSelectSequence = (selectedSequence: SequenceCount["sequence"]) => { - setSelectedSequence(selectedSequence); // Fix: Use the correct parameter to update the state - console.log(`Selected sequence: ${selectedSequence}`); + 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); @@ -32,7 +35,7 @@ const App: React.FC = () => {

{selectedSequence}

- void; } + export interface SequenceCount { sequence: string[] | null; // or whatever type your steps are (e.g., number[]) - count: number; + count: number | null; } + export const Context = createContext({} as ContextInterface); const initialState = { data: null, @@ -29,16 +32,16 @@ interface ProviderProps { } -export const Provider = ({ children }: ProviderProps) => { +export const Provider = ({children}: ProviderProps) => { const [data, setData] = useState(initialState.data) const [graphData, setGraphData] = useState(initialState.graphData) const [loading, setLoading] = useState(initialState.loading) - const [top5Sequences, setTop5Sequences] = useState(initialState.top5Sequences) + const [top5Sequences, setTop5Sequences] = useState(initialState.top5Sequences) const resetData = () => { setData(null) setGraphData(null) console.log("Data reset"); - + } return ( diff --git a/src/GraphvizContainer.css b/src/GraphvizContainer.css index a5aea5e..78c631d 100644 --- a/src/GraphvizContainer.css +++ b/src/GraphvizContainer.css @@ -19,5 +19,5 @@ .graphs > div { flex: 1; /* Allow the graphs to scale according to available space */ - max-width: 800px; /* Set a max-width for each graph */ + max-width: 600px; /* Set a max-width for each graph */ } diff --git a/src/components/GraphvizParent.tsx b/src/components/GraphvizParent.tsx index 93e1c4b..ff914ed 100644 --- a/src/components/GraphvizParent.tsx +++ b/src/components/GraphvizParent.tsx @@ -17,61 +17,59 @@ interface GraphvizParentProps { filter: string | null; selfLoops: boolean; minVisits: number; - selectedSequence: SequenceCount["sequence"] | null; -} + selectedSequence: string[]|null; +} const GraphvizParent: React.FC = ({ csvData, filter, selfLoops, minVisits, - }) => { const [dotString, setDotString] = useState(null); const [filteredDotString, setFilteredDotString] = useState(null); - const [selectedSequence, setSelectedSequence] = useState([]) + const [selectedSequence, setSelectedSequence] = useState(null); const {top5Sequences, setTop5Sequences} = useContext(Context); - // const [selectedSequenceIndex, setSelectedSequenceIndex] = useState(0); - useEffect(() => { - 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("Before: " + topSequences) - setTop5Sequences(topSequences) - if (top5Sequences != null) { - console.log("THIS: " + top5Sequences) - setSelectedSequence(top5Sequences[0]["sequence"]) - } - const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); - - let generatedDotStr = generateDotString( - normalizedThicknesses, - ratioEdges, - edgeOutcomeCounts, - edgeCounts, - totalNodeEdges, - 1, - minVisits, - selectedSequence - ); + if (csvData) { + const sortedData = loadAndSortData(csvData); + const stepSequences = createStepSequences(sortedData, selfLoops); + const outcomeSequences = createOutcomeSequences(sortedData); - setDotString(generatedDotStr); - console.log(selectedSequence) - console.log(dotString) - }, [csvData, selfLoops, minVisits, selectedSequence]); + const { + edgeCounts, + totalNodeEdges, + ratioEdges, + edgeOutcomeCounts, + maxEdgeCount, + topSequences + } = countEdges(stepSequences, outcomeSequences); + + if (JSON.stringify(top5Sequences) !== JSON.stringify(topSequences)) { + setTop5Sequences(topSequences); + } + + if (!selectedSequence && topSequences.length > 0) { + setSelectedSequence(topSequences[0].sequence); + } + + const normalizedThicknesses = normalizeThicknesses(edgeCounts, maxEdgeCount, 10); + const generatedDotStr = generateDotString( + normalizedThicknesses, + ratioEdges, + edgeOutcomeCounts, + edgeCounts, + totalNodeEdges, + 1, + minVisits, + selectedSequence + ); + setDotString(generatedDotStr); + } + }, [csvData, selfLoops, minVisits, top5Sequences, selectedSequence]); useEffect(() => { if (filter) { @@ -89,8 +87,7 @@ const GraphvizParent: React.FC = ({ } = countEdges(filteredStepSequences, filteredOutcomeSequences); const filteredNormalizedThicknesses = normalizeThicknesses(filteredEdgeCounts, filteredMaxEdgeCount, 10); - - let filteredDotStr = generateDotString( + const filteredDotStr = generateDotString( filteredNormalizedThicknesses, filteredRatioEdges, filteredEdgeOutcomeCounts, @@ -102,14 +99,40 @@ const GraphvizParent: React.FC = ({ ); setFilteredDotString(filteredDotStr); - console.log(selectedSequence) - console.log(filteredDotStr) } else { setFilteredDotString(null); } }, [csvData, filter, selfLoops, minVisits, selectedSequence]); +useEffect(() => { + if (dotString && 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); + } + }, [selectedSequence]); return (
@@ -130,6 +153,6 @@ const GraphvizParent: React.FC = ({
); -} +}; -export default GraphvizParent; \ No newline at end of file +export default GraphvizParent; diff --git a/src/components/GraphvizProcessing.ts b/src/components/GraphvizProcessing.ts index 8bf5440..db82aa7 100644 --- a/src/components/GraphvizProcessing.ts +++ b/src/components/GraphvizProcessing.ts @@ -126,7 +126,7 @@ export const countEdges = ( const edgeOutcomeCounts: { [key: string]: { [outcome: string]: number } } = {}; let maxEdgeCount = 0; - const top5Sequences = getTopSequences(stepSequences, 10) + const top5Sequences = getTopSequences(stepSequences, 5) // Process edges for all sequences Object.keys(stepSequences).forEach((sessionId) => { @@ -257,23 +257,29 @@ export function generateDotString( 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]) - const totalSteps = selectedSequence.length//stepsInSelectedSequence.length; - console.log("totalSteps" + totalSteps) // 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 = stepsInSelectedSequence[rank]; + const step = stepsInSelectedSequence![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 +// 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('->'); @@ -301,331 +307,4 @@ export function generateDotString( dotString += '}'; return dotString; -} - -// import Papa from 'papaparse'; -// -// 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) { -// // // const topSequences = Object.values(stepSequences) -// // // .sort((a, b) => stepSequences[b].length - stepSequences[a].length) -// // // .slice(0, topN); -// // -// // const sequenceCounts = Object.entries(stepSequences).reduce((acc: any, [key, value]) => { -// // acc[key] = value.count; -// // return acc; -// // }, {}); -// // console.log("counts: " + sequenceCounts[stepSequences[0]]) -// // const sortedSequences = Object.entries(sequenceCounts) -// // .sort(([, a], [, b]) => b - a) -// // .slice(0, topN); -// // console.log("sorted sequences: " + sequenceCounts[sortedSequences[0]]) -// // // return sortedSequences.map(([sequence]) => sequence); -// // return topSequences -// // } -// 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((sequenceList: string[]) => { -// const sequenceKey = JSON.stringify(sequenceList); // Convert the list to a string key -// -// 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); -// console.log("Sorted w counts: " + sortedSequences) -// console.log(sortedSequences.map(([sequenceKey, count]) => JSON.parse(sequenceKey, sequenceCounts[sequenceKey]))) -// const topSequences = sortedSequences.map(([sequenceKey, count]) => JSON.parse(sequenceKey, sequenceCounts[sequenceKey])); -// return topSequences; -// } -// -// 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 }; -// top5Sequences: string[]; -// } => { -// const edgeCounts: { [key: string]: number } = {}; -// const totalNodeEdges: { [key: string]: number } = {}; -// const ratioEdges: { [key: string]: number } = {}; -// const edgeOutcomeCounts: { [key: string]: { [outcome: string]: number } } = {}; -// let maxEdgeCount = 0; -// -// 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, top5Sequences}; -// }; -// -// -// // // Function to normalize edge thicknesses based on their ratio -// // export function normalizeThicknesses( -// // 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 -// function calculateColor(sequence: string[], 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: string -// ): string { -// // const stepsInSelectedSequence = selectedSequence//.split('->'); -// // console.log(mostCommonSequence) -// // console.log(selectedSequence[stepsInSelectedSequence]) -// const totalSteps = selectedSequence.length; -// -// console.log("selected sequence" + selectedSequence) -// console.log("totalSteps" + totalSteps) -// // Create node definitions in the DOT string -// let dotString = 'digraph G {\n'; -// Object.keys(edgeCounts).forEach((sourceNode) => { -// // Determine the rank of the node in the selected sequence -// const rank = selectedSequence.indexOf(sourceNode) + 1; -// const color = rank > 0 ? calculateColor(selectedSequence, rank, totalSteps) : '#FFFFFF'; // Default to white if not in sequence -// -// // dotString += `"${sourceNode}" [style=filled, fillcolor="${color}"];\n`; -// -// // for (let rank = 0; rank < totalSteps; rank++) { -// // const step = stepsInSelectedSequence[rank]; -// // const color = calculateColor(selectedSequence, rank, totalSteps); -// // console.log(color, step) -// const node_tooltip = `Rank:\n\t\t ${rank + 1}\nColor:\n\t\t ${color}`; -// -// dotString += ` "${sourceNode}" [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/SequenceSelector.tsx b/src/components/SequenceSelector.tsx index 92d9e5c..b5a74d5 100644 --- a/src/components/SequenceSelector.tsx +++ b/src/components/SequenceSelector.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {SequenceCount} from "@/Context"; interface SequenceSelectorProps { - sequences: SequenceCount[] | null; + sequences: SequenceCount[]|null; selectedSequence: string[] | null; onSequenceSelect: (sequence: string[]) => void; } @@ -26,7 +26,7 @@ const SequenceSelector: React.FC = ({ // }) return (
- onSequenceSelect([e.target.value])}> {sequences.map((seq: SequenceCount) => (