merge: Update graph plot (#8)

feat: updating graphing tool to compute for B.E. M.Sc. & Phoenix for better UI/UX
This commit is contained in:
vee1e 2026-02-15 02:40:10 +05:30 committed by GitHub
parent 7d89d7717c
commit c54957a389
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import DynamicDropdownForm from "./Dropdown"; import DynamicDropdownForm from "./Dropdown";
import Plotly from "plotly.js-basic-dist"; import Plotly from "plotly.js-basic-dist";
import createPlotlyComponent from "react-plotly.js/factory"; import createPlotlyComponent from "react-plotly.js/factory";
@ -22,11 +22,7 @@ interface Trace {
mode: "lines+markers"; mode: "lines+markers";
line?: { color?: string; width?: number }; line?: { color?: string; width?: number };
marker?: { size?: number; line?: { width?: number; color?: string } }; marker?: { size?: number; line?: { width?: number; color?: string } };
} visible?: boolean;
interface PlotParams {
data?: Trace[];
layout?: any;
} }
const Plot = createPlotlyComponent(Plotly); const Plot = createPlotlyComponent(Plotly);
@ -35,6 +31,49 @@ const PILANI = 0,
GOA = 1, GOA = 1,
HYDERABAD = 2; HYDERABAD = 2;
// Phoenix branch keywords (flexible matching)
const PHOENIX_KEYWORDS = [
"computer science",
"electrical",
"electronics",
"math",
];
// Check if a branch is phoenix (electrical/cse related)
function isPhoenixBranch(branchName: string): boolean {
const lower = branchName.toLowerCase();
return PHOENIX_KEYWORDS.some((keyword) => lower.includes(keyword));
}
// Check branch type
function getBranchType(branchName: string): "be" | "msc" | "other" {
const lower = branchName.toLowerCase();
// M.Sc branches
if (
lower.includes("m.sc") ||
lower.includes("biology") ||
lower.includes("chemistry") ||
lower.includes("economics") ||
lower.includes("mathematics") ||
lower.includes("physics") ||
lower.includes("general studies")
) {
return "msc";
}
// B.E. branches
if (
lower.includes("engineering") ||
lower.includes("b.e") ||
lower.includes("computer science")
) {
return "be";
}
return "other";
}
const formConfig = [ const formConfig = [
{ {
key: "campus", key: "campus",
@ -49,7 +88,10 @@ const formConfig = [
function GraphPlot() { function GraphPlot() {
const { theme } = useTheme(); const { theme } = useTheme();
const [graph, setGraph] = useState<PlotParams>({}); const [allBranches, setAllBranches] = useState<BranchData[]>([]);
const [visibleTraces, setVisibleTraces] = useState<Map<string, Trace>>(
new Map(),
);
const [isLoaded, setIsLoaded] = useState<boolean>(false); const [isLoaded, setIsLoaded] = useState<boolean>(false);
const [formData, setForm] = useState<{ campus: number; [key: string]: any }>({ const [formData, setForm] = useState<{ campus: number; [key: string]: any }>({
campus: PILANI, campus: PILANI,
@ -58,6 +100,25 @@ function GraphPlot() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
// Queues for adding and removing branches progressively
const [renderQueue, setRenderQueue] = useState<string[]>([]);
const [removeQueue, setRemoveQueue] = useState<string[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
// Filters
const [showBE, setShowBE] = useState(true);
const [showMSc, setShowMSc] = useState(true);
const [showPhoenixOnly, setShowPhoenixOnly] = useState(false);
// Refs for tracking state - used to avoid dependency cycles
const visibleTracesRef = useRef<Map<string, Trace>>(new Map());
const allBranchesRef = useRef<BranchData[]>([]);
// Keep ref in sync with state
useEffect(() => {
visibleTracesRef.current = visibleTraces;
}, [visibleTraces]);
useEffect(() => { useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 640); const check = () => setIsMobile(window.innerWidth < 640);
check(); check();
@ -65,10 +126,156 @@ function GraphPlot() {
return () => window.removeEventListener("resize", check); return () => window.removeEventListener("resize", check);
}, []); }, []);
// Determine if a branch should be visible based on current filters
const shouldShowBranch = useCallback(
(branch: BranchData): boolean => {
const type = getBranchType(branch.name);
const isPhoenix = isPhoenixBranch(branch.name);
// Phoenix only filter takes precedence - if enabled, only show phoenix branches
if (showPhoenixOnly) {
return (
isPhoenix &&
((type === "be" && showBE) ||
(type === "msc" && showMSc) ||
type === "other")
);
}
// Otherwise apply BE/MSc filters
if (type === "be" && !showBE) return false;
if (type === "msc" && !showMSc) return false;
return true;
},
[showBE, showMSc, showPhoenixOnly],
);
// Get all branches that should be visible
const getVisibleBranches = useCallback(
(branches: BranchData[]): BranchData[] => {
return branches.filter(shouldShowBranch);
},
[shouldShowBranch],
);
// Progressive processing effect - handles both adding and removing branches
useEffect(() => {
// Stop processing if both queues are empty
if (
(renderQueue.length === 0 && removeQueue.length === 0) ||
!isProcessing
) {
setIsProcessing(false);
return;
}
const timer = setTimeout(() => {
// Process remove queue first (remove one branch)
if (removeQueue.length > 0) {
const branchName = removeQueue[0];
setVisibleTraces((prev) => {
const next = new Map(prev);
next.delete(branchName);
return next;
});
setRemoveQueue((prev) => prev.slice(1));
}
// Then process render queue (add one branch)
else if (renderQueue.length > 0) {
const branchName = renderQueue[0];
const branch = allBranchesRef.current.find(
(b) => b.name === branchName,
);
if (branch) {
const newTrace: Trace = {
x: branch.years,
y: branch.marks,
name: branch.name,
type: "scatter",
mode: "lines+markers",
};
setVisibleTraces((prev) => {
const next = new Map(prev);
next.set(branch.name, newTrace);
return next;
});
}
setRenderQueue((prev) => prev.slice(1));
}
}, 0); // 0ms delay - render instantly
return () => clearTimeout(timer);
}, [renderQueue, removeQueue, isProcessing]);
// Smart filter change handler - only adds/removes what changed
// This effect runs when filters change, using refs to avoid dependency on visibleTraces
useEffect(() => {
if (!isLoaded || allBranches.length === 0) return;
// Use setTimeout to access the latest ref values without adding dependencies
const timeoutId = setTimeout(() => {
// Calculate which branches should be visible now
const shouldBeVisible = new Set(
getVisibleBranches(allBranches).map((b) => b.name),
);
// Calculate which branches are currently visible (from ref to avoid dependency)
const currentlyVisible = new Set(visibleTracesRef.current.keys());
// Branches to add (should be visible but aren't)
const toAdd: string[] = [];
shouldBeVisible.forEach((name) => {
if (!currentlyVisible.has(name)) {
toAdd.push(name);
}
});
// Branches to remove (are visible but shouldn't be)
const toRemove: string[] = [];
currentlyVisible.forEach((name) => {
if (!shouldBeVisible.has(name)) {
toRemove.push(name);
}
});
// Set up queues and start processing
allBranchesRef.current = allBranches;
if (toRemove.length > 0) {
setRemoveQueue(toRemove);
}
if (toAdd.length > 0) {
setRenderQueue(toAdd);
}
if (toRemove.length > 0 || toAdd.length > 0) {
setIsProcessing(true);
}
}, 0);
return () => clearTimeout(timeoutId);
}, [
showBE,
showMSc,
showPhoenixOnly,
allBranches,
isLoaded,
getVisibleBranches,
]);
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
setError(null); setError(null);
setIsLoaded(false); setIsLoaded(false);
setAllBranches([]);
setVisibleTraces(new Map());
setRenderQueue([]);
setRemoveQueue([]);
setIsProcessing(false);
const url = `${import.meta.env.VITE_API_URL}/graph?campus=${formData.campus}`; const url = `${import.meta.env.VITE_API_URL}/graph?campus=${formData.campus}`;
try { try {
@ -76,26 +283,19 @@ function GraphPlot() {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const response: GraphResponse = await res.json(); const response: GraphResponse = await res.json();
const traces: Trace[] = response.branches.map((branch) => ({ if (!response.branches || !Array.isArray(response.branches)) {
x: branch.years, throw new Error("Invalid response: branches data missing");
y: branch.marks, }
name: branch.name,
type: "scatter",
mode: "lines+markers",
}));
setGraph({ setAllBranches(response.branches);
data: traces, allBranchesRef.current = response.branches;
layout: {
title: { // Calculate initial visible branches
text: "Cutoff Trends", const visible = getVisibleBranches(response.branches);
font: {
family: '"JetBrains Mono", monospace', // Start progressive rendering
size: isMobile ? 14 : 18, setRenderQueue(visible.map((b) => b.name));
}, setIsProcessing(true);
},
},
});
setIsLoaded(true); setIsLoaded(true);
} catch (err) { } catch (err) {
console.error("Failed to load Plot. Error: ", err); console.error("Failed to load Plot. Error: ", err);
@ -131,6 +331,8 @@ function GraphPlot() {
? { l: 40, r: 8, t: 20, b: 80 } ? { l: 40, r: 8, t: 20, b: 80 }
: { l: 50, r: 10, t: 30, b: 50 }; : { l: 50, r: 10, t: 30, b: 50 };
const tracesArray = Array.from(visibleTraces.values());
return ( return (
<div className="w-full max-w-4xl mx-auto"> <div className="w-full max-w-4xl mx-auto">
{/* SELECTION PANEL */} {/* SELECTION PANEL */}
@ -147,6 +349,43 @@ function GraphPlot() {
/> />
</div> </div>
</div> </div>
{/* FILTERS */}
{isLoaded && (
<div className="mt-6 flex flex-wrap justify-center gap-3">
<button
onClick={() => setShowBE(!showBE)}
className={`px-4 py-2 font-bold text-sm uppercase border-2 transition-all ${
showBE
? "bg-[var(--brutal-accent)] text-white border-[var(--brutal-accent)]"
: "bg-transparent text-[var(--brutal-text)] border-[var(--brutal-border)] hover:border-[var(--brutal-accent)]"
}`}
>
B.E.
</button>
<button
onClick={() => setShowMSc(!showMSc)}
className={`px-4 py-2 font-bold text-sm uppercase border-2 transition-all ${
showMSc
? "bg-[var(--brutal-accent)] text-white border-[var(--brutal-accent)]"
: "bg-transparent text-[var(--brutal-text)] border-[var(--brutal-border)] hover:border-[var(--brutal-accent)]"
}`}
>
M.Sc
</button>
<button
onClick={() => setShowPhoenixOnly(!showPhoenixOnly)}
className={`px-4 py-2 font-bold text-sm uppercase border-2 transition-all ${
showPhoenixOnly
? "bg-orange-500 text-white border-orange-500"
: "bg-transparent text-[var(--brutal-text)] border-[var(--brutal-border)] hover:border-orange-500"
}`}
>
Phoenix Only
</button>
</div>
)}
{loading && ( {loading && (
<p className="mt-4 font-bold animate-pulse">GENERATING PLOT...</p> <p className="mt-4 font-bold animate-pulse">GENERATING PLOT...</p>
)} )}
@ -155,12 +394,12 @@ function GraphPlot() {
)} )}
</div> </div>
{/* PLOT CONTAINER */} {/* PLOT CONTAINER - Always visible once loaded */}
{isLoaded && graph.data && ( {isLoaded && (
<div className="brutal-box p-2 sm:p-4 bg-[var(--brutal-bg)] overflow-hidden"> <div className="brutal-box p-2 sm:p-4 bg-[var(--brutal-bg)] overflow-hidden">
<div className="w-full h-[350px] sm:h-[400px] md:h-[500px] relative"> <div className="w-full h-[350px] sm:h-[400px] md:h-[500px] relative">
<Plot <Plot
data={graph.data.map((trace: any) => ({ data={tracesArray.map((trace: any) => ({
...trace, ...trace,
line: { ...trace.line, width: isMobile ? 2 : 3 }, line: { ...trace.line, width: isMobile ? 2 : 3 },
marker: { marker: {
@ -170,7 +409,13 @@ function GraphPlot() {
}, },
}))} }))}
layout={{ layout={{
...graph.layout, title: {
text: "Cutoff Trends",
font: {
family: '"JetBrains Mono", monospace',
size: isMobile ? 14 : 18,
},
},
dragmode: false, dragmode: false,
plot_bgcolor: bgColor, plot_bgcolor: bgColor,
paper_bgcolor: bgColor, paper_bgcolor: bgColor,
@ -180,7 +425,6 @@ function GraphPlot() {
size: isMobile ? 10 : 12, size: isMobile ? 10 : 12,
}, },
xaxis: { xaxis: {
...graph.layout?.xaxis,
gridcolor: gridColor, gridcolor: gridColor,
zerolinecolor: gridColor, zerolinecolor: gridColor,
tickfont: { tickfont: {
@ -198,7 +442,6 @@ function GraphPlot() {
}, },
}, },
yaxis: { yaxis: {
...graph.layout?.yaxis,
gridcolor: gridColor, gridcolor: gridColor,
zerolinecolor: gridColor, zerolinecolor: gridColor,
tickfont: { tickfont: {
@ -216,11 +459,11 @@ function GraphPlot() {
}, },
}, },
legend: { legend: {
...graph.layout?.legend,
...legendConfig, ...legendConfig,
}, },
margin: margins, margin: margins,
autosize: true, autosize: true,
showlegend: tracesArray.length > 0,
}} }}
config={{ config={{
responsive: true, responsive: true,