"use client"; import React, { useMemo, useRef, useState, useEffect } from 'react'; import { BubbleChart } from '@knowyourdeveloper/react-bubble-chart'; import '@knowyourdeveloper/react-bubble-chart/style.css'; import { green1, green2, green3, green4, green5 } from '../colors'; import { ProviderIcon, getProviderDisplayName } from '../utils/provider'; import { providers } from '../types'; type SkillsRadarPoint = { axis: string; observed?: number; self_reported?: number; certified?: number; experience?: number; // 0-100 saturation driver // Total evidence count (backend computed) per category evidence_count_total?: number; }; type HoverTooltipState = { visible: boolean; x: number; y: number; title?: string; body?: React.ReactNode; } | null; const TooltipBox = ({ state }: { state: HoverTooltipState }) => { if (!state || !state.visible) return null; return (
{state.title ?
{state.title}
: null} {state.body ? (
{state.body}
) : null}
); }; export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, skillsMeta, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record; skillsMeta?: Record; years?: number; sources?: string[]; evidenceCount?: number }>; headless?: boolean }) { const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0); const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 24); const containerRef = useRef(null); const legendRef = useRef(null); const [legendTooltip, setLegendTooltip] = useState(null); const [activeCategory, setActiveCategory] = useState(null); const [legendHovered, setLegendHovered] = useState(false); useEffect(() => { if (typeof window !== 'undefined') { const id = window.setTimeout(() => { try { window.dispatchEvent(new Event('resize')); } catch {} }, 0); return () => window.clearTimeout(id); } }, []); // Tooltip helpers for legend/columns area const computeTooltipPosition = (target: HTMLElement) => { const host = legendRef.current; if (!host) return { x: 0, y: 0 }; const rect = target.getBoundingClientRect(); const hostRect = host.getBoundingClientRect(); const x = rect.left - hostRect.left; const y = rect.bottom - hostRect.top + 6; return { x, y }; }; const showLegendTooltipAt = (target: EventTarget | null, title?: string, body?: React.ReactNode) => { if (!(target instanceof HTMLElement)) return; const { x, y } = computeTooltipPosition(target); setLegendTooltip({ visible: true, x, y, title, body }); }; const hideLegendTooltip = () => setLegendTooltip(null); // ratio drives size by default; prefer backend evidence_count_total when present const bubbles = useMemo(() => { const seriesAvg = (d: SkillsRadarPoint): number => { 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); return Math.max(0, Math.min(100, Math.round(base.reduce((a, b) => a + b, 0) / (base.length || 1)))); }; // Prefer evidence_count_total across categories if available; fallback to ratio const evidenceTotals = (skillsCategoryRadar || []).map((d) => Number((d as any).evidence_count_total || 0)); const evidenceAvailable = evidenceTotals.some((v) => v > 0); const maxValue = evidenceAvailable ? Math.max(1, ...evidenceTotals) : Math.max(1, ...skillsRadarLimited.map(seriesAvg)); return skillsRadarLimited.map((d) => { const ratio = seriesAvg(d); const evidenceCountTotal = Number((d as any).evidence_count_total || 0); const valueRaw = evidenceAvailable ? evidenceCountTotal : ratio; const value = Math.max(0, Number(valueRaw)); const experience = Math.max(0, Math.min(100, Number(d.experience || 0))); const size = Math.max(2, Math.round((value / maxValue) * 100)); // 2..100 const color = 'var(--content-card-background)'; return { label: d.axis, value: size, color, tooltip: evidenceAvailable ? `${d.axis}\nEvidence: ${value} sources\nExperience: ${experience}` : `${d.axis}\nRatio: ${ratio}\nExperience: ${experience}`, data: { ratio, experience, evidence: value } }; }); }, [skillsRadarLimited, skillsCategoryRadar]); const bubbleData = useMemo(() => { return bubbles.map((b) => ({ _id: String(b.label || ''), value: Number(b.value || 0), displayText: String(b.label || ''), colorValue: Number(b.data?.experience || 0), selected: legendHovered && !!activeCategory && String(b.label || '') === String(activeCategory) })); }, [bubbles, legendHovered, activeCategory]); const colorLegend = useMemo(() => { return [green5, green4, green3, green2, green1]; }, []); const defaultActiveCategory = useMemo(() => { let best: { label: string; ratio: number } | null = null; for (const b of bubbles) { const ratio = Number(b.data?.ratio || 0); if (!best || ratio > best.ratio) best = { label: b.label, ratio }; } return best?.label || (skillsRadarLimited[0]?.axis || null); }, [bubbles, skillsRadarLimited]); useEffect(() => { if (!activeCategory && defaultActiveCategory) setActiveCategory(defaultActiveCategory); }, [activeCategory, defaultActiveCategory]); const skillsGrid = useMemo(() => { const cat = activeCategory || ''; const list = (skillsByCategory && cat in (skillsByCategory || {})) ? (skillsByCategory?.[cat] || []) : []; // Enrich with meta and sort by years desc const enriched = list.map((name) => { const meta = skillsMeta?.[name] || {}; const sources = Array.isArray((meta as any).sources) ? (meta as any).sources as string[] : []; const presenceTypes = Array.isArray((meta as any).presenceTypes) ? ((meta as any).presenceTypes as Array<'certified' | 'observed' | 'self-reported'>) : ((meta as any).presence ? [String((meta as any).presence) as any] : []); return { name, years: Number(meta.years || 0), presence: (meta as any).presence as string | undefined, presenceTypes, sources }; }).sort((a, b) => b.years - a.years || a.name.localeCompare(b.name)); const items = enriched.slice(0, 10); const overflow = list.length - items.length; const display: Array<{ label: string; years?: number; presence?: string; presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>; sources?: string[] } | ''> = []; for (let i = 0; i < Math.min(9, items.length); i++) { const it = items[i]; const label = it.name ? `${it.name}` : ''; display.push({ label, years: it.years, presence: it.presence, presenceTypes: it.presenceTypes, sources: it.sources }); } if (list.length > 10) { // Aggregate years and sources for remaining datapoints beyond the top 10 const remaining = enriched.slice(10); const aggregatedYears = Math.round(remaining.reduce((sum, it) => sum + Number(it.years || 0), 0) * 10) / 10; const aggregatedSourcesSet = new Set(); for (const it of remaining) { if (Array.isArray(it.sources)) { for (const src of it.sources) aggregatedSourcesSet.add(String(src)); } } const aggregatedSources = Array.from(aggregatedSourcesSet); display.push({ label: `Others (${overflow})`, years: aggregatedYears, sources: aggregatedSources }); } else if (items.length >= 10) { const it = items[9]; display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, presenceTypes: it?.presenceTypes, sources: it?.sources }); } else { if (items.length > 9) { const it = items[9]; display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, presenceTypes: it?.presenceTypes, sources: it?.sources }); } } while (display.length < 10) display.push(''); return display; }, [activeCategory, skillsByCategory, skillsMeta]); // Compute category percent share (by ratio) for header display const categoryPercentMap = useMemo(() => { const total = bubbles.reduce((sum, b) => sum + (b.data?.ratio || 0), 0); const map: Record = {}; for (const b of bubbles) { const p = total > 0 ? Math.round(((b.data?.ratio || 0) / total) * 100) : 0; map[b.label] = p; } return map; }, [bubbles]); const presenceColor = (presence?: string) => { const p = String(presence || '').toLowerCase(); if (p === 'self-reported') return green5; if (p === 'observed') return green2; if (p === 'certified') return green1; return 'var(--icon-button-secondary)'; }; const presenceLegendTooltip = (): { body: React.ReactNode } => { const Row = ({ color, label }: { color: string; label: string }) => (
{label}
); return { body: (
) }; }; const experienceLegendTooltip = (): { title: string; body: React.ReactNode } => { return { title: 'Experience', body: (
Calculated as time between first and most recent observed evidence across repositories.
) }; }; const leftColumnGrid = useMemo(() => (skillsGrid || []).slice(0, 5), [skillsGrid]); const rightColumnGrid = useMemo(() => (skillsGrid || []).slice(5, 10), [skillsGrid]); if (!hasRadar) return null; const columnComponent = (entry: { label: string; years?: number; presence?: string; presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>; sources?: string[] } | '', idx: number, isLeft: boolean) => { return (
{entry ? {idx + (isLeft ? 1 : 6)}. : \u00A0}
{entry && typeof entry !== 'string' ? ( {entry.label} {/* Evidence count bullet */} {(() => { const meta = skillsMeta?.[entry.label]; const count = Number((meta as any)?.evidenceCount || 0); return count > 0 ? ( <> {' '}•{' '} showLegendTooltipAt( e.currentTarget, 'Evidence count', 'Total number of sources across observed, certified, and self-reported evidence for this skill.' ) } onMouseLeave={hideLegendTooltip} > {count} ) : null; })()} ) : ( {typeof entry === 'string' ? entry : '\u00A0'} )}
{entry && typeof entry !== 'string' ? ( <> showLegendTooltipAt(e.currentTarget, 'Sources', 'The source where we observed this skill.')} onMouseLeave={hideLegendTooltip} > Sources: {Array.isArray((entry as any).sources) && (entry as any).sources.length > 0 ? ( (() => { const sourceProviders: string[] = ((entry as any).sources as string[]).map((src: string) => { const str = String(src); let provider = str.split(':')[0] || ''; if (!provider || provider === str) { // If split(':')[0] didn't find a delimiter or provider (i.e., no ':'), try split('.') provider = str.split('.')[0] || ''; } return provider.toLowerCase(); }); const uniqueProviders = Array.from(new Set(sourceProviders)); const filteredProviders = uniqueProviders.filter((provider) => providers.includes(provider.toLowerCase()) ); return filteredProviders.map((provider) => ( showLegendTooltipAt( e.currentTarget, undefined, getProviderDisplayName(provider) ) } onMouseLeave={hideLegendTooltip} className="inline-flex items-center" > )); })() ) : null} ) : ( '\u00A0' )}
{entry && typeof entry !== 'string' ? ( entry.years ? ( { const copy = experienceLegendTooltip(); showLegendTooltipAt(e.currentTarget, copy.title, copy.body); }} onMouseLeave={hideLegendTooltip} > {`${entry.years} Years`} ) : ( N/A ) ) : ( N/A )}
{ if (entry && typeof entry !== 'string') { const copy = presenceLegendTooltip(); showLegendTooltipAt(e.currentTarget, undefined, copy.body); } }} onMouseLeave={(e) => { if (entry && typeof entry !== 'string') hideLegendTooltip(); }} className="pt-1" > {(() => { if (entry && typeof entry !== 'string') { const types = Array.isArray(entry.presenceTypes) ? entry.presenceTypes : (entry.presence ? [String(entry.presence) as any] : []); const hasAny = types.length > 0; return hasAny ? (
{types.map((t) => ( ))}
) : .; } return .; })()}
) } return (
{!headless && (
Size = evidence count per category
Color = experience (darker = more)
)} { try { if (typeof window !== 'undefined') { const label = String(d.displayText || d._id || ''); const anchor = `#appendix-skills-cat-${encodeURIComponent(label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''))}`; const url = `#appendix${anchor}`; window.location.hash = url; } } catch {} }} tooltipFunc={(node, d) => { try { try { setActiveCategory(String(d.displayText || d._id || '')); } catch {} node.innerHTML = ''; node.className = 'rounded-md border shadow-sm px-3 py-2 text-xs'; (node as HTMLElement).style.background = 'var(--content-card-background)'; (node as HTMLElement).style.borderColor = 'var(--icon-button-secondary)'; (node as HTMLElement).style.color = 'var(--text-main)'; (node as HTMLElement).style.pointerEvents = 'none'; const title = document.createElement('div'); title.className = 'font-medium'; title.style.color = 'var(--text-main)'; title.textContent = String(d.displayText || d._id || ''); node.appendChild(title); } catch {} }} />
setLegendHovered(true)} onMouseLeave={() => setLegendHovered(false)} >
{activeCategory ? ( showLegendTooltipAt( e.currentTarget, 'Category share', 'Percent = how focused you are in this category vs others (combined signals).' ) } onMouseLeave={hideLegendTooltip} > {activeCategory ? `Category: ${activeCategory}` : 'Category'} {`• ${categoryPercentMap[activeCategory] ?? 0}%`} ) : null}
{leftColumnGrid.map((entry, idx) => ( columnComponent(entry, idx, true) ))}
{rightColumnGrid.map((entry, idx) => ( columnComponent(entry, idx, false) ))}
{!headless && }
); }