this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 422 lines 13 kB view raw
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, '&amp;') 418 .replace(/</g, '&lt;') 419 .replace(/>/g, '&gt;') 420 .replace(/"/g, '&quot;') 421 .replace(/'/g, '&apos;'); 422}