this repo has no description
1import { XMLParser } from 'fast-xml-parser';
2
3type ActivityMap = Map<string, number>;
4
5type HeatmapDay = {
6 date: Date;
7 isoDate: string;
8 count: number;
9 level: 0 | 1 | 2 | 3 | 4;
10 weekIndex: number;
11 weekdayIndex: number;
12 inRange: boolean;
13};
14
15type GraphSvgOptions = {
16 account: string;
17 range: number;
18 days: HeatmapDay[];
19 error?: string;
20 logoLightDataUri?: string;
21 logoDarkDataUri?: string;
22};
23
24type ParsedFeed = {
25 feed?: {
26 entry?: Array<{ updated?: string }> | { updated?: string };
27 };
28};
29
30const CELL_SIZE = 11;
31const CELL_GAP = 3;
32const HEADER_HEIGHT = 50;
33const FOOTER_HEIGHT = 28;
34const LEFT_GUTTER = 36;
35const RIGHT_GUTTER = 16;
36const TOP_GUTTER = 18;
37const MONTH_LABEL_OFFSET = 12;
38const CARD_RADIUS = 12;
39const HEADER_TEXT_X = 16;
40const HEADER_TITLE_Y = 35;
41const LOGO_WIDTH = 140;
42const LOGO_HEIGHT = 24;
43const LOGO_URL_LIGHT = 'https://assets.tangled.network/tangled_logotype_black_on_trans.svg';
44const LOGO_URL_DARK = 'https://assets.tangled.network/tangled_logotype_white_on_trans.svg';
45const MIN_CANVAS_WIDTH = 700;
46const HEADER_CHAR_WIDTH = 8.2;
47const SUMMARY_CHAR_WIDTH = 6.2;
48const WEEKDAY_LABELS = [
49 { label: 'Mon', row: 1 },
50 { label: 'Wed', row: 3 },
51 { label: 'Fri', row: 5 },
52];
53const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
54
55export function normalizeAccount(input: string | undefined): string | null {
56 if (!input) {
57 return null;
58 }
59
60 const account = input.trim().replace(/^@/, '');
61 if (!account || !/^[a-z0-9._-]+$/i.test(account)) {
62 return null;
63 }
64
65 return account;
66}
67
68export function clampRange(input: string | undefined): number {
69 const parsed = Number.parseInt(input ?? '12', 10);
70 if (Number.isNaN(parsed)) {
71 return 12;
72 }
73
74 return Math.min(12, Math.max(1, parsed));
75}
76
77export async function fetchActivityByDay(account: string, range: number): Promise<HeatmapDay[]> {
78 const feedUrl = `https://tangled.org/${encodeURIComponent(account)}/feed.atom`;
79 const response = await fetch(feedUrl, {
80 headers: {
81 Accept: 'application/atom+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
82 'User-Agent': 'tangled-activity-graph',
83 },
84 });
85
86 if (!response.ok) {
87 throw new Error(`Feed request failed with ${response.status}`);
88 }
89
90 const xml = await response.text();
91 const parser = new XMLParser({
92 ignoreAttributes: false,
93 removeNSPrefix: true,
94 parseTagValue: true,
95 trimValues: true,
96 });
97 const parsed = parser.parse(xml) as ParsedFeed;
98 const entries = toEntryArray(parsed.feed?.entry);
99
100 const now = startOfUtcDay(new Date());
101 const rangeStart = startOfUtcDay(subtractUtcMonths(now, range));
102 const calendarStart = startOfUtcWeek(rangeStart);
103 const counts = aggregateEntries(entries, rangeStart, now);
104
105 return buildDays(counts, rangeStart, calendarStart, now);
106}
107
108export async function fetchLogoDataUri(url: string): Promise<string | undefined> {
109 try {
110 const response = await fetch(url, {
111 headers: { 'User-Agent': 'tangled-activity-graph' },
112 });
113 if (!response.ok) {
114 return undefined;
115 }
116 const contentType = response.headers.get('content-type') ?? 'image/svg+xml';
117 const buffer = await response.arrayBuffer();
118 const base64 = Buffer.from(buffer).toString('base64');
119 return `data:${contentType};base64,${base64}`;
120 } catch {
121 return undefined;
122 }
123}
124
125export function buildGraphSvg({ account, range, days, error, logoLightDataUri, logoDarkDataUri }: GraphSvgOptions): string {
126 const weekCount = days.length === 0 ? 1 : Math.max(...days.map((day) => day.weekIndex)) + 1;
127 const graphWidth = weekCount * (CELL_SIZE + CELL_GAP) - CELL_GAP;
128 const graphHeight = 7 * (CELL_SIZE + CELL_GAP) - CELL_GAP;
129 const graphStartY = TOP_GUTTER + HEADER_HEIGHT;
130 const baseGraphWidth = LEFT_GUTTER + graphWidth + RIGHT_GUTTER;
131 const headerText = `@${account} Tangled activity`;
132
133 const monthLabels = buildMonthLabels(days);
134 const maxCount = days.reduce((max, day) => Math.max(max, day.count), 0);
135 const activeDays = days.reduce((sum, day) => sum + (day.count > 0 ? 1 : 0), 0);
136 const totalCount = days.reduce((sum, day) => sum + day.count, 0);
137 const summaryText = error
138 ? error
139 : `${totalCount} activities on ${activeDays} active days, peak ${maxCount}/day`;
140 const headerRequiredWidth = HEADER_TEXT_X + Math.ceil(headerText.length * HEADER_CHAR_WIDTH) + RIGHT_GUTTER;
141 const summaryRequiredWidth = LEFT_GUTTER + Math.ceil(summaryText.length * SUMMARY_CHAR_WIDTH) + RIGHT_GUTTER;
142 const width = Math.max(MIN_CANVAS_WIDTH, baseGraphWidth, headerRequiredWidth, summaryRequiredWidth);
143 const height = TOP_GUTTER + HEADER_HEIGHT + graphHeight + FOOTER_HEIGHT;
144
145 const rects = days
146 .map((day) => {
147 const x = LEFT_GUTTER + day.weekIndex * (CELL_SIZE + CELL_GAP);
148 const y = graphStartY + day.weekdayIndex * (CELL_SIZE + CELL_GAP);
149 const cellClass = day.inRange ? `heat-${day.level}` : 'oor';
150 const tooltip = `${day.isoDate}: ${day.count} activit${day.count === 1 ? 'y' : 'ies'}`;
151
152 return `<g><title>${escapeXml(tooltip)}</title><rect x="${x}" y="${y}" width="${CELL_SIZE}" height="${CELL_SIZE}" rx="2" class="${cellClass}" /></g>`;
153 })
154 .join('');
155
156 const weekdays = WEEKDAY_LABELS.map(({ label, row }) => {
157 const y = graphStartY + row * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 1;
158 return `<text x="8" y="${y}" font-size="10" class="label">${label}</text>`;
159 }).join('');
160
161 const months = monthLabels
162 .map(({ label, weekIndex }) => {
163 const x = LEFT_GUTTER + weekIndex * (CELL_SIZE + CELL_GAP);
164 return `<text x="${x}" y="${graphStartY - MONTH_LABEL_OFFSET}" font-size="10" class="label">${label}</text>`;
165 })
166 .join('');
167
168 const summary = error
169 ? `<text x="${LEFT_GUTTER}" y="${height - 10}" font-size="11" class="error">${escapeXml(error)}</text>`
170 : `<text x="${LEFT_GUTTER}" y="${height - 10}" font-size="11" class="summary">${escapeXml(summaryText)}</text>`;
171 const accountUrl = `https://tangled.org/@${encodeURIComponent(account)}`;
172
173 return `<?xml version="1.0" encoding="UTF-8"?>
174<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet" style="max-width:100%;height:auto" role="img" aria-labelledby="title desc">
175 <title id="title">${escapeXml(headerText)}</title>
176 <desc id="desc">Calendar heatmap of Tangled activities for ${escapeXml(account)}</desc>
177 <style>
178 :root {
179 --bg: #ffffff;
180 --border: #e5e7eb;
181 --title: #111827;
182 --label: #6b7280;
183 --summary: #4b5563;
184 --error: #b91c1c;
185 --heat-0: #ebedf0;
186 --heat-1: #d1d5db;
187 --heat-2: #9ca3af;
188 --heat-3: #4b5563;
189 --heat-4: #111827;
190 --oor-fill: #f8fafc;
191 --oor-stroke: #e5e7eb;
192 }
193 @media (prefers-color-scheme: dark) {
194 :root {
195 --bg: #161b22;
196 --border: #30363d;
197 --title: #e6edf3;
198 --label: #8b949e;
199 --summary: #8b949e;
200 --error: #f85149;
201 --heat-0: #21262d;
202 --heat-1: #374151;
203 --heat-2: #6b7280;
204 --heat-3: #9ca3af;
205 --heat-4: #e6edf3;
206 --oor-fill: #0d1117;
207 --oor-stroke: #21262d;
208 }
209 }
210 .bg { fill: var(--bg); stroke: var(--border); }
211 .title { fill: var(--title); }
212 .label { fill: var(--label); }
213 .summary { fill: var(--summary); }
214 .error { fill: var(--error); }
215 .heat-0 { fill: var(--heat-0); }
216 .heat-1 { fill: var(--heat-1); }
217 .heat-2 { fill: var(--heat-2); }
218 .heat-3 { fill: var(--heat-3); }
219 .heat-4 { fill: var(--heat-4); }
220 .oor { fill: var(--oor-fill); stroke: var(--oor-stroke); }
221 .logo-dark { display: none; }
222 @media (prefers-color-scheme: dark) {
223 .logo-light { display: none; }
224 .logo-dark { display: block; }
225 }
226 </style>
227 <a href="${accountUrl}" target="_blank">
228 <rect width="100%" height="100%" rx="${CARD_RADIUS}" class="bg" />
229 <text x="${HEADER_TEXT_X}" y="${HEADER_TITLE_Y}" font-size="15" font-weight="600" class="title">${escapeXml(headerText)}</text>
230
231 ${months}
232 ${weekdays}
233 ${rects}
234 ${summary}
235 ${renderLogo(width, height, logoLightDataUri, logoDarkDataUri)}
236 </a>
237</svg>`;
238}
239
240function toEntryArray(
241 entries: Array<{ updated?: string }> | { updated?: string } | undefined,
242): Array<{ updated?: string }> {
243 if (!entries) {
244 return [];
245 }
246
247 return Array.isArray(entries) ? entries : [entries];
248}
249
250function aggregateEntries(
251 entries: Array<{ updated?: string }>,
252 rangeStart: Date,
253 rangeEnd: Date,
254): ActivityMap {
255 const counts: ActivityMap = new Map();
256
257 for (const entry of entries) {
258 if (!entry.updated) {
259 continue;
260 }
261
262 const updatedAt = new Date(entry.updated);
263 if (Number.isNaN(updatedAt.getTime())) {
264 continue;
265 }
266
267 const day = startOfUtcDay(updatedAt);
268 if (day < rangeStart || day > rangeEnd) {
269 continue;
270 }
271
272 const key = isoDay(day);
273 counts.set(key, (counts.get(key) ?? 0) + 1);
274 }
275
276 return counts;
277}
278
279function buildDays(
280 counts: ActivityMap,
281 rangeStart: Date,
282 calendarStart: Date,
283 rangeEnd: Date,
284): HeatmapDay[] {
285 const days: HeatmapDay[] = [];
286
287 for (let cursor = new Date(calendarStart); cursor <= rangeEnd; cursor = addUtcDays(cursor, 1)) {
288 const isoDate = isoDay(cursor);
289 const count = counts.get(isoDate) ?? 0;
290 const inRange = cursor >= rangeStart && cursor <= rangeEnd;
291 const weekIndex = Math.floor(diffDays(calendarStart, cursor) / 7);
292 const weekdayIndex = cursor.getUTCDay();
293
294 days.push({
295 date: new Date(cursor),
296 isoDate,
297 count,
298 level: inRange ? activityLevel(count) : 0,
299 weekIndex,
300 weekdayIndex,
301 inRange,
302 });
303 }
304
305 return days;
306}
307
308function buildMonthLabels(days: HeatmapDay[]): Array<{ label: string; weekIndex: number }> {
309 const seen = new Set<string>();
310 const rawLabels: Array<{ monthName: string; year: number; weekIndex: number }> = [];
311
312 for (const day of days) {
313 if (!day.inRange) {
314 continue;
315 }
316
317 const key = `${day.date.getUTCFullYear()}-${day.date.getUTCMonth()}`;
318 if (seen.has(key)) {
319 continue;
320 }
321
322 seen.add(key);
323 rawLabels.push({
324 monthName: MONTH_LABELS[day.date.getUTCMonth()],
325 year: day.date.getUTCFullYear(),
326 weekIndex: day.weekIndex,
327 });
328 }
329
330 // どの月名が重複するか先に調べる
331 const monthNameCount = new Map<string, number>();
332 for (const { monthName } of rawLabels) {
333 monthNameCount.set(monthName, (monthNameCount.get(monthName) ?? 0) + 1);
334 }
335
336 // 重複する月名は最初の出現のみ表示(右端の同名月はスキップ)
337 const seenMonthNames = new Set<string>();
338 return rawLabels.flatMap(({ monthName, year, weekIndex }) => {
339 const isDuplicate = (monthNameCount.get(monthName) ?? 0) > 1;
340 if (isDuplicate) {
341 if (seenMonthNames.has(monthName)) {
342 return [];
343 }
344 seenMonthNames.add(monthName);
345 // 最初の出現は年なしで表示
346 return [{ label: monthName, weekIndex }];
347 }
348 const isJanuary = monthName === 'Jan';
349 const label = isJanuary ? `${monthName} '${String(year).slice(2)}` : monthName;
350 return [{ label, weekIndex }];
351 });
352}
353
354function activityLevel(count: number): 0 | 1 | 2 | 3 | 4 {
355 if (count <= 0) {
356 return 0;
357 }
358 if (count === 1) {
359 return 1;
360 }
361 if (count <= 3) {
362 return 2;
363 }
364 if (count <= 6) {
365 return 3;
366 }
367 return 4;
368}
369
370function renderLogo(width: number, height: number, logoLightDataUri?: string, logoDarkDataUri?: string): string {
371 const x = width - RIGHT_GUTTER - LOGO_WIDTH;
372 const y = height - FOOTER_HEIGHT + 1;
373 const lightSrc = logoLightDataUri ?? LOGO_URL_LIGHT;
374 const darkSrc = logoDarkDataUri ?? LOGO_URL_DARK;
375
376 return (
377 `<image class="logo-light" x="${x}" y="${y}" width="${LOGO_WIDTH}" height="${LOGO_HEIGHT}" href="${lightSrc}" />` +
378 `<image class="logo-dark" x="${x}" y="${y}" width="${LOGO_WIDTH}" height="${LOGO_HEIGHT}" href="${darkSrc}" />`
379 );
380}
381
382function subtractUtcMonths(date: Date, months: number): Date {
383 const targetYear = date.getUTCFullYear();
384 const targetMonth = date.getUTCMonth() - months;
385 const targetDay = Math.min(date.getUTCDate(), daysInUtcMonth(targetYear, targetMonth));
386
387 return new Date(Date.UTC(targetYear, targetMonth, targetDay));
388}
389
390function startOfUtcWeek(date: Date): Date {
391 return addUtcDays(date, -date.getUTCDay());
392}
393
394function startOfUtcDay(date: Date): Date {
395 return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
396}
397
398function addUtcDays(date: Date, days: number): Date {
399 return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days));
400}
401
402function diffDays(from: Date, to: Date): number {
403 const millisecondsPerDay = 24 * 60 * 60 * 1000;
404 return Math.round((to.getTime() - from.getTime()) / millisecondsPerDay);
405}
406
407function isoDay(date: Date): string {
408 return date.toISOString().slice(0, 10);
409}
410
411function daysInUtcMonth(year: number, month: number): number {
412 return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
413}
414
415function escapeXml(value: string): string {
416 return value
417 .replace(/&/g, '&')
418 .replace(/</g, '<')
419 .replace(/>/g, '>')
420 .replace(/"/g, '"')
421 .replace(/'/g, ''');
422}