kyd-shared-badge 0.2.26 → 0.2.28
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 +7 -5
- package/src/SharedBadgeDisplay.tsx +464 -226
- package/src/components/AppendixTables.tsx +105 -11
- package/src/components/BusinessRuleLink.tsx +33 -0
- package/src/components/BusinessRulesContext.tsx +56 -0
- package/src/components/CategoryBars.tsx +100 -0
- package/src/components/ConnectedPlatforms.tsx +67 -0
- package/src/components/GaugeCard.tsx +99 -0
- package/src/components/GraphInsights.tsx +351 -0
- package/src/components/IpRiskAnalysisDisplay.tsx +4 -4
- package/src/components/ReportHeader.tsx +75 -42
- package/src/components/RiskCard.tsx +106 -0
- package/src/components/Skills.tsx +422 -0
- package/src/components/SkillsAppendixTable.tsx +83 -0
- package/src/types.ts +223 -11
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { green } from '../colors';
|
|
5
|
+
import { SkillsMatrix } from '../types';
|
|
6
|
+
import {
|
|
7
|
+
ResponsiveContainer,
|
|
8
|
+
BarChart,
|
|
9
|
+
Bar,
|
|
10
|
+
XAxis,
|
|
11
|
+
YAxis,
|
|
12
|
+
CartesianGrid,
|
|
13
|
+
Tooltip,
|
|
14
|
+
} from 'recharts';
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
type SkillsRadarPoint = {
|
|
18
|
+
axis: string;
|
|
19
|
+
observed?: number;
|
|
20
|
+
self_reported?: number;
|
|
21
|
+
certified?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
type BubbleDatum = { label: string; value: number; color: string };
|
|
26
|
+
type PackedBubble = { x: number; y: number; r: number; label: string; value: number; color: string };
|
|
27
|
+
|
|
28
|
+
const packBubbles = (data: BubbleDatum[], width: number, height: number, padding: number): PackedBubble[] => {
|
|
29
|
+
const filtered = data.filter((d) => Number(d.value) > 0);
|
|
30
|
+
if (filtered.length === 0) return [];
|
|
31
|
+
const maxVal = Math.max(...filtered.map((d) => d.value));
|
|
32
|
+
const maxR = Math.min(width, height) * 0.24;
|
|
33
|
+
const nodes: PackedBubble[] = filtered
|
|
34
|
+
.sort((a, b) => b.value - a.value)
|
|
35
|
+
.map((d) => ({
|
|
36
|
+
label: d.label,
|
|
37
|
+
value: d.value,
|
|
38
|
+
color: d.color,
|
|
39
|
+
r: Math.max(6, Math.sqrt(d.value / maxVal) * maxR),
|
|
40
|
+
x: width / 2,
|
|
41
|
+
y: height / 2,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const placed: PackedBubble[] = [];
|
|
45
|
+
const centerX = width / 2;
|
|
46
|
+
const centerY = height / 2;
|
|
47
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
48
|
+
const node = nodes[i];
|
|
49
|
+
if (i === 0) {
|
|
50
|
+
placed.push({ ...node });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let angle = 0;
|
|
54
|
+
let radiusFromCenter = node.r + padding;
|
|
55
|
+
let found = false;
|
|
56
|
+
const maxAttempts = 2000;
|
|
57
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
58
|
+
const x = centerX + radiusFromCenter * Math.cos(angle);
|
|
59
|
+
const y = centerY + radiusFromCenter * Math.sin(angle);
|
|
60
|
+
const withinBounds = (x - node.r >= 0) && (x + node.r <= width) && (y - node.r >= 0) && (y + node.r <= height);
|
|
61
|
+
if (withinBounds) {
|
|
62
|
+
let overlaps = false;
|
|
63
|
+
for (let j = 0; j < placed.length; j++) {
|
|
64
|
+
const p = placed[j];
|
|
65
|
+
const dx = x - p.x;
|
|
66
|
+
const dy = y - p.y;
|
|
67
|
+
const dist2 = dx * dx + dy * dy;
|
|
68
|
+
const minDist = node.r + p.r + padding;
|
|
69
|
+
if (dist2 < minDist * minDist) {
|
|
70
|
+
overlaps = true;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!overlaps) {
|
|
75
|
+
placed.push({ ...node, x, y });
|
|
76
|
+
found = true;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
angle += Math.PI / 18; // 10 degrees
|
|
81
|
+
if (angle >= Math.PI * 2) {
|
|
82
|
+
angle = 0;
|
|
83
|
+
radiusFromCenter += Math.max(4, node.r * 0.25);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!found) {
|
|
87
|
+
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) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return placed;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Compute up to two centered lines that fit inside a circle of given radius.
|
|
94
|
+
// Uses a rough char width approximation to avoid expensive text measurement.
|
|
95
|
+
const buildLabelLines = (label: string, radius: number): { lines: string[]; fontSize: number } => {
|
|
96
|
+
const clean = String(label || '').trim();
|
|
97
|
+
if (!clean) return { lines: [], fontSize: 10 };
|
|
98
|
+
|
|
99
|
+
// Choose font size based on radius, within reasonable bounds
|
|
100
|
+
const fontSize = Math.max(8, Math.min(14, Math.floor(radius * 0.45)));
|
|
101
|
+
const charWidth = fontSize * 0.58; // approx width per character
|
|
102
|
+
const maxLineWidth = Math.max(0, (radius * 2) * 0.85);
|
|
103
|
+
const maxCharsPerLine = Math.max(2, Math.floor(maxLineWidth / charWidth));
|
|
104
|
+
|
|
105
|
+
// Prefer breaking on spaces; fallback to hard cut
|
|
106
|
+
const words = clean.split(/\s+/);
|
|
107
|
+
const lines: string[] = [];
|
|
108
|
+
let current = '';
|
|
109
|
+
for (let i = 0; i < words.length; i++) {
|
|
110
|
+
const w = words[i];
|
|
111
|
+
const candidate = current ? current + ' ' + w : w;
|
|
112
|
+
if (candidate.length <= maxCharsPerLine) {
|
|
113
|
+
current = candidate;
|
|
114
|
+
} else {
|
|
115
|
+
if (current) lines.push(current);
|
|
116
|
+
current = w.length > maxCharsPerLine ? w.slice(0, Math.max(1, maxCharsPerLine - 1)) + '…' : w;
|
|
117
|
+
}
|
|
118
|
+
if (lines.length >= 2) break;
|
|
119
|
+
}
|
|
120
|
+
if (lines.length < 2 && current) lines.push(current);
|
|
121
|
+
|
|
122
|
+
// If still too many words, force truncate to 2 lines
|
|
123
|
+
if (lines.length > 2) lines.length = 2;
|
|
124
|
+
// Ensure each line within limit
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
if (lines[i].length > maxCharsPerLine) {
|
|
127
|
+
lines[i] = lines[i].slice(0, Math.max(1, maxCharsPerLine - 1)) + '…';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { lines, fontSize };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type HoverTooltipState = {
|
|
135
|
+
visible: boolean;
|
|
136
|
+
x: number;
|
|
137
|
+
y: number;
|
|
138
|
+
title: string;
|
|
139
|
+
body?: string;
|
|
140
|
+
} | null;
|
|
141
|
+
|
|
142
|
+
const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
|
|
143
|
+
if (!state || !state.visible) return null;
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
style={{
|
|
147
|
+
position: 'absolute',
|
|
148
|
+
left: state.x,
|
|
149
|
+
top: state.y,
|
|
150
|
+
pointerEvents: 'none',
|
|
151
|
+
background: 'var(--content-card-background)',
|
|
152
|
+
border: '1px solid var(--icon-button-secondary)',
|
|
153
|
+
color: 'var(--text-main)',
|
|
154
|
+
padding: 10,
|
|
155
|
+
borderRadius: 6,
|
|
156
|
+
minWidth: 250,
|
|
157
|
+
maxWidth: 320,
|
|
158
|
+
zIndex: 10,
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<div style={{ fontWeight: 600 }}>{state.title}</div>
|
|
162
|
+
{state.body ? (
|
|
163
|
+
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{state.body}</div>
|
|
164
|
+
) : null}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const Skills = ({ skillsCategoryRadar }: { skillsMatrix?: SkillsMatrix; skillsCategoryRadar?: SkillsRadarPoint[] }) => {
|
|
170
|
+
// const hasMatrix = !!(skillsMatrix && Array.isArray(skillsMatrix.skills) && skillsMatrix.skills.length > 0);
|
|
171
|
+
const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0);
|
|
172
|
+
|
|
173
|
+
const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 8);
|
|
174
|
+
|
|
175
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
176
|
+
const footprintLegendRef = useRef<HTMLDivElement>(null);
|
|
177
|
+
const barLegendRef = useRef<HTMLDivElement>(null);
|
|
178
|
+
const [containerWidth, setContainerWidth] = useState<number>(0);
|
|
179
|
+
const [footprintChartTooltip, setFootprintChartTooltip] = useState<HoverTooltipState>(null);
|
|
180
|
+
const [footprintLegendTooltip, setFootprintLegendTooltip] = useState<HoverTooltipState>(null);
|
|
181
|
+
const [barLegendTooltip, setBarLegendTooltip] = useState<HoverTooltipState>(null);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const measure = () => setContainerWidth(containerRef.current?.clientWidth || 0);
|
|
185
|
+
measure();
|
|
186
|
+
window.addEventListener('resize', measure);
|
|
187
|
+
return () => window.removeEventListener('resize', measure);
|
|
188
|
+
}, []);
|
|
189
|
+
|
|
190
|
+
const combinedBubbleData: BubbleDatum[] = useMemo(() => {
|
|
191
|
+
return skillsRadarLimited.map((d) => {
|
|
192
|
+
const vals = [Number(d.observed || 0), Number(d.self_reported || 0), Number(d.certified || 0)];
|
|
193
|
+
const nonZero = vals.filter((v) => v > 0);
|
|
194
|
+
const base = (nonZero.length > 0 ? nonZero : vals);
|
|
195
|
+
const avg = Math.round(base.reduce((a, b) => a + b, 0) / (base.length || 1));
|
|
196
|
+
return { label: d.axis, value: avg, color: green };
|
|
197
|
+
});
|
|
198
|
+
}, [skillsRadarLimited]);
|
|
199
|
+
|
|
200
|
+
const legendData = useMemo(() => {
|
|
201
|
+
const total = combinedBubbleData.reduce((sum, d) => sum + d.value, 0);
|
|
202
|
+
return combinedBubbleData
|
|
203
|
+
.slice()
|
|
204
|
+
.sort((a, b) => b.value - a.value)
|
|
205
|
+
.map((d) => ({ label: d.label, percent: total > 0 ? Math.round((d.value / total) * 100) : 0 }));
|
|
206
|
+
}, [combinedBubbleData]);
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div className="space-y-6">
|
|
210
|
+
|
|
211
|
+
{/* Skills Coverage and Breakdown: two charts side-by-side */}
|
|
212
|
+
{hasRadar && (
|
|
213
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
214
|
+
<div className={'rounded-lg p-4 border'} style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
|
|
215
|
+
<h4 className={'font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Skills Footprint</h4>
|
|
216
|
+
<p className={'text-sm mb-4'} style={{ color: 'var(--text-secondary)' }}>The bubble chart visualizes individual skills, where bubble size reflects the weight of supporting evidence and placement indicates relative strength across the skill set.</p>
|
|
217
|
+
<div ref={containerRef} style={{ width: '100%', height: 340, position: 'relative' }}>
|
|
218
|
+
{(() => {
|
|
219
|
+
const width = containerWidth || 600;
|
|
220
|
+
const height = 300;
|
|
221
|
+
const innerWidth = Math.max(0, width);
|
|
222
|
+
const innerHeight = Math.max(0, height);
|
|
223
|
+
const packed = packBubbles(combinedBubbleData, innerWidth, innerHeight, 6);
|
|
224
|
+
return (
|
|
225
|
+
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
|
226
|
+
<g transform={`translate(-25, -20)`}>
|
|
227
|
+
{packed.map((b, idx) => {
|
|
228
|
+
const clipId = `bubble-clip-${idx}`;
|
|
229
|
+
const canShowText = b.r >= 14;
|
|
230
|
+
const labelSpec = canShowText ? buildLabelLines(b.label, b.r) : { lines: [], fontSize: 10 };
|
|
231
|
+
const lineGap = Math.max(2, Math.floor(labelSpec.fontSize * 0.2));
|
|
232
|
+
const totalTextHeight = labelSpec.lines.length * labelSpec.fontSize + Math.max(0, (labelSpec.lines.length - 1) * lineGap);
|
|
233
|
+
const startY = -Math.floor(totalTextHeight / 2) + Math.floor(labelSpec.fontSize * 0.85);
|
|
234
|
+
return (
|
|
235
|
+
<g
|
|
236
|
+
key={idx}
|
|
237
|
+
transform={`translate(${b.x},${b.y})`}
|
|
238
|
+
onMouseEnter={(e) => {
|
|
239
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
240
|
+
if (!rect) return;
|
|
241
|
+
setFootprintChartTooltip({
|
|
242
|
+
visible: true,
|
|
243
|
+
x: e.clientX - rect.left + 12,
|
|
244
|
+
y: e.clientY - rect.top + 12,
|
|
245
|
+
title: b.label,
|
|
246
|
+
body: `Score: ${b.value}`,
|
|
247
|
+
});
|
|
248
|
+
}}
|
|
249
|
+
onMouseMove={(e) => {
|
|
250
|
+
if (!footprintChartTooltip || !containerRef.current) return;
|
|
251
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
252
|
+
setFootprintChartTooltip({ ...footprintChartTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 });
|
|
253
|
+
}}
|
|
254
|
+
onMouseLeave={() => setFootprintChartTooltip(null)}
|
|
255
|
+
>
|
|
256
|
+
<defs>
|
|
257
|
+
<clipPath id={clipId}>
|
|
258
|
+
<circle r={b.r} />
|
|
259
|
+
</clipPath>
|
|
260
|
+
</defs>
|
|
261
|
+
<circle r={b.r} fill={'var(--bubble-foreground)'} stroke={'var(--icon-button-secondary)'} />
|
|
262
|
+
<title>{`${b.label}: ${b.value}`}</title>
|
|
263
|
+
{canShowText && labelSpec.lines.length > 0 ? (
|
|
264
|
+
<g clipPath={`url(#${clipId})`} style={{ pointerEvents: 'none' }}>
|
|
265
|
+
{labelSpec.lines.map((line, li) => (
|
|
266
|
+
<text key={li} y={startY + li * (labelSpec.fontSize + lineGap)} fontSize={labelSpec.fontSize} textAnchor="middle" fill={'var(--bubble-background)'}>
|
|
267
|
+
{line}
|
|
268
|
+
</text>
|
|
269
|
+
))}
|
|
270
|
+
</g>
|
|
271
|
+
) : null}
|
|
272
|
+
</g>
|
|
273
|
+
);
|
|
274
|
+
})}
|
|
275
|
+
</g>
|
|
276
|
+
</svg>
|
|
277
|
+
);
|
|
278
|
+
})()}
|
|
279
|
+
<TooltipBox state={footprintChartTooltip} />
|
|
280
|
+
</div>
|
|
281
|
+
{/* Legend */}
|
|
282
|
+
<div className={'mt-3'}>
|
|
283
|
+
{/* <div className={'text-xs mb-2'} style={{ color: 'var(--text-secondary)' }}>Legend</div> */}
|
|
284
|
+
<div ref={footprintLegendRef} style={{ position: 'relative' }}>
|
|
285
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
|
286
|
+
{legendData.map((item, idx) => (
|
|
287
|
+
<div
|
|
288
|
+
key={idx}
|
|
289
|
+
className="flex items-center gap-2 text-xs"
|
|
290
|
+
style={{ color: 'var(--text-secondary)' }}
|
|
291
|
+
onMouseEnter={(e) => {
|
|
292
|
+
const rect = footprintLegendRef.current?.getBoundingClientRect();
|
|
293
|
+
if (!rect) return;
|
|
294
|
+
const x = e.clientX - rect.left + 12;
|
|
295
|
+
const y = e.clientY - rect.top + 12;
|
|
296
|
+
setFootprintLegendTooltip({
|
|
297
|
+
visible: true,
|
|
298
|
+
x,
|
|
299
|
+
y,
|
|
300
|
+
title: item.label,
|
|
301
|
+
body: `${item.label} represents ${item.percent}% of the Developer’s observable technical skills.`,
|
|
302
|
+
});
|
|
303
|
+
}}
|
|
304
|
+
onMouseMove={(e) => {
|
|
305
|
+
if (!footprintLegendTooltip || !footprintLegendRef.current) return;
|
|
306
|
+
const rect = footprintLegendRef.current.getBoundingClientRect();
|
|
307
|
+
setFootprintLegendTooltip({ ...footprintLegendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 });
|
|
308
|
+
}}
|
|
309
|
+
onMouseLeave={() => setFootprintLegendTooltip(null)}
|
|
310
|
+
>
|
|
311
|
+
<span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: 'var(--text-secondary)', flexShrink: 0 }} />
|
|
312
|
+
<span className="truncate">{item.label}</span>
|
|
313
|
+
<span className="ml-auto opacity-80">{item.percent}%</span>
|
|
314
|
+
</div>
|
|
315
|
+
))}
|
|
316
|
+
</div>
|
|
317
|
+
<TooltipBox state={footprintLegendTooltip} />
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
<div className={'rounded-lg p-4 border'} style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
|
|
322
|
+
<h4 className={'font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Skills by Validation Type</h4>
|
|
323
|
+
<p className={'text-sm mb-4'} style={{ color: 'var(--text-secondary)' }}>The bar chart shows how each skill is supported by self-attested claims, observed practice, or certified evidence.</p>
|
|
324
|
+
<div className="space-y-6 flex-col items-center" >
|
|
325
|
+
<div style={{ height: 360 }} >
|
|
326
|
+
<div className="flex-1 min-h-0 h-full">
|
|
327
|
+
<ResponsiveContainer>
|
|
328
|
+
<BarChart data={skillsRadarLimited} margin={{ top: 8, right: 8, left: 8, bottom: 36 }}>
|
|
329
|
+
<CartesianGrid strokeDasharray="3 3" stroke={'var(--icon-button-secondary)'} />
|
|
330
|
+
<XAxis dataKey="axis" tick={{ fill: 'var(--text-secondary)', fontSize: 12 }} interval={0} angle={-20} textAnchor="end" height={50} />
|
|
331
|
+
<YAxis domain={[0, 100]} tick={{ fill: 'var(--text-secondary)' }} />
|
|
332
|
+
<Tooltip contentStyle={{ background: 'var(--content-card-background)', border: `1px solid var(--icon-button-secondary)`, color: 'var(--text-main)' }} />
|
|
333
|
+
<Bar dataKey="observed" name="Observed" fill={'var(--bar-observed)'} />
|
|
334
|
+
<Bar dataKey="self_reported" name="Self-reported" fill={'var(--bar-self-reported)'} />
|
|
335
|
+
<Bar dataKey="certified" name="Certified" fill={'var(--bar-certified)'} />
|
|
336
|
+
</BarChart>
|
|
337
|
+
</ResponsiveContainer>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
<div ref={barLegendRef} className="mt-2" style={{ position: 'relative' }}>
|
|
341
|
+
<div className="grid grid-cols-3 gap-3 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
342
|
+
<div
|
|
343
|
+
className="flex items-center gap-2"
|
|
344
|
+
onMouseEnter={(e) => {
|
|
345
|
+
const rect = barLegendRef.current?.getBoundingClientRect();
|
|
346
|
+
if (!rect) return;
|
|
347
|
+
setBarLegendTooltip({
|
|
348
|
+
visible: true,
|
|
349
|
+
x: e.clientX - rect.left + 12,
|
|
350
|
+
y: e.clientY - rect.top + 12,
|
|
351
|
+
title: 'Observed',
|
|
352
|
+
body: 'Observed: Skills evidenced through third-party activity or recognition.',
|
|
353
|
+
});
|
|
354
|
+
}}
|
|
355
|
+
onMouseMove={(e) => {
|
|
356
|
+
if (!barLegendTooltip || !barLegendRef.current) return;
|
|
357
|
+
const rect = barLegendRef.current.getBoundingClientRect();
|
|
358
|
+
setBarLegendTooltip({ ...barLegendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 });
|
|
359
|
+
}}
|
|
360
|
+
onMouseLeave={() => setBarLegendTooltip(null)}
|
|
361
|
+
>
|
|
362
|
+
<span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: 'var(--bar-observed)' }} />
|
|
363
|
+
<span>Observed</span>
|
|
364
|
+
</div>
|
|
365
|
+
<div
|
|
366
|
+
className="flex items-center gap-2"
|
|
367
|
+
onMouseEnter={(e) => {
|
|
368
|
+
const rect = barLegendRef.current?.getBoundingClientRect();
|
|
369
|
+
if (!rect) return;
|
|
370
|
+
setBarLegendTooltip({
|
|
371
|
+
visible: true,
|
|
372
|
+
x: e.clientX - rect.left + 12,
|
|
373
|
+
y: e.clientY - rect.top + 12,
|
|
374
|
+
title: 'Self-Attested',
|
|
375
|
+
body: 'Self-Attested: Skills claimed directly by the developer.',
|
|
376
|
+
});
|
|
377
|
+
}}
|
|
378
|
+
onMouseMove={(e) => {
|
|
379
|
+
if (!barLegendTooltip || !barLegendRef.current) return;
|
|
380
|
+
const rect = barLegendRef.current.getBoundingClientRect();
|
|
381
|
+
setBarLegendTooltip({ ...barLegendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 });
|
|
382
|
+
}}
|
|
383
|
+
onMouseLeave={() => setBarLegendTooltip(null)}
|
|
384
|
+
>
|
|
385
|
+
<span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: 'var(--bar-self-reported)' }} />
|
|
386
|
+
<span>Self-reported</span>
|
|
387
|
+
</div>
|
|
388
|
+
<div
|
|
389
|
+
className="flex items-center gap-2"
|
|
390
|
+
onMouseEnter={(e) => {
|
|
391
|
+
const rect = barLegendRef.current?.getBoundingClientRect();
|
|
392
|
+
if (!rect) return;
|
|
393
|
+
setBarLegendTooltip({
|
|
394
|
+
visible: true,
|
|
395
|
+
x: e.clientX - rect.left + 12,
|
|
396
|
+
y: e.clientY - rect.top + 12,
|
|
397
|
+
title: 'Certified',
|
|
398
|
+
body: 'Certified: Skills verified by formal credentials or certifications',
|
|
399
|
+
});
|
|
400
|
+
}}
|
|
401
|
+
onMouseMove={(e) => {
|
|
402
|
+
if (!barLegendTooltip || !barLegendRef.current) return;
|
|
403
|
+
const rect = barLegendRef.current.getBoundingClientRect();
|
|
404
|
+
setBarLegendTooltip({ ...barLegendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 });
|
|
405
|
+
}}
|
|
406
|
+
onMouseLeave={() => setBarLegendTooltip(null)}
|
|
407
|
+
>
|
|
408
|
+
<span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: 'var(--bar-certified)' }} />
|
|
409
|
+
<span>Certified</span>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
<TooltipBox state={barLegendTooltip} />
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
export default Skills;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect } from 'react';
|
|
4
|
+
import { SkillsAll, SkillRow } from '../types';
|
|
5
|
+
import { green } from '../colors';
|
|
6
|
+
|
|
7
|
+
const slugify = (name: string) =>
|
|
8
|
+
String(name || '')
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
11
|
+
.replace(/^-+|-+$/g, '');
|
|
12
|
+
|
|
13
|
+
const SkillsAppendixTable = ({ skillsAll }: { skillsAll?: SkillsAll }) => {
|
|
14
|
+
const rows = (skillsAll?.skills || []).filter((row: SkillRow) => row.observed?.present || row.self_reported?.present || row.certified?.present);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const flash = () => {
|
|
17
|
+
const hash = typeof window !== 'undefined' ? window.location.hash : '';
|
|
18
|
+
if (!hash || !hash.startsWith('#appendix-skills-')) return;
|
|
19
|
+
const id = hash.slice(1);
|
|
20
|
+
const el = document.getElementById(id) as HTMLElement | null;
|
|
21
|
+
if (!el) return;
|
|
22
|
+
const originalBg = el.style.backgroundColor;
|
|
23
|
+
el.style.transition = 'background-color 300ms ease';
|
|
24
|
+
el.style.backgroundColor = 'rgba(2, 163, 137, 0.14)';
|
|
25
|
+
setTimeout(() => {
|
|
26
|
+
el.style.backgroundColor = originalBg || '';
|
|
27
|
+
}, 1200);
|
|
28
|
+
};
|
|
29
|
+
flash();
|
|
30
|
+
window.addEventListener('hashchange', flash);
|
|
31
|
+
return () => window.removeEventListener('hashchange', flash);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
if (!rows.length) return null;
|
|
35
|
+
return (
|
|
36
|
+
<div id="appendix-skills" className="mt-4">
|
|
37
|
+
<div className={'overflow-auto rounded-lg border'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
|
|
38
|
+
<table className="min-w-full text-sm">
|
|
39
|
+
<thead>
|
|
40
|
+
<tr style={{ backgroundColor: 'var(--content-card-background)' }}>
|
|
41
|
+
<th className="text-left p-3" style={{ color: 'var(--text-secondary)' }}>Skill</th>
|
|
42
|
+
<th className="text-left p-3" style={{ color: 'var(--text-secondary)' }}>Observed</th>
|
|
43
|
+
<th className="text-left p-3" style={{ color: 'var(--text-secondary)' }}>Self-reported</th>
|
|
44
|
+
<th className="text-left p-3" style={{ color: 'var(--text-secondary)' }}>Certified</th>
|
|
45
|
+
</tr>
|
|
46
|
+
</thead>
|
|
47
|
+
<tbody>
|
|
48
|
+
{rows.map((row, idx: number) => (
|
|
49
|
+
<tr id={`appendix-skills-${slugify(row.name)}`} key={idx} className="border-t" style={{ borderColor: 'var(--icon-button-secondary)' }}>
|
|
50
|
+
<td className="p-3" style={{ color: 'var(--text-main)' }}>{row.name}</td>
|
|
51
|
+
{(['observed','self_reported','certified'] as const).map((b) => {
|
|
52
|
+
const bucket = row[b];
|
|
53
|
+
const present = !!bucket.present;
|
|
54
|
+
const dot = present ? green : 'var(--icon-button-secondary)';
|
|
55
|
+
const ev = bucket.evidence;
|
|
56
|
+
const sources = (bucket.sources || []).slice(0,4);
|
|
57
|
+
return (
|
|
58
|
+
<td key={b} className="p-3 align-top" style={{ color: 'var(--text-secondary)' }}>
|
|
59
|
+
<div className="flex items-start gap-2">
|
|
60
|
+
<div className={'mt-1 rounded-full'} style={{ backgroundColor: dot, minHeight: '8px', minWidth: '8px', height: '8px', width: '8px', flexShrink: 0 }} />
|
|
61
|
+
<div>
|
|
62
|
+
{(ev || (sources && sources.length > 0)) && (
|
|
63
|
+
<div className={'text-xs mt-1'} style={{ color: 'var(--text-secondary)' }}>
|
|
64
|
+
{ev ? <div>{ev}</div> : null}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</td>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</tr>
|
|
73
|
+
))}
|
|
74
|
+
</tbody>
|
|
75
|
+
</table>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export default SkillsAppendixTable;
|
|
82
|
+
|
|
83
|
+
|