Skip to content

Commit be6a040

Browse files
committed
beautiful chart
1 parent 88ad39f commit be6a040

2 files changed

Lines changed: 274 additions & 38 deletions

File tree

‎.github/workflows/deploy.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ on:
55
branches: ['main']
66
workflow_dispatch:
77
schedule:
8-
- cron: '0 19 * * *'
8+
- cron: '0 * * * *'
99

1010
permissions:
1111
contents: read

‎scripts/gen-star-chart.mjs‎

Lines changed: 273 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import { writeFileSync, mkdirSync } from 'fs';
1+
import { mkdirSync, writeFileSync } from 'node:fs';
22

33
const repo = 'LotusInputMethod/fcitx5-lotus';
44
const token = process.env.STAR_TOKEN;
55

6-
if (!repo || !token) {
7-
throw new Error('Missing GITHUB_REPOSITORY or STAR_TOKEN');
6+
if (!token) {
7+
throw new Error('Missing STAR_TOKEN');
88
}
99

1010
const stars = [];
1111
let page = 1;
12+
1213
while (true) {
1314
const res = await fetch(
1415
`https://api.github.com/repos/${repo}/stargazers?per_page=100&page=${page}`,
@@ -20,52 +21,287 @@ while (true) {
2021
},
2122
},
2223
);
23-
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`);
24+
25+
if (!res.ok) {
26+
throw new Error(`GitHub API ${res.status}: ${await res.text()}`);
27+
}
28+
2429
const data = await res.json();
30+
2531
if (data.length === 0) break;
26-
for (const s of data) stars.push(new Date(s.starred_at).getTime());
32+
33+
for (const star of data) {
34+
if (star.starred_at) {
35+
stars.push(new Date(star.starred_at));
36+
}
37+
}
38+
2739
if (data.length < 100) break;
28-
page++;
40+
41+
page += 1;
42+
}
43+
44+
if (stars.length === 0) {
45+
throw new Error('No stargazer data returned');
2946
}
3047

3148
stars.sort((a, b) => a - b);
3249

33-
const byDay = new Map();
34-
stars.forEach((t, i) => {
35-
byDay.set(new Date(t).toISOString().slice(0, 10), i + 1);
50+
const formatDate = (date) => date.toISOString().slice(0, 10);
51+
52+
const parseDate = (dateString) => {
53+
const [year, month, day] = dateString.split('-').map(Number);
54+
return new Date(Date.UTC(year, month - 1, day));
55+
};
56+
57+
const addDays = (date, days) => {
58+
const result = new Date(date);
59+
result.setUTCDate(result.getUTCDate() + days);
60+
return result;
61+
};
62+
63+
const firstDay = parseDate(formatDate(stars[0]));
64+
const lastDay = parseDate(formatDate(new Date()));
65+
66+
const starsByDay = new Map();
67+
68+
for (const star of stars) {
69+
const day = formatDate(star);
70+
starsByDay.set(day, (starsByDay.get(day) ?? 0) + 1);
71+
}
72+
73+
const points = [];
74+
let count = 0;
75+
let currentDay = firstDay;
76+
77+
while (currentDay <= lastDay) {
78+
const day = formatDate(currentDay);
79+
80+
count += starsByDay.get(day) ?? 0;
81+
82+
points.push({
83+
day,
84+
date: new Date(currentDay),
85+
count,
86+
});
87+
88+
currentDay = addDays(currentDay, 1);
89+
}
90+
91+
const W = 1200;
92+
const H = 600;
93+
94+
const PAD_LEFT = 80;
95+
const PAD_RIGHT = 40;
96+
const PAD_TOP = 70;
97+
const PAD_BOTTOM = 85;
98+
99+
const CHART_W = W - PAD_LEFT - PAD_RIGHT;
100+
const CHART_H = H - PAD_TOP - PAD_BOTTOM;
101+
102+
const maxCount = Math.max(...points.map((point) => point.count), 1);
103+
const maxDate = points[points.length - 1].date.getTime();
104+
const minDate = points[0].date.getTime();
105+
const dateRange = Math.max(maxDate - minDate, 1);
106+
107+
const getX = (point) => {
108+
const progress = (point.date.getTime() - minDate) / dateRange;
109+
return PAD_LEFT + progress * CHART_W;
110+
};
111+
112+
const getY = (point) => {
113+
const progress = point.count / maxCount;
114+
return PAD_TOP + CHART_H - progress * CHART_H;
115+
};
116+
117+
const linePoints = points
118+
.map((point) => `${getX(point).toFixed(2)},${getY(point).toFixed(2)}`)
119+
.join(' ');
120+
121+
const areaPoints = [
122+
`${PAD_LEFT},${PAD_TOP + CHART_H}`,
123+
...points.map(
124+
(point) => `${getX(point).toFixed(2)},${getY(point).toFixed(2)}`,
125+
),
126+
`${PAD_LEFT + CHART_W},${PAD_TOP + CHART_H}`,
127+
].join(' ');
128+
129+
const escapeXml = (value) =>
130+
String(value)
131+
.replaceAll('&', '&amp;')
132+
.replaceAll('<', '&lt;')
133+
.replaceAll('>', '&gt;')
134+
.replaceAll('"', '&quot;')
135+
.replaceAll("'", '&apos;');
136+
137+
const formatLabel = (dateString) => {
138+
const [year, month, day] = dateString.split('-');
139+
return `${day}/${month}/${year}`;
140+
};
141+
142+
const tickCount = 8;
143+
const xTicks = Array.from({ length: tickCount }, (_, index) => {
144+
const progress = index / (tickCount - 1);
145+
const pointIndex = Math.round(progress * (points.length - 1));
146+
const point = points[pointIndex];
147+
148+
return {
149+
x: getX(point),
150+
label: formatLabel(point.day),
151+
};
36152
});
37-
const points = [...byDay.entries()].map(([day, count]) => ({
38-
day,
39-
count,
40-
}));
41-
42-
const W = 800,
43-
H = 400,
44-
PAD = 50;
45-
const maxCount = Math.max(...points.map((p) => p.count), 1);
46-
const n = points.length;
47-
const xy = points.map((p, i) => {
48-
const x = PAD + (i / Math.max(n - 1, 1)) * (W - 2 * PAD);
49-
const y = H - PAD - (p.count / maxCount) * (H - 2 * PAD);
50-
return `${x.toFixed(1)},${y.toFixed(1)}`;
153+
154+
const yTickCount = 5;
155+
const yTicks = Array.from({ length: yTickCount }, (_, index) => {
156+
const progress = index / (yTickCount - 1);
157+
const value = Math.round(maxCount * progress);
158+
const y = PAD_TOP + CHART_H - progress * CHART_H;
159+
160+
return {
161+
value,
162+
y,
163+
};
51164
});
52165

53-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" font-family="sans-serif">
54-
<rect width="${W}" height="${H}" fill="#0d1117" rx="8"/>
55-
<text x="${W / 2}" y="32" fill="#e6edf3" font-size="18" text-anchor="middle" font-weight="bold">
56-
${repo} — Star History (${maxCount} ⭐)
57-
</text>
58-
<line x1="${PAD}" y1="${H - PAD}" x2="${W - PAD}" y2="${H - PAD}" stroke="#30363d"/>
59-
<line x1="${PAD}" y1="${PAD}" x2="${PAD}" y2="${H - PAD}" stroke="#30363d"/>
60-
<text x="${PAD - 8}" y="${PAD + 5}" fill="#8b949e" font-size="12" text-anchor="end">${maxCount}</text>
61-
<text x="${PAD - 8}" y="${H - PAD + 5}" fill="#8b949e" font-size="12" text-anchor="end">0</text>
62-
<text x="${PAD}" y="${H - PAD + 22}" fill="#8b949e" font-size="12">${points[0]?.day ?? ''}</text>
63-
<text x="${W - PAD}" y="${H - PAD + 22}" fill="#8b949e" font-size="12" text-anchor="end">${points[n - 1]?.day ?? ''}</text>
64-
<polyline points="${xy.join(' ')}" fill="none" stroke="#f0b429" stroke-width="2.5" stroke-linejoin="round"/>
65-
</svg>`;
166+
const horizontalGrid = yTicks
167+
.map(
168+
(tick) => `
169+
<line
170+
x1="${PAD_LEFT}"
171+
y1="${tick.y.toFixed(2)}"
172+
x2="${PAD_LEFT + CHART_W}"
173+
y2="${tick.y.toFixed(2)}"
174+
stroke="#30363d"
175+
stroke-width="1"
176+
/>
177+
<text
178+
x="${PAD_LEFT - 12}"
179+
y="${(tick.y + 5).toFixed(2)}"
180+
fill="#8b949e"
181+
font-size="13"
182+
text-anchor="end"
183+
>${tick.value}</text>
184+
`,
185+
)
186+
.join('');
187+
188+
const verticalTicks = xTicks
189+
.map(
190+
(tick) => `
191+
<line
192+
x1="${tick.x.toFixed(2)}"
193+
y1="${PAD_TOP}"
194+
x2="${tick.x.toFixed(2)}"
195+
y2="${PAD_TOP + CHART_H}"
196+
stroke="#21262d"
197+
stroke-width="1"
198+
/>
199+
<text
200+
x="${tick.x.toFixed(2)}"
201+
y="${PAD_TOP + CHART_H + 28}"
202+
fill="#8b949e"
203+
font-size="13"
204+
text-anchor="middle"
205+
>${tick.label}</text>
206+
`,
207+
)
208+
.join('');
209+
210+
const svg = `
211+
<svg
212+
xmlns="http://www.w3.org/2000/svg"
213+
viewBox="0 0 ${W} ${H}"
214+
role="img"
215+
aria-labelledby="title description"
216+
font-family="-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
217+
>
218+
<title id="title">GitHub Star History for ${escapeXml(repo)}</title>
219+
<desc id="description">
220+
Star count increased to ${maxCount} from ${formatLabel(points[0].day)}
221+
to ${formatLabel(points[points.length - 1].day)}.
222+
</desc>
223+
224+
<rect
225+
width="${W}"
226+
height="${H}"
227+
rx="12"
228+
fill="#0d1117"
229+
/>
230+
231+
<text
232+
x="${W / 2}"
233+
y="36"
234+
fill="#e6edf3"
235+
font-size="21"
236+
font-weight="600"
237+
text-anchor="middle"
238+
>${escapeXml(repo)} — Star History</text>
239+
240+
<text
241+
x="${W / 2}"
242+
y="59"
243+
fill="#8b949e"
244+
font-size="13"
245+
text-anchor="middle"
246+
>${maxCount.toLocaleString()} stars</text>
247+
248+
${horizontalGrid}
249+
${verticalTicks}
250+
251+
<line
252+
x1="${PAD_LEFT}"
253+
y1="${PAD_TOP}"
254+
x2="${PAD_LEFT}"
255+
y2="${PAD_TOP + CHART_H}"
256+
stroke="#8b949e"
257+
stroke-width="1"
258+
/>
259+
260+
<line
261+
x1="${PAD_LEFT}"
262+
y1="${PAD_TOP + CHART_H}"
263+
x2="${PAD_LEFT + CHART_W}"
264+
y2="${PAD_TOP + CHART_H}"
265+
stroke="#8b949e"
266+
stroke-width="1"
267+
/>
268+
269+
<polygon
270+
points="${areaPoints}"
271+
fill="#f0b429"
272+
fill-opacity="0.12"
273+
/>
274+
275+
<polyline
276+
points="${linePoints}"
277+
fill="none"
278+
stroke="#f0b429"
279+
stroke-width="3"
280+
stroke-linecap="round"
281+
stroke-linejoin="round"
282+
/>
283+
284+
<circle
285+
cx="${getX(points[points.length - 1]).toFixed(2)}"
286+
cy="${getY(points[points.length - 1]).toFixed(2)}"
287+
r="5"
288+
fill="#f0b429"
289+
/>
290+
291+
<text
292+
x="${getX(points[points.length - 1]) - 10}"
293+
y="${getY(points[points.length - 1]) - 14}"
294+
fill="#f0b429"
295+
font-size="14"
296+
font-weight="600"
297+
text-anchor="end"
298+
>${maxCount.toLocaleString()}</text>
299+
</svg>
300+
`.trim();
66301

67302
mkdirSync('public', { recursive: true });
68303
writeFileSync('public/starcharts.svg', svg);
304+
69305
console.log(
70-
`Generated public/starcharts.svg with ${maxCount} stars, ${n} points`,
306+
`Generated public/starcharts.svg with ${maxCount} stars across ${points.length} days`,
71307
);

0 commit comments

Comments
 (0)