'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) => (
{
try {
if (typeof window !== 'undefined') {
const anchor = `#appendix-skills-cat-${encodeURIComponent(item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''))}`;
const url = `#appendix${anchor}`;
window.location.hash = url;
}
} catch {}
}}
onMouseEnter={(e) => {
const rect = footprintLegendRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left + 12;
const y = e.clientY - rect.top + 12;
setFootprintLegendTooltip({
visible: true,
x,
y,
title: item.label,
body: `${item.label} represents ${item.percent}% of the Developer’s observable technical skills.`,
});
}}
onMouseMove={(e) => {
if (!footprintLegendTooltip || !footprintLegendRef.current) return;
const rect = footprintLegendRef.current.getBoundingClientRect();
setFootprintLegendTooltip({ ...footprintLegendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 });
}}
onMouseLeave={() => setFootprintLegendTooltip(null)}
>
{item.label}
{item.percent}%
))}
{!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;