diff --git a/src/components/ExperienceDonutChart/ExperienceDonutChart.jsx b/src/components/ExperienceDonutChart/ExperienceDonutChart.jsx index 8f342c7f02..45a8aa0b01 100644 --- a/src/components/ExperienceDonutChart/ExperienceDonutChart.jsx +++ b/src/components/ExperienceDonutChart/ExperienceDonutChart.jsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import axios from 'axios'; // Added axios import to fix network request errors -import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'; +import { PieChart, Pie, Cell, ResponsiveContainer, Sector } from 'recharts'; import styles from './ExperienceDonutChart.module.css'; const SEGMENT_COLORS = [ @@ -16,11 +16,14 @@ const SEGMENT_COLORS = [ const EXPERIENCE_LABELS = ['0-1 years', '1-3 years', '3-5 years', '5+ years']; -// ✅ Crypto-based RNG (safer than Math.random) -function secureRandomInt(min, max) { - const array = new Uint32Array(1); - crypto.getRandomValues(array); - return min + (array[0] % (max - min + 1)); +function getContrastColor(hexColor) { + const hex = hexColor.replace('#', ''); + const bigint = parseInt(hex, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + return brightness > 150 ? '#111827' : '#ffffff'; } function Spinner() { @@ -32,6 +35,13 @@ function Spinner() { ); } +const TODAY = new Date().toISOString().split('T')[0]; + +const PREFERS_REDUCED_MOTION = + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false; + export default function ExperienceDonutChart() { const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); @@ -44,7 +54,6 @@ export default function ExperienceDonutChart() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeIndex, setActiveIndex] = useState(null); const darkMode = useSelector(state => state.theme.darkMode); const hasFilters = useMemo( @@ -52,15 +61,17 @@ export default function ExperienceDonutChart() { Boolean( appliedFilters.startDate || appliedFilters.endDate || - (appliedFilters.roles?.length ?? 0) > 0, + (appliedFilters.roles?.length ?? 0) > 0 || + startDate || + endDate || + selectedRoles.length > 0, ), - [appliedFilters], + [appliedFilters, startDate, endDate, selectedRoles], ); const fetchData = async () => { setLoading(true); setError(null); - setActiveIndex(null); try { const token = localStorage.getItem('token'); @@ -88,7 +99,7 @@ export default function ExperienceDonutChart() { if (!data || data.length === 0) { setChartData(null); - setLoading(false); + setTotal(0); return; } @@ -120,18 +131,139 @@ export default function ExperienceDonutChart() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [appliedFilters]); - const onRolesChange = e => { - setSelectedRoles(Array.from(e.target.selectedOptions, o => o.value)); + const visibleChartData = useMemo(() => chartData?.filter(d => d.value > 0) ?? [], [chartData]); + + // Hide counts until the sweep animation finishes so they don't bleed through + const [animationDone, setAnimationDone] = useState(false); + useEffect(() => { + setAnimationDone(PREFERS_REDUCED_MOTION); + }, [chartData]); + + const [hoveredIndex, setHoveredIndex] = useState(null); + + const [isMobile, setIsMobile] = useState( + typeof window !== 'undefined' && window.innerWidth < 450, + ); + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < 450); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + const pieMargin = isMobile + ? { top: 5, right: 5, bottom: 5, left: 5 } + : { top: 20, right: 115, bottom: 20, left: 115 }; + + // Renders the hovered segment with a slightly larger outer radius + const renderActiveShape = props => { + const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props; + return ( + + ); + }; + + // Draws the count at the visual center of each segment — only after animation completes + const renderInsideCount = ({ cx, cy, midAngle, innerRadius, outerRadius, value, index }) => { + if (!value || !animationDone) return null; + const isHovered = index === hoveredIndex; + const RADIAN = Math.PI / 180; + // Push centroid outward slightly when hovered to stay centered in the expanded segment + const expandedOuter = isHovered ? outerRadius + 10 : outerRadius; + const radius = (innerRadius - (isHovered ? 3 : 0) + expandedOuter) * 0.5; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + return ( + + {value.toLocaleString()} + + ); + }; + + // Draws name on top line, percentage below — outside the segment, only after animation completes + const renderOutsideLabel = ({ cx, cy, midAngle, outerRadius, name, percent, index }) => { + if (!animationDone) return null; + // On very small screens hide outside labels — inside counts are still visible + if (isMobile) return null; + const isHovered = index === hoveredIndex; + const RADIAN = Math.PI / 180; + const expandedOuter = isHovered ? outerRadius + 10 : outerRadius; + const lineStart = expandedOuter + 8; + const lineEnd = expandedOuter + (isMobile ? 30 : 50); + const sx = cx + lineStart * Math.cos(-midAngle * RADIAN); + const sy = cy + lineStart * Math.sin(-midAngle * RADIAN); + const ex = cx + lineEnd * Math.cos(-midAngle * RADIAN); + const ey = cy + lineEnd * Math.sin(-midAngle * RADIAN); + const isRight = ex > cx; + const elbowX = ex + (isRight ? 18 : -18); + const textX = elbowX + (isRight ? 4 : -4); + const textAnchor = isRight ? 'start' : 'end'; + const pct = `${(percent * 100).toFixed(1)}%`; + const labelColor = darkMode ? '#f8fafc' : '#0f172a'; + const lineColor = darkMode ? '#94a3b8' : '#64748b'; + const nameFontSize = isHovered ? '1.2rem' : '1.05rem'; + const pctFontSize = isHovered ? '1.05rem' : '0.95rem'; + const strokeWidth = isHovered ? 2.5 : 1.5; + + return ( + + + + + {name} + + + {pct} + + + + ); }; const applyFilters = () => { + if (startDate && startDate > TODAY) { + setError('Start date cannot be in the future.'); + return; + } + if (endDate && endDate > TODAY) { + setError('End date cannot be in the future.'); + return; + } if (startDate && endDate && new Date(startDate) > new Date(endDate)) { - setError(null); - setChartData(null); - setTotal(0); - setLoading(false); + setError('Start date must be before end date.'); return; } + setError(null); setAppliedFilters({ startDate, endDate, roles: selectedRoles }); }; @@ -139,51 +271,10 @@ export default function ExperienceDonutChart() { setStartDate(''); setEndDate(''); setSelectedRoles([]); + setError(null); setAppliedFilters({ startDate: '', endDate: '', roles: [] }); }; - const DetailsPanel = () => { - if (!chartData || total === 0) return null; - - return ( -
- {chartData.map((d, idx) => { - const pct = total > 0 ? ((d.value / total) * 100).toFixed(1) : 0; - return ( -
setActiveIndex(idx)} - onMouseLeave={() => setActiveIndex(null)} - > - - {d.name} - {d.value.toLocaleString()} - {pct}% -
- ); - })} -
- ); - }; - - const CustomTooltip = ({ active, payload }) => { - if (!active || !payload?.length) return null; - const d = payload[0]?.payload; - const pct = total > 0 ? ((d.value / total) * 100).toFixed(1) : 0; - - return ( -
- {/* Corrected tooltip to use name and value from payload for visibility */} - {d.name} -
- Count: {d.value} -
- {pct}% of applicants -
- ); - }; - return (
setStartDate(e.target.value)} />
@@ -218,27 +310,38 @@ export default function ExperienceDonutChart() { type="date" className={styles['filter-input']} value={endDate} + max={TODAY} onChange={e => setEndDate(e.target.value)} />
- - +
+ Roles +
+ {[ + 'Frontend Developer', + 'DevOps Engineer', + 'Project Manager', + 'Junior Developer', + 'Full Stack Developer', + ].map(role => ( + + ))} +
+
@@ -247,7 +350,9 @@ export default function ExperienceDonutChart() { Apply