diff --git a/src/actions/bmdashboard/knowledgeEvolutionActions.js b/src/actions/bmdashboard/knowledgeEvolutionActions.js
new file mode 100644
index 0000000000..2e629aca9b
--- /dev/null
+++ b/src/actions/bmdashboard/knowledgeEvolutionActions.js
@@ -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;
+ }
+ };
+};
+
diff --git a/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.jsx b/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.jsx
new file mode 100644
index 0000000000..e346fd64a3
--- /dev/null
+++ b/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.jsx
@@ -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
Loading Knowledge Evolution...
;
+ if (error) return Failed to load knowledge evolution data. Please try again later.
;
+ if (!data) return No knowledge evolution data available.
;
+ 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 (
+
+
+ {/* HEADER */}
+
+
Knowledge Evolution
+
+ {/* SUMMARY */}
+
+
Overall Progress Across All Subjects
+
+
+
+
{totalCompleted}
+
Total Completed
+
+
+
+
{totalInProgress}
+
Total In Progress
+
+
+
+
{totalNotStarted}
+
Total Not Started
+
+
+
+
{savedInterest}
+
Saved Interest
+
+
+
+
+ {/* SEARCH + FILTER */}
+
+
+
+
+
+
+
+
+
+
+ {/* SUBJECT TABS */}
+
+ {data.knowledgeEvolution.map(s => (
+
+ ))}
+
+
+ {tooltipData ? (
+ <>
+
{tooltipData.subject} Progress
+
+
+
{tooltipData.completed}
+
Completed
+
+
+
+
{tooltipData.inProgress}
+
In Progress
+
+
+
+
{tooltipData.notStarted}
+
Not Started
+
+
+ >
+ ) : null}
+
+
+ {/* D3 CHART with subject-level hover */}
+
+
+ {/* Legend placed below chart */}
+
+
+ Completed
+
+
+ In Progress
+
+
+ Not Started
+
+
+
+
+ );
+};
+
+export default KnowledgeEvolution;
diff --git a/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.module.css b/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.module.css
new file mode 100644
index 0000000000..f103909cec
--- /dev/null
+++ b/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.module.css
@@ -0,0 +1,358 @@
+.pageContainer {
+ padding: 10px;
+ font-family: Inter, sans-serif;
+ position: relative;
+}
+
+.pageContainerDarkMode {
+ background-color: #1b2a41;
+ color: #fff;
+}
+
+.headerContainer {
+ margin-bottom: 20px;
+ font-weight: 800;
+}
+
+.summarySection {
+ text-align: left;
+ margin-bottom: 20px;
+ border: 1px solid #ddd;
+ border-radius: 10px;
+ padding: 15px;
+ background: #fff;
+}
+
+.pageContainerDarkMode .summarySection {
+ background-color: #2a3b55;
+ border-color: #444;
+}
+
+.summaryHeading {
+ font-size: 16px;
+ margin-bottom: 10px;
+}
+
+.summaryStats {
+ display: flex;
+ gap: 25px;
+}
+
+.statBox {
+ background: #fff;
+ padding: 12px 20px;
+ text-align: center;
+ flex: 1;
+}
+
+.pageContainerDarkMode .statBox {
+ background-color: #3a4b65;
+}
+
+.statBox h3 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+}
+
+.statBox p {
+ margin: 5px 0 0;
+ font-size: 13px;
+ color: #555;
+}
+
+.pageContainerDarkMode .statBox p {
+ color: #aaa;
+}
+
+.searchFilterContainer {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.searchWrapper {
+ position: relative;
+ display: inline-block;
+}
+
+.searchIcon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #888;
+}
+
+.searchInput {
+ flex: 0.6;
+ padding: 8px 12px;
+ border: 1.3px solid #ccc;
+ border-radius: 6px;
+ font-size: 14px;
+ padding-left: 35px;
+ height: 38px;
+}
+
+.pageContainerDarkMode .searchInput {
+ background-color: #3a4b65;
+ border-color: #555;
+ color: #eee;
+}
+
+.filterButton {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background-color: #f0f0f0;
+ border: 1.3px solid #ccc;
+ border-radius: 6px;
+ padding: 8px 12px;
+ cursor: pointer;
+}
+
+.pageContainerDarkMode .filterButton {
+ background-color: #3a4b65;
+ border-color: #555;
+ color: #eee;
+}
+
+.filterButton:hover {
+ background-color: #e6e6e6;
+}
+
+.chartWrapper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border: 1.5px solid #ccc;
+ background: #fafafa;
+ padding: 15px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 10%);
+ position: relative;
+ min-height: 420px;
+ margin-bottom: 12px;
+ width: 100%;
+ cursor: default;
+ font: inherit;
+ color: inherit;
+ text-align: left;
+}
+
+.pageContainerDarkMode .chartWrapper {
+ background-color: #2a3b55;
+ border-color: #444;
+}
+
+.subjectTabs {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 20px;
+ border: 2px solid #ddd;
+ padding-bottom: 5px;
+ margin-bottom: 12px;
+}
+
+.pageContainerDarkMode .subjectTabs {
+ border-color: #444;
+}
+
+.tabButton {
+ background: none;
+ border: none;
+ font-size: 15px;
+ font-weight: 500;
+ padding: 8px 14px;
+ cursor: pointer;
+ color: #555;
+ position: relative;
+ overflow-wrap: break-word;
+ white-space: normal;
+ max-width: 160px;
+ text-align: center;
+}
+
+.tabButton:hover {
+ color: #000;
+}
+
+.pageContainerDarkMode .tabButton {
+ color: #ccc;
+}
+
+.pageContainerDarkMode .tabButton:hover {
+ color: #fff;
+}
+
+.activeTab {
+ color: orange;
+ font-weight: 600;
+}
+
+.activeTab::after {
+ content: '';
+ position: absolute;
+ bottom: -6px;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background-color: orange;
+}
+
+.subjectTooltipTop {
+ position: fixed;
+ transform: translateX(-50%);
+ background: white;
+ padding: 12px 18px;
+ border-radius: 10px;
+ box-shadow: 0 6px 20px rgb(0 0 0 / 12%);
+ font-size: 13px;
+ min-width: 320px;
+ max-width: 720px;
+ visibility: hidden;
+ z-index: 60;
+ text-align: center;
+ pointer-events: none;
+}
+
+.pageContainerDarkMode .subjectTooltipTop {
+ background-color: #3a4b65;
+ color: #eee;
+ box-shadow: 0 6px 20px rgb(0 0 0 / 40%);
+ border: 1.5px solid #555;
+}
+
+.tooltipTitle {
+ font-weight: 600;
+ margin-bottom: 8px;
+ font-size: 15px;
+ color: #222;
+}
+
+.pageContainerDarkMode .tooltipTitle {
+ color: #eee;
+}
+
+.tooltipCounts {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 0;
+}
+
+.tooltipCount {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ gap: 2px;
+}
+
+.completedDot,
+.inProgressDot,
+.notStartedDot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ display: inline-block;
+}
+
+.completedDot {
+ background: #28a745;
+}
+
+.inProgressDot {
+ background: #ffc107;
+}
+
+.notStartedDot {
+ background: #6c757d;
+}
+
+.subjectTooltipBottomLegend {
+ display: flex;
+ justify-content: center;
+ gap: 24px;
+ align-items: center;
+ padding: 10px 6px;
+ background: transparent;
+ z-index: 40;
+}
+
+.legendItem {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ font-size: 13px;
+ color: #444;
+}
+
+.pageContainerDarkMode .legendItem {
+ color: #ccc;
+}
+
+.completedDotSmall,
+.inProgressDotSmall,
+.notStartedDotSmall {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+}
+
+.completedDotSmall {
+ background: #28a745;
+}
+
+.inProgressDotSmall {
+ background: #ffc107;
+}
+
+.notStartedDotSmall {
+ background: #6c757d;
+}
+
+@media (width <= 800px) {
+ .subjectTooltipTop {
+ min-width: 260px;
+ }
+
+ .tooltipCounts {
+ flex-direction: column;
+ gap: 6px;
+ align-items: center;
+ }
+
+ .tooltipCount {
+ justify-content: flex-start;
+ width: 100%;
+ padding-left: 12px;
+ }
+
+ .chartWrapper {
+ min-height: 360px;
+ }
+
+ .subjectTooltipBottomLegend {
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+}
+
+.completedText {
+ color: #28a745;
+ font-weight: 600;
+}
+
+.inProgressText {
+ color: #ffc107;
+ font-weight: 600;
+}
+
+.notStartedText {
+ color: #6c757d;
+ font-weight: 600;
+}
diff --git a/src/constants/bmdashboard/knowledgeEvolutionConstants.js b/src/constants/bmdashboard/knowledgeEvolutionConstants.js
new file mode 100644
index 0000000000..df8bb9c7ea
--- /dev/null
+++ b/src/constants/bmdashboard/knowledgeEvolutionConstants.js
@@ -0,0 +1,3 @@
+export const FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST = 'FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST';
+export const FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS = 'FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS';
+export const FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE = 'FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE';
diff --git a/src/reducers/bmdashboard/knowledgeEvolutionReducer.js b/src/reducers/bmdashboard/knowledgeEvolutionReducer.js
new file mode 100644
index 0000000000..f1815d9d2c
--- /dev/null
+++ b/src/reducers/bmdashboard/knowledgeEvolutionReducer.js
@@ -0,0 +1,27 @@
+import {
+ FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST,
+ FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS,
+ FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE,
+} from '../../constants/bmdashboard/knowledgeEvolutionConstants';
+
+const initialState = {
+ loading: false,
+ data: null,
+ error: null,
+};
+
+// eslint-disable-next-line default-param-last
+const knowledgeEvolutionReducer = (state = initialState, action) => {
+ switch (action.type) {
+ case FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST:
+ return { ...state, loading: true, error: null };
+ case FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS:
+ return { ...state, loading: false, data: action.payload };
+ case FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE:
+ return { ...state, loading: false, error: action.payload };
+ default:
+ return state;
+ }
+};
+
+export default knowledgeEvolutionReducer;
diff --git a/src/reducers/index.js b/src/reducers/index.js
index 68ca63e94b..c2a0be0e9c 100644
--- a/src/reducers/index.js
+++ b/src/reducers/index.js
@@ -70,6 +70,7 @@ import issueReducer from './bmdashboard/issueReducer';
import dashboardReducer from './dashboardReducer';
import HGNFormReducer from './hgnFormReducers';
import injuriesReducer from './injuries';
+import knowledgeEvolutionReducer from './bmdashboard/knowledgeEvolutionReducer';
import { timeOffRequestsReducer } from './timeOffRequestReducer';
import { totalOrgSummaryReducer } from './totalOrgSummaryReducer';
// import { weeklyProjectSummaryReducer } from './bmdashboard/weeklyProjectSummaryReducer';
@@ -173,6 +174,7 @@ const localReducers = {
dashboard: dashboardReducer,
injuries: injuriesReducer,
weeklyProjectSummary: weeklyProjectSummaryReducer,
+ knowledgeEvolution: knowledgeEvolutionReducer,
costBreakdown: costBreakdownReducer,
// lbdashboard
diff --git a/src/routes.jsx b/src/routes.jsx
index f2fc47c514..70061df044 100644
--- a/src/routes.jsx
+++ b/src/routes.jsx
@@ -53,6 +53,7 @@ import EquipmentUpdate from './components/BMDashboard/Tools/EquipmentUpdate';
import Toolslist from './components/BMDashboard/Tools/ToolsList';
import WeeklyProjectSummary from './components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary';
import AnalyticsDashboard from './components/JobCCDashboard/JobAnalytics/JobAnalytics';
+import KnowledgeEvolution from './components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution';
import { ExperienceDonutChart } from './components/ExperienceDonutChart';
import FaqHistory from './components/Faq/FaqHistory';
import FaqManagement from './components/Faq/FaqManagement';
@@ -956,6 +957,11 @@ export default (
exact
component={ToolsAvailabilityPage}
/>
+
`${APIEndpoint}/bm/orgLocation/${projectId}`,
BM_PROJECT_MEMBERS: projectId => `${APIEndpoint}/bm/project/${projectId}/users`,
+ KNOWLEDGE_EVOLUTION: `${APIEndpoint}/student/knowledge-evolution`,
BM_UPDATE_NAME_AND_UNIT: invtypeId => `${APIEndpoint}/bm/invtypes/material/${invtypeId}`,
BM_ITEM_UPDATE_HISTORY: invtypeId => `${APIEndpoint}/bm/invtypes/${invtypeId}/history`,