kyd-shared-badge 0.3.95 → 0.3.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/SharedBadgeDisplay.tsx +1 -1
- package/src/components/SkillsBubble.tsx +151 -36
- package/src/types.ts +18 -1
package/package.json
CHANGED
|
@@ -288,7 +288,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
|
|
|
288
288
|
<Reveal headless={isHeadless}>
|
|
289
289
|
<div className={'kyd-avoid-break'}>
|
|
290
290
|
<h4 className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Skills Footprint</h4>
|
|
291
|
-
<SkillsBubble skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} skillsByCategory={assessmentResult?.graph_insights?.skillsByCategory} />
|
|
291
|
+
<SkillsBubble skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} skillsByCategory={assessmentResult?.graph_insights?.skillsByCategory} skillsMeta={assessmentResult?.graph_insights?.skillsMeta} />
|
|
292
292
|
</div>
|
|
293
293
|
</Reveal>
|
|
294
294
|
<div className={'pt-6 text-sm text-center'} style={{ color: 'var(--text-secondary)' }}>
|
|
@@ -4,6 +4,8 @@ import React, { useMemo, useRef, useState, useEffect } from 'react';
|
|
|
4
4
|
import { BubbleChart } from '@knowyourdeveloper/react-bubble-chart';
|
|
5
5
|
import '@knowyourdeveloper/react-bubble-chart/style.css';
|
|
6
6
|
import { green1, green2, green3, green4, green5 } from '../colors';
|
|
7
|
+
import { ProviderIcon } from '../utils/provider';
|
|
8
|
+
import { providers } from '../types';
|
|
7
9
|
|
|
8
10
|
type SkillsRadarPoint = {
|
|
9
11
|
axis: string;
|
|
@@ -47,24 +49,14 @@ const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
|
|
|
47
49
|
);
|
|
48
50
|
};
|
|
49
51
|
|
|
50
|
-
const pickGreenByExperience = (experience: number): string => {
|
|
51
|
-
const exp = Math.max(0, Math.min(100, Number(experience || 0)));
|
|
52
|
-
if (exp >= 80) return green1;
|
|
53
|
-
if (exp >= 60) return green2;
|
|
54
|
-
if (exp >= 40) return green3;
|
|
55
|
-
if (exp >= 20) return green4;
|
|
56
|
-
return green5;
|
|
57
|
-
};
|
|
58
52
|
|
|
59
|
-
export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, skillsMeta, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record<string, string[]>; skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number }>; headless?: boolean }) {
|
|
53
|
+
export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, skillsMeta, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record<string, string[]>; skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number; sources?: string[] }>; headless?: boolean }) {
|
|
60
54
|
const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0);
|
|
61
55
|
const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 24);
|
|
62
56
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
63
57
|
const legendRef = useRef<HTMLDivElement>(null);
|
|
64
58
|
const [legendTooltip, setLegendTooltip] = useState<HoverTooltipState>(null);
|
|
65
59
|
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
|
66
|
-
console.log('skillsRadarLimited', skillsRadarLimited);
|
|
67
|
-
console.log('skillsByCategory', skillsByCategory);
|
|
68
60
|
useEffect(() => {
|
|
69
61
|
if (typeof window !== 'undefined') {
|
|
70
62
|
const id = window.setTimeout(() => {
|
|
@@ -74,6 +66,23 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
74
66
|
}
|
|
75
67
|
}, []);
|
|
76
68
|
|
|
69
|
+
// Tooltip helpers for legend/columns area
|
|
70
|
+
const computeTooltipPosition = (target: HTMLElement) => {
|
|
71
|
+
const host = legendRef.current;
|
|
72
|
+
if (!host) return { x: 0, y: 0 };
|
|
73
|
+
const rect = target.getBoundingClientRect();
|
|
74
|
+
const hostRect = host.getBoundingClientRect();
|
|
75
|
+
const x = rect.left - hostRect.left;
|
|
76
|
+
const y = rect.bottom - hostRect.top + 6;
|
|
77
|
+
return { x, y };
|
|
78
|
+
};
|
|
79
|
+
const showLegendTooltipAt = (target: EventTarget | null, title: string, body?: string) => {
|
|
80
|
+
if (!(target instanceof HTMLElement)) return;
|
|
81
|
+
const { x, y } = computeTooltipPosition(target);
|
|
82
|
+
setLegendTooltip({ visible: true, x, y, title, body });
|
|
83
|
+
};
|
|
84
|
+
const hideLegendTooltip = () => setLegendTooltip(null);
|
|
85
|
+
|
|
77
86
|
// ratio drives size: average of observed/self_reported/certified
|
|
78
87
|
const bubbles = useMemo(() => {
|
|
79
88
|
const seriesAvg = (d: SkillsRadarPoint): number => {
|
|
@@ -132,25 +141,36 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
132
141
|
// Enrich with meta and sort by years desc
|
|
133
142
|
const enriched = list.map((name) => {
|
|
134
143
|
const meta = skillsMeta?.[name] || {};
|
|
135
|
-
|
|
144
|
+
const sources = Array.isArray((meta as any).sources) ? (meta as any).sources as string[] : [];
|
|
145
|
+
return { name, years: Number(meta.years || 0), presence: (meta.presence as string) || '', sources };
|
|
136
146
|
}).sort((a, b) => b.years - a.years || a.name.localeCompare(b.name));
|
|
137
147
|
const items = enriched.slice(0, 10);
|
|
138
148
|
const overflow = list.length - items.length;
|
|
139
|
-
const display: Array<{ label: string; years?: number; presence?: string } | ''> = [];
|
|
149
|
+
const display: Array<{ label: string; years?: number; presence?: string; sources?: string[] } | ''> = [];
|
|
140
150
|
for (let i = 0; i < Math.min(9, items.length); i++) {
|
|
141
151
|
const it = items[i];
|
|
142
152
|
const label = it.name ? `${it.name}` : '';
|
|
143
|
-
display.push({ label, years: it.years, presence: it.presence });
|
|
153
|
+
display.push({ label, years: it.years, presence: it.presence, sources: it.sources });
|
|
144
154
|
}
|
|
145
155
|
if (list.length > 10) {
|
|
146
|
-
|
|
156
|
+
// Aggregate years and sources for remaining datapoints beyond the top 10
|
|
157
|
+
const remaining = enriched.slice(10);
|
|
158
|
+
const aggregatedYears = remaining.reduce((sum, it) => sum + Number(it.years || 0), 0);
|
|
159
|
+
const aggregatedSourcesSet = new Set<string>();
|
|
160
|
+
for (const it of remaining) {
|
|
161
|
+
if (Array.isArray(it.sources)) {
|
|
162
|
+
for (const src of it.sources) aggregatedSourcesSet.add(String(src));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const aggregatedSources = Array.from(aggregatedSourcesSet);
|
|
166
|
+
display.push({ label: `Others (${overflow})`, years: aggregatedYears, sources: aggregatedSources });
|
|
147
167
|
} else if (items.length >= 10) {
|
|
148
168
|
const it = items[9];
|
|
149
|
-
display.push({ label: it?.name || '', years: it?.years, presence: it?.presence });
|
|
169
|
+
display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
|
|
150
170
|
} else {
|
|
151
171
|
if (items.length > 9) {
|
|
152
172
|
const it = items[9];
|
|
153
|
-
display.push({ label: it?.name || '', years: it?.years, presence: it?.presence });
|
|
173
|
+
display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
|
|
154
174
|
}
|
|
155
175
|
}
|
|
156
176
|
while (display.length < 10) display.push('');
|
|
@@ -168,8 +188,94 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
168
188
|
return map;
|
|
169
189
|
}, [bubbles]);
|
|
170
190
|
|
|
191
|
+
const presenceColor = (presence?: string) => {
|
|
192
|
+
const p = String(presence || '').toLowerCase();
|
|
193
|
+
if (p === 'self-reported') return green5;
|
|
194
|
+
if (p === 'observed') return green3;
|
|
195
|
+
if (p === 'certified') return green1;
|
|
196
|
+
return 'var(--icon-button-secondary)';
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const presenceTooltipCopy = (presence?: string): { title: string; body: string } => {
|
|
200
|
+
const p = String(presence || '').toLowerCase();
|
|
201
|
+
if (p === 'self-reported') return { title: 'Self-reported', body: 'Claims (bios, profiles, resumes).' };
|
|
202
|
+
if (p === 'observed') return { title: 'Observed', body: 'Evidence directly from code and repos.' };
|
|
203
|
+
if (p === 'certified') return { title: 'Certified', body: 'Verified by credential issuers.' };
|
|
204
|
+
return { title: 'Info', body: '' };
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const leftColumnGrid = useMemo(() => (skillsGrid || []).slice(0, 5), [skillsGrid]);
|
|
208
|
+
const rightColumnGrid = useMemo(() => (skillsGrid || []).slice(5, 10), [skillsGrid]);
|
|
209
|
+
|
|
171
210
|
if (!hasRadar) return null;
|
|
172
211
|
|
|
212
|
+
const columnComponent = (entry: { label: string; years?: number; presence?: string; sources?: string[] } | '', idx: number, isLeft: boolean) => {
|
|
213
|
+
return (
|
|
214
|
+
<div key={idx} className="flex items-stretch justify-between gap-3 min-w-0">
|
|
215
|
+
<div className="flex flex-col min-w-0 justify-center">
|
|
216
|
+
<div className="flex items-center gap-2 min-w-0 text-lg text-[var(--text-main)]">
|
|
217
|
+
<span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: entry ? 'var(--icon-button-secondary)' : 'transparent', flexShrink: 0 }} />
|
|
218
|
+
<span className="shrink-0 opacity-70">{idx + (isLeft ? 1 : 6)}.</span>
|
|
219
|
+
{entry && typeof entry !== 'string' ? (
|
|
220
|
+
<span className="truncate" title={entry.label}>{entry.label}</span>
|
|
221
|
+
) : (
|
|
222
|
+
<span className="truncate">{typeof entry === 'string' ? entry : '\u00A0'}</span>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
<span className="text-xs text-[var(--text-secondary)] flex flex-wrap items-center gap-1">
|
|
226
|
+
<span
|
|
227
|
+
className="underline decoration-dotted underline-offset-2 cursor-help"
|
|
228
|
+
onMouseEnter={(e) => showLegendTooltipAt(e.currentTarget, 'Sources', 'The source where we observed this skill.')}
|
|
229
|
+
onMouseLeave={hideLegendTooltip}
|
|
230
|
+
>
|
|
231
|
+
Sources
|
|
232
|
+
</span>:
|
|
233
|
+
{Array.isArray((entry as any).sources) && (entry as any).sources.length > 0 ? (
|
|
234
|
+
(() => {
|
|
235
|
+
const sourceProviders: string[] = ((entry as any).sources as string[]).map((src: string) => {
|
|
236
|
+
const str = String(src);
|
|
237
|
+
let provider = str.split(':')[0] || '';
|
|
238
|
+
if (!provider || provider === str) {
|
|
239
|
+
// If split(':')[0] didn't find a delimiter or provider (i.e., no ':'), try split('.')
|
|
240
|
+
provider = str.split('.')[0] || '';
|
|
241
|
+
}
|
|
242
|
+
return provider.toLowerCase();
|
|
243
|
+
});
|
|
244
|
+
const uniqueProviders = Array.from(new Set<string>(sourceProviders));
|
|
245
|
+
const filteredProviders = uniqueProviders.filter((provider) =>
|
|
246
|
+
providers.includes(provider.toLowerCase())
|
|
247
|
+
);
|
|
248
|
+
return filteredProviders.map((provider) => (
|
|
249
|
+
<ProviderIcon key={provider} name={provider} />
|
|
250
|
+
));
|
|
251
|
+
})()
|
|
252
|
+
) : null}
|
|
253
|
+
</span>
|
|
254
|
+
</div>
|
|
255
|
+
{entry && typeof entry !== 'string' ? (
|
|
256
|
+
<div className="flex flex-col items-end leading-tight h-full justify-between text-base">
|
|
257
|
+
{entry.years ? <span className="whitespace-nowrap text-[var(--text-main)]">{`${entry.years} Years`}</span> : <span className="opacity-0 whitespace-nowrap text-[var(--text-main)]">0 Years</span>}
|
|
258
|
+
{entry.presence ? (
|
|
259
|
+
<div className="flex items-center gap-1">
|
|
260
|
+
<span className="inline-block h-2 w-2 rounded-full text-sm" style={{ background: presenceColor(entry.presence) }} />
|
|
261
|
+
<span
|
|
262
|
+
className="whitespace-nowrap text-sm underline decoration-dotted underline-offset-2 cursor-help"
|
|
263
|
+
onMouseEnter={(e) => {
|
|
264
|
+
const copy = presenceTooltipCopy(entry.presence);
|
|
265
|
+
showLegendTooltipAt(e.currentTarget, copy.title, copy.body);
|
|
266
|
+
}}
|
|
267
|
+
onMouseLeave={hideLegendTooltip}
|
|
268
|
+
>
|
|
269
|
+
{entry.presence}
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
) : <span className="opacity-0 whitespace-nowrap">.</span>}
|
|
273
|
+
</div>
|
|
274
|
+
) : null}
|
|
275
|
+
</div>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
173
279
|
return (
|
|
174
280
|
<div className={'kyd-avoid-break'}>
|
|
175
281
|
<div ref={containerRef} style={{ width: '100%', height: 340, position: 'relative' }}>
|
|
@@ -233,28 +339,37 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
233
339
|
}}
|
|
234
340
|
/>
|
|
235
341
|
</div>
|
|
236
|
-
<div className=
|
|
342
|
+
<div className='mt-8'>
|
|
237
343
|
<div ref={legendRef} className={'kyd-avoid-break'} style={{ position: 'relative', breakInside: 'avoid', pageBreakInside: 'avoid' as unknown as undefined }}>
|
|
238
344
|
<div className="mb-2 text-xs font-medium" style={{ color: 'var(--text-main)' }}>
|
|
239
|
-
|
|
240
|
-
{activeCategory ?
|
|
345
|
+
|
|
346
|
+
{activeCategory ? (
|
|
347
|
+
<span
|
|
348
|
+
className="ml-1 underline decoration-dotted underline-offset-2 cursor-help"
|
|
349
|
+
onMouseEnter={(e) =>
|
|
350
|
+
showLegendTooltipAt(
|
|
351
|
+
e.currentTarget,
|
|
352
|
+
'Category share',
|
|
353
|
+
'Percent = how focused you are in this category vs others (combined signals).'
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
onMouseLeave={hideLegendTooltip}
|
|
357
|
+
>
|
|
358
|
+
<span>{activeCategory ? `Category: ${activeCategory}` : 'Category'}</span> {`• ${categoryPercentMap[activeCategory] ?? 0}%`}
|
|
359
|
+
</span>
|
|
360
|
+
) : null}
|
|
241
361
|
</div>
|
|
242
|
-
<div className="grid grid-cols-2 gap-x-4
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
) : (
|
|
254
|
-
<span className="truncate">{typeof entry === 'string' ? entry : '\u00A0'}</span>
|
|
255
|
-
)}
|
|
256
|
-
</div>
|
|
257
|
-
))}
|
|
362
|
+
<div className="grid grid-cols-2 gap-x-4 text-xs" style={{ color: 'var(--text-secondary)', minHeight: 250 }}>
|
|
363
|
+
<div className="grid gap-2">
|
|
364
|
+
{leftColumnGrid.map((entry, idx) => (
|
|
365
|
+
columnComponent(entry, idx, true)
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
<div className="grid gap-2">
|
|
369
|
+
{rightColumnGrid.map((entry, idx) => (
|
|
370
|
+
columnComponent(entry, idx, false)
|
|
371
|
+
))}
|
|
372
|
+
</div>
|
|
258
373
|
</div>
|
|
259
374
|
{!headless && <TooltipBox state={legendTooltip} />}
|
|
260
375
|
</div>
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
export const providers = [
|
|
2
|
+
'github',
|
|
3
|
+
'gitlab',
|
|
4
|
+
'credly',
|
|
5
|
+
'fiverr',
|
|
6
|
+
'kaggle',
|
|
7
|
+
'google_scholar',
|
|
8
|
+
'stackoverflow',
|
|
9
|
+
'linkedin',
|
|
10
|
+
'toptal',
|
|
11
|
+
'coursera',
|
|
12
|
+
'udemy',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export type ProviderNames = typeof providers;
|
|
16
|
+
|
|
17
|
+
|
|
1
18
|
export type Provider = {
|
|
2
19
|
id: string;
|
|
3
20
|
name: string;
|
|
@@ -384,7 +401,7 @@ export interface GraphInsightsPayload {
|
|
|
384
401
|
// New: mapping of category -> list of skills contributing to that category
|
|
385
402
|
skillsByCategory?: Record<string, string[]>;
|
|
386
403
|
// New: per-skill metadata used by UI (e.g., presence label, experience years)
|
|
387
|
-
skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number }>;
|
|
404
|
+
skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number; sources?: string[] }>;
|
|
388
405
|
// New: Flattened list of business rule selections (for appendix)
|
|
389
406
|
business_rules_all?: Array<{
|
|
390
407
|
provider: string;
|