Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
965bec2
Intial page display configuration with atoms
alishawalunj Oct 31, 2025
7580b0c
Updated display with Search bar, summary section , filter and subject…
alishawalunj Nov 1, 2025
26fe1ce
Update: resized nodes and their display, link types, added search ico…
alishawalunj Nov 1, 2025
37d3445
feat(knowledge-evolution): backend integration
alishawalunj Nov 22, 2025
9b2b11c
fix(knowledge-evolution): fixed fetchKnowledgeEvolutionDataSuccess ac…
alishawalunj Nov 29, 2025
5b48fde
fix(knowledge-evolution): removed loggers and fixed fetching user fro…
alishawalunj Nov 29, 2025
78a3f85
fix(knowledge-evolution): implemented tooltip display as part of hove…
alishawalunj Nov 29, 2025
3bff44c
fix: dark mode implementation
alishawalunj Jan 24, 2026
0927fd7
Fix CSS module import casing
alishawalunj Jan 24, 2026
d558162
Merge branch 'development' into alisha/feature/learner-knowledge-evol…
alishawalunj Jan 30, 2026
e9a7846
merge(development): resolve conflicts in index.js, routes.jsx, URL.js…
Jun 12, 2026
3f59ba1
fix(knowledge-evolution): remove use client, fix blank page on error,…
Jun 17, 2026
f88a458
fix(knowledge-evolution): tooltip follows mouse cursor instead of fix…
Jun 17, 2026
f8b3153
fix(routes): remove extra trailing blank line to pass prettier lint
Jun 17, 2026
5eefb47
fix(knowledge-evolution): add role, tabIndex, and keyboard handler to…
Jun 18, 2026
195232c
fix(knowledge-evolution): replace div role=button with native button …
Jun 18, 2026
1bd04b5
fix(knowledge-evolution): remove console.error to resolve lint warning
Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/actions/bmdashboard/knowledgeEvolutionActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import axios from "axios";
import { toast } from "react-toastify";
import { ENDPOINTS } from "~/utils/URL";
import {
FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST,
FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS,
FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE,
} from "../../constants/bmdashboard/knowledgeEvolutionConstants";


const fetchKnowledgeEvolutionDataSuccess = (data) => ({
type: FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS,
payload: data,
});

const fetchKnowledgeEvolutionDataFailure = (error) => ({
type: FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE,
payload: error,
});

export const fetchKnowledgeEvolutionData = (userId) => {
return async (dispatch) => {
try {
dispatch({ type: FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST });
const url = `${ENDPOINTS.KNOWLEDGE_EVOLUTION}/?studentId=${userId}`;
const res = await axios.get(url);
dispatch(fetchKnowledgeEvolutionDataSuccess(res.data));
return res.data;
} catch (err) {
const errorPayload = err.response?.data || { message: err.message };
dispatch(fetchKnowledgeEvolutionDataFailure(errorPayload));
toast.error(err.response?.data?.error || "Failed to fetch knowledge evolution data");
return null;
}
};
};

323 changes: 323 additions & 0 deletions src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { Funnel, Search } from 'lucide-react';
import styles from './KnowledgeEvolution.module.css';
import { useDispatch, useSelector } from 'react-redux';
import { fetchKnowledgeEvolutionData } from '../../../actions/bmdashboard/knowledgeEvolutionActions';

const KnowledgeEvolution = () => {
const svgRef = useRef();
const dispatch = useDispatch();
const { data, loading, error } = useSelector(state => state.knowledgeEvolution);
const user = useSelector(state => state.auth.user);
const darkMode = useSelector(state => state.theme.darkMode);
const userId = user ? user.userid : null;

useEffect(() => {
dispatch(fetchKnowledgeEvolutionData(userId));
}, [dispatch, userId]);

const [selectedSubject, setSelectedSubject] = useState(null);

useEffect(() => {
if (data?.knowledgeEvolution?.length > 0) {
setSelectedSubject(data.knowledgeEvolution[0]._id);
}
}, [data]);

const allAtoms = data?.knowledgeEvolution?.flatMap(s => s.atoms) || [];
const totalCompleted = allAtoms.filter(a => a.atomStatus === 'completed').length;
const totalInProgress = allAtoms.filter(a => a.atomStatus === 'in_progress').length;
const totalNotStarted = allAtoms.filter(a => a.atomStatus === 'not_started').length;
const savedInterest = 2;
const tooltipRef = useRef(null);
const [tooltipData, setTooltipData] = useState(null);
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });

useEffect(() => {
if (!data || !selectedSubject) return;

const subjectData = data.knowledgeEvolution.find(s => s._id === selectedSubject);
if (!subjectData) return;

const courses = subjectData.atoms || [];

const width = 700;
const height = 500;
const svg = d3.select(svgRef.current);
svg.attr('viewBox', `0 0 ${width} ${height}`);
svg.selectAll('*').remove();

const centerX = width / 2;
const centerY = height / 2;
const subjectRadius = 60;
const courseRadius = 45;
const orbitRadius = 180;

const colorMap = {
completed: '#28a745',
in_progress: '#ffc107',
not_started: '#6c757d',
};

const darkerMap = {
completed: '#1e7e34',
in_progress: '#e0a800',
not_started: '#5a6268',
};

const subjectNode = {
id: subjectData.subjectName,
type: 'subject',
x: centerX,
y: centerY,
};

const courseNodes = courses.map((atom, i) => {
const angle = (2 * Math.PI * i) / (courses.length || 1);
return {
id: atom.atomId,
name: atom.atomName,
status: atom.atomStatus,
type: 'course',
x: centerX + orbitRadius * Math.cos(angle),
y: centerY + orbitRadius * Math.sin(angle),
};
});

const allNodes = [subjectNode, ...courseNodes];

const allLinks = courseNodes.map(atom => {
const dx = atom.x - subjectNode.x;
const dy = atom.y - subjectNode.y;
const angle = Math.atan2(dy, dx);
return {
x1: subjectNode.x + subjectRadius * Math.cos(angle),
y1: subjectNode.y + subjectRadius * Math.sin(angle),
x2: atom.x - courseRadius * Math.cos(angle),
y2: atom.y - courseRadius * Math.sin(angle),
status: atom.status,
};
});

svg
.append('g')
.selectAll('line')
.data(allLinks)
.enter()
.append('line')
.attr('x1', d => d.x1)
.attr('y1', d => d.y1)
.attr('x2', d => d.x2)
.attr('y2', d => d.y2)
.attr('stroke', d => colorMap[d.status])
.attr('stroke-width', 2)
.attr('stroke-dasharray', d => (d.status === 'not_started' ? '6,4' : '0'))
.attr('opacity', 0.95);

svg
.append('g')
.selectAll('circle')
.data(allNodes)
.enter()
.append('circle')
.attr('cx', d => d.x)
.attr('cy', d => d.y)
.attr('r', d => (d.type === 'subject' ? subjectRadius : courseRadius))
.attr('fill', d => {
if (d.type === 'subject') return darkMode ? '#2a3b55' : '#ffffff';
const c = d3.color(colorMap[d.status]);
c.opacity = 0.3;
return c;
})
.attr('stroke', d => (d.type === 'subject' ? '#8b5a00' : darkerMap[d.status]))
.attr('stroke-width', d => (d.type === 'subject' ? 4 : 3));

svg
.append('g')
.selectAll('text')
.data(allNodes)
.enter()
.append('text')
.attr('x', d => d.x)
.attr('y', d => d.y)
.attr('text-anchor', 'middle')
.attr('font-size', d => (d.type === 'subject' ? 18 : 12))
.attr('fill', darkMode ? '#ffffff' : '#222')
.each(function(d) {
const node = d3.select(this);
const words = (d.type === 'subject' ? d.id : d.name || '').split(' ');
let yOffset = -(words.length - 1) * 6;
words.forEach(word => {
node
.append('tspan')
.attr('x', d.x)
.attr('dy', yOffset)
.text(word);
yOffset = 12;
});
});
}, [data, selectedSubject, darkMode]);

if (loading) return <div>Loading Knowledge Evolution...</div>;
if (error) return <div>Failed to load knowledge evolution data. Please try again later.</div>;
if (!data) return <div>No knowledge evolution data available.</div>;
const handleChartMouseEnter = () => {
const subjectData = data?.knowledgeEvolution?.find(s => s._id === selectedSubject);
if (!subjectData) return;
const atoms = subjectData.atoms || [];
const completed = atoms.filter(a => a.atomStatus === 'completed').length;
const inProgress = atoms.filter(a => a.atomStatus === 'in_progress').length;
const notStarted = atoms.filter(a => a.atomStatus === 'not_started').length;

setTooltipData({
subject: subjectData.subjectName,
completed,
inProgress,
notStarted,
});
if (tooltipRef.current) tooltipRef.current.style.visibility = 'visible';
};

const handleChartMouseLeave = () => {
setTooltipData(null);
if (tooltipRef.current) tooltipRef.current.style.visibility = 'hidden';
};

const handleChartMouseMove = e => {
setTooltipPos({ x: e.clientX, y: e.clientY });
};

const handleChartKeyDown = e => {
if (e.key === 'Enter' || e.key === ' ') {
handleChartMouseEnter();
} else if (e.key === 'Escape') {
handleChartMouseLeave();
}
};

return (
<div className={`${darkMode ? styles.pageContainerDarkMode : ''}`}>
<div className={`${styles.pageContainer}`}>
{/* HEADER */}
<div className={`${styles.headerContainer}`}>
<h5>Knowledge Evolution</h5>

{/* SUMMARY */}
<div className={`${styles.summarySection}`}>
<h6 className={`${styles.summaryHeading}`}>Overall Progress Across All Subjects</h6>

<div className={`${styles.summaryStats}`}>
<div className={`${styles.statBox}`}>
<h3 className={`${styles.completedText}`}>{totalCompleted}</h3>
<p>Total Completed</p>
</div>

<div className={`${styles.statBox}`}>
<h3 className={`${styles.inProgressText}`}>{totalInProgress}</h3>
<p>Total In Progress</p>
</div>

<div className={`${styles.statBox}`}>
<h3 className={`${styles.notStartedText}`}>{totalNotStarted}</h3>
<p>Total Not Started</p>
</div>

<div className={`${styles.statBox}`}>
<h3>{savedInterest}</h3>
<p>Saved Interest</p>
</div>
</div>
</div>

{/* SEARCH + FILTER */}
<div className={`${styles.searchFilterContainer}`}>
<div className={`${styles.searchWrapper}`}>
<Search size={18} className={`${styles.searchIcon}`} />
<input
type="text"
placeholder="Search atoms or subjects"
className={`${styles.searchInput}`}
/>
</div>

<button className={`${styles.filterButton}`}>
<Funnel size={18} />
<span>Filter by Subject</span>
</button>
</div>
</div>

{/* SUBJECT TABS */}
<div className={`${styles.subjectTabs}`}>
{data.knowledgeEvolution.map(s => (
<button
key={s._id}
className={`${styles.tabButton} ${selectedSubject === s._id ? styles.activeTab : ''}`}
onClick={() => setSelectedSubject(s._id)}
>
{s.subjectName}
</button>
))}
</div>
<div
ref={tooltipRef}
className={`${styles.subjectTooltipTop}`}
style={tooltipData ? { top: tooltipPos.y - 130, left: tooltipPos.x } : {}}
aria-hidden={!tooltipData}
>
{tooltipData ? (
<>
<div className={`${styles.tooltipTitle}`}>{tooltipData.subject} Progress</div>
<div className={`${styles.tooltipCounts}`}>
<div className={`${styles.tooltipCount}`}>
<span className={`${styles.completedText}`}>{tooltipData.completed}</span>
<div> Completed</div>
</div>

<div className={`${styles.tooltipCount}`}>
<span className={`${styles.inProgressText}`}>{tooltipData.inProgress}</span>
<div> In Progress</div>
</div>

<div className={`${styles.tooltipCount}`}>
<span className={`${styles.notStartedText}`}>{tooltipData.notStarted}</span>
<div> Not Started</div>
</div>
</div>
</>
) : null}
</div>

{/* D3 CHART with subject-level hover */}
<button
type="button"
className={`${styles.chartWrapper}`}
onMouseEnter={handleChartMouseEnter}
onMouseLeave={handleChartMouseLeave}
onMouseMove={handleChartMouseMove}
onFocus={handleChartMouseEnter}
onBlur={handleChartMouseLeave}
onKeyDown={handleChartKeyDown}
>
<svg ref={svgRef} width={700} height={500} />
</button>

{/* Legend placed below chart */}
<div className={`${styles.subjectTooltipBottomLegend}`}>
<div className={`${styles.legendItem}`}>
<span className={`${styles.completedDotSmall}`} /> Completed
</div>
<div className={`${styles.legendItem}`}>
<span className={`${styles.inProgressDotSmall}`} /> In Progress
</div>
<div className={`${styles.legendItem}`}>
<span className={`${styles.notStartedDotSmall}`} /> Not Started
</div>
</div>
</div>
</div>
);
};

export default KnowledgeEvolution;
Loading
Loading