@shiinasaku/github-card 4.1.0 → 4.2.0
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/README.md +155 -98
- package/dist/lib.d.ts +1 -1
- package/dist/lib.js +258 -120
- package/dist/utils/font.d.ts +3 -1
- package/dist/utils/format.d.ts +7 -0
- package/dist/utils/icons.d.ts +3 -1
- package/dist/utils/index.d.ts +1 -1
- package/package.json +8 -9
- package/src/card.ts +307 -404
- package/src/github.ts +107 -49
- package/src/index.ts +7 -6
- package/src/lib.ts +1 -1
- package/src/utils/font.ts +4 -2
- package/src/utils/format.ts +41 -3
- package/src/utils/github-client.ts +13 -8
- package/src/utils/icons.ts +26 -9
- package/src/utils/index.ts +1 -1
- package/fonts/Roboto-subset.woff2 +0 -0
package/src/card.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
escapeXml,
|
|
5
5
|
icon,
|
|
6
6
|
wrapText,
|
|
7
|
+
measureText,
|
|
7
8
|
FONT_FAMILY,
|
|
8
9
|
resolveTw,
|
|
9
10
|
} from "./utils/index";
|
|
@@ -38,6 +39,22 @@ type LangVisual = {
|
|
|
38
39
|
size: number;
|
|
39
40
|
};
|
|
40
41
|
|
|
42
|
+
/* ── tiny SVG builder ──────────────────────────────────────── */
|
|
43
|
+
|
|
44
|
+
function el(
|
|
45
|
+
tag: string,
|
|
46
|
+
attrs: Record<string, string | number | undefined>,
|
|
47
|
+
children = "",
|
|
48
|
+
): string {
|
|
49
|
+
let a = "";
|
|
50
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
51
|
+
if (v === undefined || v === null || v === "") continue;
|
|
52
|
+
a += ` ${k}="${escapeXml(String(v))}"`;
|
|
53
|
+
}
|
|
54
|
+
if (!children) return `<${tag}${a} />`;
|
|
55
|
+
return `<${tag}${a}>${children}</${tag}>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
/* ── helpers ───────────────────────────────────────────────── */
|
|
42
59
|
|
|
43
60
|
function raw(token: string): string {
|
|
@@ -64,7 +81,6 @@ function buildLanguageSegments(
|
|
|
64
81
|
tinySize += lang.size;
|
|
65
82
|
continue;
|
|
66
83
|
}
|
|
67
|
-
|
|
68
84
|
visible.push(lang);
|
|
69
85
|
}
|
|
70
86
|
|
|
@@ -97,10 +113,9 @@ export function renderCard(
|
|
|
97
113
|
const iconColor = raw(c.icon);
|
|
98
114
|
const borderColor = raw(c.border);
|
|
99
115
|
|
|
100
|
-
|
|
101
|
-
const fontFamily = FONT_FAMILY.replace(/"/g, "'");
|
|
116
|
+
const fontFamily = FONT_FAMILY;
|
|
102
117
|
|
|
103
|
-
/* ── profile text
|
|
118
|
+
/* ── profile text ─────────────────────────────────────────── */
|
|
104
119
|
const displayName = user.name || user.login;
|
|
105
120
|
const nameEsc = escapeXml(clamp(displayName, 28));
|
|
106
121
|
const loginEsc = escapeXml(clamp(user.login, 24));
|
|
@@ -108,7 +123,8 @@ export function renderCard(
|
|
|
108
123
|
const bioRaw = !compact && user.bio ? user.bio : "";
|
|
109
124
|
const bioLines = bioRaw ? wrapText(bioRaw, 44, 2) : [];
|
|
110
125
|
const twitter = !compact && user.twitter ? escapeXml(clamp(user.twitter, 22)) : "";
|
|
111
|
-
const avatar =
|
|
126
|
+
const avatar = user.avatarUrl || "";
|
|
127
|
+
const metaStr = pronouns ? `@${loginEsc} \u00b7 ${pronouns}` : `@${loginEsc}`;
|
|
112
128
|
|
|
113
129
|
const visible = STAT_DEFS.filter((d) => !hidden.has(d.key));
|
|
114
130
|
const activeLangsList = langs.filter((l) => {
|
|
@@ -118,59 +134,56 @@ export function renderCard(
|
|
|
118
134
|
return true;
|
|
119
135
|
});
|
|
120
136
|
|
|
121
|
-
/* ── dimensions (dynamic width)
|
|
122
|
-
const PX =
|
|
123
|
-
const PY =
|
|
124
|
-
const avatarSize =
|
|
137
|
+
/* ── dimensions (measured, dynamic width) ───────────────── */
|
|
138
|
+
const PX = 24; // horizontal padding
|
|
139
|
+
const PY = 22; // top padding
|
|
140
|
+
const avatarSize = 60;
|
|
125
141
|
const avatarR = avatarSize / 2;
|
|
142
|
+
const textGap = 16;
|
|
126
143
|
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
metaLen * 7.5, // ~7.5px per medium char
|
|
133
|
-
maxBioLen * 6.5, // ~6.5px per small char
|
|
134
|
-
twitter.length * 7 + 20,
|
|
135
|
-
);
|
|
144
|
+
const nameW = measureText(displayName, 17, 700);
|
|
145
|
+
const metaW = measureText(metaStr, 12.5, 500);
|
|
146
|
+
const bioW = bioLines.length ? Math.max(...bioLines.map((l) => measureText(l, 11.5, 400))) : 0;
|
|
147
|
+
const twitterW = twitter ? measureText(`@${twitter}`, 11.5, 500) : 0;
|
|
148
|
+
const profileTextW = Math.max(nameW, metaW, bioW, twitterW);
|
|
136
149
|
|
|
137
|
-
const profileW = PX + avatarSize +
|
|
138
|
-
const statsW = visible.length > 0 ? visible.length *
|
|
139
|
-
const langsW = activeLangsList.length > 0 ? 300 : 0;
|
|
150
|
+
const profileW = PX + avatarSize + textGap + profileTextW + PX;
|
|
151
|
+
const statsW = visible.length > 0 ? visible.length * 84 + PX * 2 : 0;
|
|
152
|
+
const langsW = activeLangsList.length > 0 ? 300 : 0;
|
|
140
153
|
|
|
141
154
|
const W = Math.round(Math.min(540, Math.max(340, profileW, statsW, langsW)));
|
|
142
155
|
const contentW = W - PX * 2;
|
|
143
156
|
|
|
144
|
-
/* ── profile section
|
|
145
|
-
const infoX = PX + avatarSize +
|
|
146
|
-
const nameY = PY +
|
|
147
|
-
const loginY = nameY +
|
|
148
|
-
const bioStartY = loginY +
|
|
157
|
+
/* ── profile section ─────────────────────────────────────── */
|
|
158
|
+
const infoX = PX + avatarSize + textGap;
|
|
159
|
+
const nameY = PY + 20;
|
|
160
|
+
const loginY = nameY + 17;
|
|
161
|
+
const bioStartY = loginY + 15;
|
|
149
162
|
const twitterY = bioStartY + bioLines.length * 14 + (bioLines.length ? 4 : 0);
|
|
150
163
|
|
|
151
|
-
let profileH = loginY +
|
|
164
|
+
let profileH = loginY + 8 - PY;
|
|
152
165
|
if (bioLines.length) profileH = bioStartY + bioLines.length * 14 - PY;
|
|
153
166
|
if (twitter) profileH = twitterY + 10 - PY;
|
|
154
|
-
profileH = Math.max(profileH, avatarSize +
|
|
167
|
+
profileH = Math.max(profileH, avatarSize + 6);
|
|
155
168
|
|
|
156
169
|
/* ── stats section ────────────────────────────────────────── */
|
|
157
|
-
const statsY = PY + profileH +
|
|
170
|
+
const statsY = PY + profileH + 16;
|
|
158
171
|
const statW = visible.length > 0 ? contentW / visible.length : 0;
|
|
159
|
-
const statsH = visible.length > 0 ?
|
|
172
|
+
const statsH = visible.length > 0 ? 58 : 0;
|
|
160
173
|
|
|
161
|
-
/* ── languages section
|
|
174
|
+
/* ── languages section ─────────────────────────────────── */
|
|
162
175
|
const totalSize = activeLangsList.reduce((s, l) => s + l.size, 0) || 1;
|
|
163
176
|
const sorted = [...activeLangsList].sort((a, b) => b.size - a.size);
|
|
164
177
|
const visualLangs = buildLanguageSegments(sorted, totalSize, contentW);
|
|
165
|
-
const barY = statsY + statsH + (statsH ?
|
|
178
|
+
const barY = statsY + statsH + (statsH ? 18 : 14);
|
|
166
179
|
const barH = 10;
|
|
167
180
|
|
|
168
181
|
const maxLegend = compact ? 0 : 6;
|
|
169
182
|
const legendLangs = visualLangs.slice(0, maxLegend);
|
|
170
183
|
const otherSize = visualLangs.slice(maxLegend).reduce((s, l) => s + l.size, 0);
|
|
171
184
|
|
|
172
|
-
const legendY = barY + barH +
|
|
173
|
-
const legendRowH =
|
|
185
|
+
const legendY = barY + barH + 14;
|
|
186
|
+
const legendRowH = 17;
|
|
174
187
|
const legendCols = 3;
|
|
175
188
|
const legendColW = contentW / legendCols;
|
|
176
189
|
const legendItems: { name: string; color: string; pct: string }[] = legendLangs.map((l) => ({
|
|
@@ -196,432 +209,326 @@ export function renderCard(
|
|
|
196
209
|
const legendRows = compact ? 0 : Math.ceil(legendItems.length / legendCols);
|
|
197
210
|
|
|
198
211
|
const hasLangs = activeLangsList.length > 0;
|
|
199
|
-
const langSectionH = hasLangs ? barH +
|
|
212
|
+
const langSectionH = hasLangs ? barH + 14 + legendRows * legendRowH + 10 : 0;
|
|
200
213
|
|
|
201
|
-
/* ── final height
|
|
202
|
-
const H = barY + (hasLangs ? langSectionH : 0) +
|
|
214
|
+
/* ── final height ────────────────────────────────────────── */
|
|
215
|
+
const H = barY + (hasLangs ? langSectionH : 0) + 14 + (hasLangs ? 0 : 6);
|
|
203
216
|
|
|
204
|
-
/* ──
|
|
217
|
+
/* ── accessibility ────────────────────────────────────────── */
|
|
205
218
|
const parts: string[] = [];
|
|
206
|
-
|
|
207
|
-
|
|
219
|
+
const svgAttrs: Record<string, string | number> = {
|
|
220
|
+
width: W,
|
|
221
|
+
height: H,
|
|
222
|
+
viewBox: `0 0 ${W} ${H}`,
|
|
223
|
+
fill: "none",
|
|
224
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
225
|
+
role: "img",
|
|
226
|
+
"aria-labelledby": "cardTitle cardDesc",
|
|
227
|
+
};
|
|
228
|
+
let svgOpen = "<svg";
|
|
229
|
+
for (const [k, v] of Object.entries(svgAttrs)) svgOpen += ` ${k}="${escapeXml(String(v))}"`;
|
|
230
|
+
parts.push(svgOpen + ">");
|
|
231
|
+
parts.push(el("title", { id: "cardTitle" }, `${escapeXml(displayName)}'s GitHub Stats`));
|
|
208
232
|
parts.push(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
W +
|
|
215
|
-
" " +
|
|
216
|
-
H +
|
|
217
|
-
'" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="cardTitle cardDesc">',
|
|
233
|
+
el(
|
|
234
|
+
"desc",
|
|
235
|
+
{ id: "cardDesc" },
|
|
236
|
+
`GitHub profile card for ${escapeXml(displayName)} with ${kFormat(stats.stars)} stars, ${kFormat(stats.commits)} commits, and ${activeLangsList.length} highlighted languages.`,
|
|
237
|
+
),
|
|
218
238
|
);
|
|
219
239
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
escapeXml(displayName) +
|
|
225
|
-
" with " +
|
|
226
|
-
kFormat(stats.stars) +
|
|
227
|
-
" stars, " +
|
|
228
|
-
kFormat(stats.commits) +
|
|
229
|
-
" commits, and " +
|
|
230
|
-
activeLangsList.length +
|
|
231
|
-
" highlighted languages.</desc>",
|
|
240
|
+
/* ── styles ──────────────────────────────────────────────── */
|
|
241
|
+
const css: string[] = [];
|
|
242
|
+
css.push(
|
|
243
|
+
`* { font-family: ${fontFamily}; text-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }`,
|
|
232
244
|
);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const cssLines: string[] = [];
|
|
236
|
-
cssLines.push("* { font-family: " + fontFamily + "; }");
|
|
237
|
-
cssLines.push(".header { font-size: 18px; font-weight: 700; fill: " + titleColor + "; }");
|
|
238
|
-
cssLines.push(".stat { font-size: 14px; font-weight: 600; fill: " + textColor + "; }");
|
|
239
|
-
cssLines.push(".bold { font-weight: 700; }");
|
|
240
|
-
cssLines.push(".icon { fill: " + iconColor + "; }");
|
|
241
|
-
cssLines.push(".lang-name { font-size: 11px; fill: " + textColor + "; opacity: 0.8; }");
|
|
242
|
-
cssLines.push(".lang-progress { fill: " + textColor + "; opacity: 0.08; }");
|
|
243
|
-
cssLines.push(
|
|
244
|
-
".meta { font-size: 12px; font-weight: 500; fill: " + textColor + "; opacity: 0.7; }",
|
|
245
|
+
css.push(
|
|
246
|
+
`.header { font-size: 17px; font-weight: 700; fill: ${titleColor}; letter-spacing: -0.015em; }`,
|
|
245
247
|
);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
+
css.push(`.stat { font-size: 14px; font-weight: 600; fill: ${textColor}; }`);
|
|
249
|
+
css.push(`.icon { fill: ${iconColor}; }`);
|
|
250
|
+
css.push(`.lang-name { font-size: 11px; fill: ${textColor}; opacity: 0.82; }`);
|
|
251
|
+
css.push(`.lang-progress { fill: ${textColor}; opacity: 0.07; }`);
|
|
252
|
+
css.push(`.meta { font-size: 12.5px; font-weight: 500; fill: ${textColor}; opacity: 0.72; }`);
|
|
253
|
+
css.push(`.bio { font-size: 11.5px; font-weight: 400; fill: ${textColor}; opacity: 0.68; }`);
|
|
254
|
+
css.push(
|
|
255
|
+
`.stat-label { font-size: 10px; font-weight: 700; fill: ${textColor}; opacity: 0.68; letter-spacing: 0.06em; }`,
|
|
248
256
|
);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
textColor +
|
|
252
|
-
"; opacity: 0.5; letter-spacing: 0.08em; }",
|
|
257
|
+
css.push(
|
|
258
|
+
`.stat-value { font-size: 16px; font-weight: 700; fill: ${textColor}; letter-spacing: -0.015em; font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }`,
|
|
253
259
|
);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
".twitter-text { font-size: 11px; font-weight: 500; fill: " + textColor + "; opacity: 0.65; }",
|
|
260
|
+
css.push(
|
|
261
|
+
`.twitter-text { font-size: 11.5px; font-weight: 500; fill: ${textColor}; opacity: 0.65; }`,
|
|
257
262
|
);
|
|
258
263
|
|
|
259
264
|
if (animate) {
|
|
260
|
-
|
|
261
|
-
|
|
265
|
+
css.push("@keyframes fadeInAnimation { from { opacity: 0; } to { opacity: 1; } }");
|
|
266
|
+
css.push(".stagger { opacity: 0; animation: fadeInAnimation 0.3s ease-in-out forwards; }");
|
|
262
267
|
}
|
|
263
268
|
|
|
264
|
-
parts.push("
|
|
269
|
+
parts.push(el("style", {}, css.join(" ")));
|
|
265
270
|
|
|
266
|
-
|
|
267
|
-
parts.push("<defs>");
|
|
268
|
-
parts.push(
|
|
269
|
-
'<clipPath id="av"><circle cx="' +
|
|
270
|
-
(PX + avatarR) +
|
|
271
|
-
'" cy="' +
|
|
272
|
-
(PY + avatarR) +
|
|
273
|
-
'" r="' +
|
|
274
|
-
avatarR +
|
|
275
|
-
'"/></clipPath>',
|
|
276
|
-
);
|
|
277
|
-
parts.push(
|
|
278
|
-
'<radialGradient id="gl" cx="12%" cy="15%" r="65%">' +
|
|
279
|
-
'<stop offset="0%" stop-color="' +
|
|
280
|
-
iconColor +
|
|
281
|
-
'" stop-opacity="0.08"/>' +
|
|
282
|
-
'<stop offset="100%" stop-color="' +
|
|
283
|
-
iconColor +
|
|
284
|
-
'" stop-opacity="0"/>' +
|
|
285
|
-
"</radialGradient>",
|
|
286
|
-
);
|
|
271
|
+
/* ── defs ────────────────────────────────────────────────── */
|
|
287
272
|
parts.push(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
273
|
+
el(
|
|
274
|
+
"defs",
|
|
275
|
+
{},
|
|
276
|
+
[
|
|
277
|
+
el(
|
|
278
|
+
"clipPath",
|
|
279
|
+
{ id: "av" },
|
|
280
|
+
el("circle", { cx: PX + avatarR, cy: PY + avatarR, r: avatarR }),
|
|
281
|
+
),
|
|
282
|
+
el(
|
|
283
|
+
"radialGradient",
|
|
284
|
+
{ id: "gl", cx: "12%", cy: "15%", r: "65%" },
|
|
285
|
+
el("stop", { offset: "0%", "stop-color": iconColor, "stop-opacity": "0.08" }) +
|
|
286
|
+
el("stop", { offset: "100%", "stop-color": iconColor, "stop-opacity": "0" }),
|
|
287
|
+
),
|
|
288
|
+
el(
|
|
289
|
+
"radialGradient",
|
|
290
|
+
{ id: "gl2", cx: "92%", cy: "95%", r: "55%" },
|
|
291
|
+
el("stop", { offset: "0%", "stop-color": titleColor, "stop-opacity": "0.035" }) +
|
|
292
|
+
el("stop", { offset: "100%", "stop-color": titleColor, "stop-opacity": "0" }),
|
|
293
|
+
),
|
|
294
|
+
el(
|
|
295
|
+
"linearGradient",
|
|
296
|
+
{ id: "ring", x1: "0", y1: "0", x2: "1", y2: "1" },
|
|
297
|
+
el("stop", { offset: "0%", "stop-color": titleColor }) +
|
|
298
|
+
el("stop", { offset: "100%", "stop-color": iconColor }),
|
|
299
|
+
),
|
|
300
|
+
el(
|
|
301
|
+
"linearGradient",
|
|
302
|
+
{ id: "card-bg", x1: "0", y1: "0", x2: W, y2: H, gradientUnits: "userSpaceOnUse" },
|
|
303
|
+
el("stop", { offset: "0%", "stop-color": bgColor }) +
|
|
304
|
+
el("stop", { offset: "82%", "stop-color": bgColor }) +
|
|
305
|
+
el("stop", { offset: "100%", "stop-color": titleColor, "stop-opacity": "0.04" }),
|
|
306
|
+
),
|
|
307
|
+
el(
|
|
308
|
+
"linearGradient",
|
|
309
|
+
{ id: "rim", x1: "0", y1: "0", x2: W, y2: "0", gradientUnits: "userSpaceOnUse" },
|
|
310
|
+
el("stop", { offset: "0%", "stop-color": iconColor, "stop-opacity": "0" }) +
|
|
311
|
+
el("stop", { offset: "45%", "stop-color": iconColor, "stop-opacity": "0.28" }) +
|
|
312
|
+
el("stop", { offset: "100%", "stop-color": iconColor, "stop-opacity": "0" }),
|
|
313
|
+
),
|
|
314
|
+
].join(""),
|
|
315
|
+
),
|
|
296
316
|
);
|
|
297
|
-
parts.push(
|
|
298
|
-
'<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">' +
|
|
299
|
-
'<stop offset="0%" stop-color="' +
|
|
300
|
-
titleColor +
|
|
301
|
-
'"/>' +
|
|
302
|
-
'<stop offset="100%" stop-color="' +
|
|
303
|
-
iconColor +
|
|
304
|
-
'"/>' +
|
|
305
|
-
"</linearGradient>",
|
|
306
|
-
);
|
|
307
|
-
parts.push(
|
|
308
|
-
'<linearGradient id="card-bg" x1="0" y1="0" x2="' +
|
|
309
|
-
W +
|
|
310
|
-
'" y2="' +
|
|
311
|
-
H +
|
|
312
|
-
'" gradientUnits="userSpaceOnUse">' +
|
|
313
|
-
'<stop offset="0%" stop-color="' +
|
|
314
|
-
bgColor +
|
|
315
|
-
'"/>' +
|
|
316
|
-
'<stop offset="82%" stop-color="' +
|
|
317
|
-
bgColor +
|
|
318
|
-
'"/>' +
|
|
319
|
-
'<stop offset="100%" stop-color="' +
|
|
320
|
-
titleColor +
|
|
321
|
-
'" stop-opacity="0.04"/>' +
|
|
322
|
-
"</linearGradient>",
|
|
323
|
-
);
|
|
324
|
-
parts.push(
|
|
325
|
-
'<linearGradient id="rim" x1="0" y1="0" x2="' +
|
|
326
|
-
W +
|
|
327
|
-
'" y2="0" gradientUnits="userSpaceOnUse">' +
|
|
328
|
-
'<stop offset="0%" stop-color="' +
|
|
329
|
-
iconColor +
|
|
330
|
-
'" stop-opacity="0"/>' +
|
|
331
|
-
'<stop offset="45%" stop-color="' +
|
|
332
|
-
iconColor +
|
|
333
|
-
'" stop-opacity="0.28"/>' +
|
|
334
|
-
'<stop offset="100%" stop-color="' +
|
|
335
|
-
iconColor +
|
|
336
|
-
'" stop-opacity="0"/>' +
|
|
337
|
-
"</linearGradient>",
|
|
338
|
-
);
|
|
339
|
-
parts.push("</defs>");
|
|
340
317
|
|
|
341
|
-
|
|
318
|
+
/* ── background & glow ───────────────────────────────────── */
|
|
342
319
|
parts.push(
|
|
343
|
-
|
|
344
|
-
(W - 1) +
|
|
345
|
-
'" height="' +
|
|
346
|
-
(H - 1) +
|
|
347
|
-
'" rx="10" fill="' +
|
|
348
|
-
"url(#card-bg)" +
|
|
349
|
-
'" stroke-opacity="1"/>',
|
|
320
|
+
el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#card-bg)" }),
|
|
350
321
|
);
|
|
351
|
-
|
|
352
|
-
// glow overlays (icon tint top-left, title tint bottom-right)
|
|
353
322
|
parts.push(
|
|
354
|
-
|
|
355
|
-
(W - 1) +
|
|
356
|
-
'" height="' +
|
|
357
|
-
(H - 1) +
|
|
358
|
-
'" rx="10" fill="url(#gl)"/>',
|
|
323
|
+
el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#gl)" }),
|
|
359
324
|
);
|
|
360
325
|
parts.push(
|
|
361
|
-
|
|
362
|
-
(W - 1) +
|
|
363
|
-
'" height="' +
|
|
364
|
-
(H - 1) +
|
|
365
|
-
'" rx="10" fill="url(#gl2)"/>',
|
|
326
|
+
el("rect", { x: "0.5", y: "0.5", width: W - 1, height: H - 1, rx: 12, fill: "url(#gl2)" }),
|
|
366
327
|
);
|
|
367
328
|
parts.push(
|
|
368
|
-
|
|
369
|
-
PX
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
329
|
+
el("line", {
|
|
330
|
+
x1: PX,
|
|
331
|
+
y1: "1.5",
|
|
332
|
+
x2: W - PX,
|
|
333
|
+
y2: "1.5",
|
|
334
|
+
stroke: "url(#rim)",
|
|
335
|
+
"stroke-width": 1,
|
|
336
|
+
}),
|
|
373
337
|
);
|
|
374
338
|
|
|
375
|
-
// border
|
|
376
339
|
if (!hideBorder) {
|
|
377
340
|
parts.push(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
341
|
+
el("rect", {
|
|
342
|
+
x: "0.5",
|
|
343
|
+
y: "0.5",
|
|
344
|
+
width: W - 1,
|
|
345
|
+
height: H - 1,
|
|
346
|
+
rx: 12,
|
|
347
|
+
fill: "none",
|
|
348
|
+
stroke: borderColor,
|
|
349
|
+
"stroke-opacity": "0.5",
|
|
350
|
+
}),
|
|
385
351
|
);
|
|
386
352
|
}
|
|
387
353
|
|
|
388
|
-
|
|
354
|
+
/* ── avatar ───────────────────────────────────────────────── */
|
|
389
355
|
parts.push(
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
356
|
+
el(
|
|
357
|
+
"g",
|
|
358
|
+
{},
|
|
359
|
+
el("circle", {
|
|
360
|
+
cx: PX + avatarR + 1,
|
|
361
|
+
cy: PY + avatarR + 1,
|
|
362
|
+
r: avatarR + 3,
|
|
363
|
+
fill: titleColor,
|
|
364
|
+
opacity: "0.08",
|
|
365
|
+
}) +
|
|
366
|
+
el("image", {
|
|
367
|
+
href: avatar,
|
|
368
|
+
x: PX,
|
|
369
|
+
y: PY,
|
|
370
|
+
width: avatarSize,
|
|
371
|
+
height: avatarSize,
|
|
372
|
+
"clip-path": "url(#av)",
|
|
373
|
+
preserveAspectRatio: "xMidYMid slice",
|
|
374
|
+
}) +
|
|
375
|
+
el("circle", {
|
|
376
|
+
cx: PX + avatarR,
|
|
377
|
+
cy: PY + avatarR,
|
|
378
|
+
r: avatarR + 1.5,
|
|
379
|
+
fill: "none",
|
|
380
|
+
stroke: "url(#ring)",
|
|
381
|
+
"stroke-opacity": "0.55",
|
|
382
|
+
"stroke-width": "1.5",
|
|
383
|
+
}),
|
|
384
|
+
),
|
|
419
385
|
);
|
|
420
386
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
parts.push('<text x="' + infoX + '" y="' + nameY + '" class="header">' + nameEsc + "</text>");
|
|
426
|
-
|
|
427
|
-
// username + pronouns
|
|
428
|
-
const metaStr = pronouns ? "@" + loginEsc + " \u00b7 " + pronouns : "@" + loginEsc;
|
|
429
|
-
parts.push('<text x="' + infoX + '" y="' + loginY + '" class="meta">' + metaStr + "</text>");
|
|
430
|
-
|
|
431
|
-
// bio
|
|
387
|
+
/* ── profile text ─────────────────────────────────────────── */
|
|
388
|
+
const textParts: string[] = [];
|
|
389
|
+
textParts.push(el("text", { x: infoX, y: nameY, class: "header" }, nameEsc));
|
|
390
|
+
textParts.push(el("text", { x: infoX, y: loginY, class: "meta" }, metaStr));
|
|
432
391
|
for (let i = 0; i < bioLines.length; i++) {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
infoX +
|
|
436
|
-
'" y="' +
|
|
437
|
-
(bioStartY + i * 14) +
|
|
438
|
-
'" class="bio">' +
|
|
439
|
-
escapeXml(bioLines[i]!.trim()) +
|
|
440
|
-
"</text>",
|
|
392
|
+
textParts.push(
|
|
393
|
+
el("text", { x: infoX, y: bioStartY + i * 14, class: "bio" }, escapeXml(bioLines[i]!.trim())),
|
|
441
394
|
);
|
|
442
395
|
}
|
|
443
|
-
|
|
444
|
-
// twitter
|
|
445
396
|
if (twitter) {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
(twitterY - 9) +
|
|
451
|
-
')">' +
|
|
397
|
+
textParts.push(
|
|
398
|
+
el(
|
|
399
|
+
"g",
|
|
400
|
+
{ transform: `translate(${infoX},${twitterY - 9})` },
|
|
452
401
|
icon("x", textColor, 11) +
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
"</text></g>",
|
|
402
|
+
el("text", { x: 15, y: 9, class: "twitter-text" }, `@${twitter}`),
|
|
403
|
+
),
|
|
456
404
|
);
|
|
457
405
|
}
|
|
406
|
+
parts.push(el("g", {}, textParts.join("")));
|
|
458
407
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
// ── stats row ──────────────────────────────────────────────
|
|
408
|
+
/* ── stats row ───────────────────────────────────────────── */
|
|
462
409
|
if (visible.length > 0) {
|
|
463
|
-
|
|
464
|
-
'<rect x="' +
|
|
465
|
-
PX +
|
|
466
|
-
'" y="' +
|
|
467
|
-
(statsY - 8) +
|
|
468
|
-
'" width="' +
|
|
469
|
-
contentW +
|
|
470
|
-
'" height="' +
|
|
471
|
-
(statsH + 8) +
|
|
472
|
-
'" rx="8" fill="' +
|
|
473
|
-
textColor +
|
|
474
|
-
'" opacity="0.035"/>',
|
|
475
|
-
);
|
|
476
|
-
|
|
477
|
-
parts.push('<g transform="translate(0,0)">');
|
|
410
|
+
const statParts: string[] = [];
|
|
478
411
|
|
|
479
412
|
for (let i = 0; i < visible.length; i++) {
|
|
480
413
|
const d = visible[i]!;
|
|
481
414
|
const val = stats[d.key as keyof UserStats];
|
|
482
415
|
const cx = PX + statW * i + statW / 2;
|
|
483
|
-
const
|
|
484
|
-
const iy = statsY + 2;
|
|
416
|
+
const iy = statsY + 4;
|
|
485
417
|
|
|
486
|
-
const staggerStyle = animate ? ' style="animation-delay: ' + (i + 3) * 150 + 'ms"' : "";
|
|
487
418
|
const staggerClass = animate ? " stagger" : "";
|
|
488
419
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
420
|
+
// Vertical stack: icon → value → label, all centered, with airy gaps.
|
|
421
|
+
const iconSize = 15;
|
|
422
|
+
const iconY = 2;
|
|
423
|
+
const valueY = 37;
|
|
424
|
+
const labelY = 52;
|
|
425
|
+
|
|
426
|
+
const cellParts: string[] = [];
|
|
427
|
+
cellParts.push(
|
|
428
|
+
el(
|
|
429
|
+
"g",
|
|
430
|
+
{ transform: `translate(${-iconSize / 2},${iconY})`, class: "stat-icon" },
|
|
431
|
+
icon(d.iconName, iconColor, iconSize),
|
|
432
|
+
),
|
|
501
433
|
);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
parts.push(
|
|
505
|
-
'<g transform="translate(' +
|
|
506
|
-
cx +
|
|
507
|
-
"," +
|
|
508
|
-
iy +
|
|
509
|
-
')" class="stat' +
|
|
510
|
-
staggerClass +
|
|
511
|
-
'"' +
|
|
512
|
-
staggerStyle +
|
|
513
|
-
">",
|
|
434
|
+
cellParts.push(
|
|
435
|
+
el("text", { x: 0, y: valueY, class: "stat-value", "text-anchor": "middle" }, kFormat(val)),
|
|
514
436
|
);
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
437
|
+
cellParts.push(
|
|
438
|
+
el(
|
|
439
|
+
"text",
|
|
440
|
+
{ x: 0, y: labelY, class: "stat-label", "text-anchor": "middle" },
|
|
441
|
+
d.label.toUpperCase(),
|
|
442
|
+
),
|
|
519
443
|
);
|
|
520
444
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
);
|
|
532
|
-
parts.push(
|
|
533
|
-
'<text x="' + (-rowW / 2 + 14) + '" y="40" class="stat-label">' + labelText + "</text>",
|
|
445
|
+
statParts.push(
|
|
446
|
+
el(
|
|
447
|
+
"g",
|
|
448
|
+
{
|
|
449
|
+
transform: `translate(${cx},${iy})`,
|
|
450
|
+
class: `stat${staggerClass}`,
|
|
451
|
+
style: animate ? `animation-delay: ${(i + 3) * 150}ms` : undefined,
|
|
452
|
+
},
|
|
453
|
+
cellParts.join(""),
|
|
454
|
+
),
|
|
534
455
|
);
|
|
535
456
|
|
|
536
|
-
|
|
457
|
+
// Subtle divider between stats (not after the last one).
|
|
458
|
+
if (i < visible.length - 1) {
|
|
459
|
+
const dx = PX + statW * (i + 1);
|
|
460
|
+
statParts.push(
|
|
461
|
+
el("line", {
|
|
462
|
+
x1: dx,
|
|
463
|
+
y1: statsY + 4,
|
|
464
|
+
x2: dx,
|
|
465
|
+
y2: statsY + statsH - 6,
|
|
466
|
+
stroke: textColor,
|
|
467
|
+
"stroke-opacity": "0.12",
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
537
471
|
}
|
|
538
|
-
|
|
539
|
-
parts.push("</g>");
|
|
472
|
+
parts.push(el("g", {}, statParts.join("")));
|
|
540
473
|
}
|
|
541
474
|
|
|
542
|
-
|
|
475
|
+
/* ── languages ───────────────────────────────────────────── */
|
|
543
476
|
if (hasLangs) {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
PX
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
'" stroke="' +
|
|
555
|
-
textColor +
|
|
556
|
-
'" stroke-opacity="0.08"/>',
|
|
477
|
+
const langParts: string[] = [];
|
|
478
|
+
langParts.push(
|
|
479
|
+
el("line", {
|
|
480
|
+
x1: PX,
|
|
481
|
+
y1: barY - 8,
|
|
482
|
+
x2: W - PX,
|
|
483
|
+
y2: barY - 8,
|
|
484
|
+
stroke: textColor,
|
|
485
|
+
"stroke-opacity": "0.08",
|
|
486
|
+
}),
|
|
557
487
|
);
|
|
558
488
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
489
|
+
langParts.push(
|
|
490
|
+
el(
|
|
491
|
+
"clipPath",
|
|
492
|
+
{ id: "lang-clip" },
|
|
493
|
+
el(
|
|
494
|
+
"rect",
|
|
495
|
+
{
|
|
496
|
+
x: PX,
|
|
497
|
+
y: barY,
|
|
498
|
+
width: animate ? "0" : contentW,
|
|
499
|
+
height: barH,
|
|
500
|
+
rx: 5,
|
|
501
|
+
},
|
|
502
|
+
animate
|
|
503
|
+
? el("animate", {
|
|
504
|
+
attributeName: "width",
|
|
505
|
+
from: "0",
|
|
506
|
+
to: contentW,
|
|
507
|
+
dur: "0.6s",
|
|
508
|
+
fill: "freeze",
|
|
509
|
+
})
|
|
510
|
+
: "",
|
|
511
|
+
),
|
|
512
|
+
),
|
|
578
513
|
);
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
PX +
|
|
582
|
-
'" y="' +
|
|
583
|
-
barY +
|
|
584
|
-
'" width="' +
|
|
585
|
-
contentW +
|
|
586
|
-
'" height="' +
|
|
587
|
-
barH +
|
|
588
|
-
'" rx="5" class="lang-progress"/>',
|
|
514
|
+
langParts.push(
|
|
515
|
+
el("rect", { x: PX, y: barY, width: contentW, height: barH, rx: 5, class: "lang-progress" }),
|
|
589
516
|
);
|
|
590
517
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const activeLangs = visualLangs;
|
|
518
|
+
const barParts: string[] = [];
|
|
594
519
|
let off = 0;
|
|
595
|
-
for (let i = 0; i <
|
|
596
|
-
const lang =
|
|
520
|
+
for (let i = 0; i < visualLangs.length; i++) {
|
|
521
|
+
const lang = visualLangs[i]!;
|
|
597
522
|
const pct = ((lang.size / totalSize) * 100).toFixed(1);
|
|
598
|
-
let w = i ===
|
|
523
|
+
let w = i === visualLangs.length - 1 ? contentW - off : (lang.size / totalSize) * contentW;
|
|
599
524
|
if (w < 0) w = 0;
|
|
600
525
|
const x = PX + off;
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
parts.push(
|
|
605
|
-
'<rect x="' +
|
|
606
|
-
x +
|
|
607
|
-
'" y="' +
|
|
608
|
-
barY +
|
|
609
|
-
'" width="' +
|
|
610
|
-
w +
|
|
611
|
-
'" height="' +
|
|
612
|
-
barH +
|
|
613
|
-
'" fill="' +
|
|
614
|
-
lang.color +
|
|
615
|
-
'">' +
|
|
616
|
-
tooltip +
|
|
617
|
-
"</rect>",
|
|
618
|
-
);
|
|
526
|
+
const tooltip = compact ? "" : el("title", {}, `${escapeXml(lang.name)} ${pct}%`);
|
|
527
|
+
barParts.push(el("rect", { x, y: barY, width: w, height: barH, fill: lang.color }, tooltip));
|
|
619
528
|
off += w;
|
|
620
529
|
}
|
|
530
|
+
langParts.push(el("g", { "clip-path": "url(#lang-clip)" }, barParts.join("")));
|
|
621
531
|
|
|
622
|
-
parts.push("</g>");
|
|
623
|
-
|
|
624
|
-
// legend (3-column grid)
|
|
625
532
|
for (let i = 0; i < legendItems.length; i++) {
|
|
626
533
|
const item = legendItems[i]!;
|
|
627
534
|
const col = i % legendCols;
|
|
@@ -629,30 +536,26 @@ export function renderCard(
|
|
|
629
536
|
const lx = PX + col * legendColW;
|
|
630
537
|
const ly = legendY + row * legendRowH;
|
|
631
538
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
(lx +
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
"%</text>",
|
|
539
|
+
langParts.push(
|
|
540
|
+
el(
|
|
541
|
+
"g",
|
|
542
|
+
{
|
|
543
|
+
class: animate ? "stagger" : undefined,
|
|
544
|
+
style: animate ? `animation-delay: ${(i + 3) * 150}ms` : undefined,
|
|
545
|
+
},
|
|
546
|
+
el("circle", { cx: lx + 5, cy: ly, r: 4, fill: item.color }) +
|
|
547
|
+
el(
|
|
548
|
+
"text",
|
|
549
|
+
{ x: lx + 14, y: ly + 4, class: "lang-name", "font-weight": "500" },
|
|
550
|
+
`${escapeXml(item.name)} ${item.pct}%`,
|
|
551
|
+
),
|
|
552
|
+
),
|
|
647
553
|
);
|
|
648
|
-
parts.push("</g>");
|
|
649
554
|
}
|
|
650
555
|
|
|
651
|
-
parts.push("
|
|
556
|
+
parts.push(el("g", {}, langParts.join("")));
|
|
652
557
|
}
|
|
653
558
|
|
|
654
|
-
// close
|
|
655
559
|
parts.push("</svg>");
|
|
656
|
-
|
|
657
560
|
return parts.join("");
|
|
658
561
|
}
|