'use client'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { green } from '../colors'; import { SkillsMatrix } from '../types'; import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ScatterChart, Scatter, ZAxis, } from 'recharts'; type SkillsRadarPoint = { axis: string; observed?: number; self_reported?: number; certified?: number; }; type BubbleDatum = { label: string; value: number; color: string }; type PackedBubble = { x: number; y: number; r: number; label: string; value: number; color: string }; const packBubbles = (data: BubbleDatum[], width: number, height: number, padding: number): PackedBubble[] => { const filtered = data.filter((d) => Number(d.value) > 0); if (filtered.length === 0) return []; const maxVal = Math.max(...filtered.map((d) => d.value)); const maxR = Math.min(width, height) * 0.24; const nodes: PackedBubble[] = filtered .sort((a, b) => b.value - a.value) .map((d) => ({ label: d.label, value: d.value, color: d.color, r: Math.max(6, Math.sqrt(d.value / maxVal) * maxR), x: width / 2, y: height / 2, })); const placed: PackedBubble[] = []; const centerX = width / 2; const centerY = height / 2; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (i === 0) { placed.push({ ...node }); continue; } let angle = 0; let radiusFromCenter = node.r + padding; let found = false; const maxAttempts = 2000; for (let attempt = 0; attempt < maxAttempts; attempt++) { const x = centerX + radiusFromCenter * Math.cos(angle); const y = centerY + radiusFromCenter * Math.sin(angle); const withinBounds = (x - node.r >= 0) && (x + node.r <= width) && (y - node.r >= 0) && (y + node.r <= height); if (withinBounds) { let overlaps = false; for (let j = 0; j < placed.length; j++) { const p = placed[j]; const dx = x - p.x; const dy = y - p.y; const dist2 = dx * dx + dy * dy; const minDist = node.r + p.r + padding; if (dist2 < minDist * minDist) { overlaps = true; break; } } if (!overlaps) { placed.push({ ...node, x, y }); found = true; break; } } angle += Math.PI / 18; // 10 degrees if (angle >= Math.PI * 2) { angle = 0; radiusFromCenter += Math.max(4, node.r * 0.25); } } if (!found) { placed.push({ ...node, x: Math.min(Math.max(node.r, centerX), width - node.r), y: Math.min(Math.max(node.r, centerY), height - node.r) }); } } return placed; }; // Compute up to two centered lines that fit inside a circle of given radius. // Uses a rough char width approximation to avoid expensive text measurement. const buildLabelLines = (label: string, radius: number): { lines: string[]; fontSize: number } => { const clean = String(label || '').trim(); if (!clean) return { lines: [], fontSize: 10 }; // Choose font size based on radius, within reasonable bounds const fontSize = Math.max(8, Math.min(14, Math.floor(radius * 0.45))); const charWidth = fontSize * 0.58; // approx width per character const maxLineWidth = Math.max(0, (radius * 2) * 0.85); const maxCharsPerLine = Math.max(2, Math.floor(maxLineWidth / charWidth)); // Prefer breaking on spaces; fallback to hard cut const words = clean.split(/\s+/); const lines: string[] = []; let current = ''; for (let i = 0; i < words.length; i++) { const w = words[i]; const candidate = current ? current + ' ' + w : w; if (candidate.length <= maxCharsPerLine) { current = candidate; } else { if (current) lines.push(current); current = w.length > maxCharsPerLine ? w.slice(0, Math.max(1, maxCharsPerLine - 1)) + '…' : w; } if (lines.length >= 2) break; } if (lines.length < 2 && current) lines.push(current); // If still too many words, force truncate to 2 lines if (lines.length > 2) lines.length = 2; // Ensure each line within limit for (let i = 0; i < lines.length; i++) { if (lines[i].length > maxCharsPerLine) { lines[i] = lines[i].slice(0, Math.max(1, maxCharsPerLine - 1)) + '…'; } } return { lines, fontSize }; }; type HoverTooltipState = { visible: boolean; x: number; y: number; title: string; body?: string; } | null; const TooltipBox = ({ state }: { state: HoverTooltipState }) => { if (!state || !state.visible) return null; return (
{state.title}
{state.body ? (
{state.body}
) : null}
); }; const Skills = ({ skillsCategoryRadar, headless }: { skillsMatrix?: SkillsMatrix; skillsCategoryRadar?: SkillsRadarPoint[]; headless?: boolean }) => { // const hasMatrix = !!(skillsMatrix && Array.isArray(skillsMatrix.skills) && skillsMatrix.skills.length > 0); const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0); const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 8); const containerRef = useRef(null); const footprintLegendRef = useRef(null); const barLegendRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [footprintChartTooltip, setFootprintChartTooltip] = useState(null); const [footprintLegendTooltip, setFootprintLegendTooltip] = useState(null); const [barLegendTooltip, setBarLegendTooltip] = useState(null); const disableAnimations = useMemo(() => { if (headless) return true; if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { try { return window.matchMedia('print').matches; } catch {} } return false; }, [headless]); useEffect(() => { const measure = () => setContainerWidth(containerRef.current?.clientWidth || 0); measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); }, []); // Track container width changes more reliably (including print/headless layouts) useEffect(() => { const el = containerRef.current; if (!el || typeof ResizeObserver === 'undefined') return; try { const ro = new ResizeObserver((entries) => { for (const entry of entries) { const width = entry.contentRect?.width || el.clientWidth || 0; setContainerWidth(width); } }); ro.observe(el); return () => ro.disconnect(); } catch { // no-op: older engines } }, []); // Nudge layout for headless/print contexts so responsive containers measure correctly useEffect(() => { if (typeof window !== 'undefined') { const id = window.setTimeout(() => { try { window.dispatchEvent(new Event('resize')); } catch {} }, 0); return () => window.clearTimeout(id); } }, []); const combinedBubbleData: BubbleDatum[] = useMemo(() => { return skillsRadarLimited.map((d) => { const vals = [Number(d.observed || 0), Number(d.self_reported || 0), Number(d.certified || 0)]; const nonZero = vals.filter((v) => v > 0); const base = (nonZero.length > 0 ? nonZero : vals); const avg = Math.round(base.reduce((a, b) => a + b, 0) / (base.length || 1)); return { label: d.axis, value: avg, color: green }; }); }, [skillsRadarLimited]); const legendData = useMemo(() => { const total = combinedBubbleData.reduce((sum, d) => sum + d.value, 0); return combinedBubbleData .slice() .sort((a, b) => b.value - a.value) .map((d) => ({ label: d.label, percent: total > 0 ? Math.round((d.value / total) * 100) : 0 })); }, [combinedBubbleData]); return (
{/* Skills Coverage and Breakdown: two charts side-by-side */} {hasRadar && (

Skills Footprint

The bubble chart visualizes individual skills, where bubble size reflects the weight of supporting evidence and placement indicates relative strength across the skill set.

{(() => { const data = combinedBubbleData.map((d, idx) => ({ x: (idx % 4) + 1, y: Math.floor(idx / 4) + 1, value: d.value, label: d.label })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const BubbleTooltip = ({ active, payload }: any) => { if (!active || !payload || !payload.length) return null; const p = payload[0]?.payload; return (
{p?.label}
Score: {p?.value}
); }; return ( } cursor={{ stroke: 'var(--icon-button-secondary)' }} /> ); })()}
{/* Legend */}
{/*
Legend
*/}
{legendData.map((item, idx) => ( ))}
{!headless && }

Skills by Validation Type

The bar chart shows how each skill is supported by self-attested claims, observed practice, or certified evidence.

{ const rect = barLegendRef.current?.getBoundingClientRect(); if (!rect) return; setBarLegendTooltip({ visible: true, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12, title: 'Observed', body: 'Observed: Skills evidenced through third-party activity or recognition.', }); }} onMouseMove={(e) => { if (!barLegendTooltip || !barLegendRef.current) return; const rect = barLegendRef.current.getBoundingClientRect(); setBarLegendTooltip({ ...barLegendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 }); }} onMouseLeave={() => setBarLegendTooltip(null)} > Observed
{ const rect = barLegendRef.current?.getBoundingClientRect(); if (!rect) return; setBarLegendTooltip({ visible: true, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12, title: 'Self-Attested', body: 'Self-Attested: Skills claimed directly by the developer.', }); }} onMouseMove={(e) => { if (!barLegendTooltip || !barLegendRef.current) return; const rect = barLegendRef.current.getBoundingClientRect(); setBarLegendTooltip({ ...barLegendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 }); }} onMouseLeave={() => setBarLegendTooltip(null)} > Self-reported
{ const rect = barLegendRef.current?.getBoundingClientRect(); if (!rect) return; setBarLegendTooltip({ visible: true, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12, title: 'Certified', body: 'Certified: Skills verified by formal credentials or certifications', }); }} onMouseMove={(e) => { if (!barLegendTooltip || !barLegendRef.current) return; const rect = barLegendRef.current.getBoundingClientRect(); setBarLegendTooltip({ ...barLegendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 }); }} onMouseLeave={() => setBarLegendTooltip(null)} > Certified
{!headless && }
)}
); }; export default Skills;