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