import { XMLParser } from 'fast-xml-parser'; type ActivityMap = Map; type HeatmapDay = { date: Date; isoDate: string; count: number; level: 0 | 1 | 2 | 3 | 4; weekIndex: number; weekdayIndex: number; inRange: boolean; }; type GraphSvgOptions = { account: string; range: number; days: HeatmapDay[]; error?: string; logoLightDataUri?: string; logoDarkDataUri?: string; }; type ParsedFeed = { feed?: { entry?: Array<{ updated?: string }> | { updated?: string }; }; }; const CELL_SIZE = 11; const CELL_GAP = 3; const HEADER_HEIGHT = 50; const FOOTER_HEIGHT = 28; const LEFT_GUTTER = 36; const RIGHT_GUTTER = 16; const TOP_GUTTER = 18; const MONTH_LABEL_OFFSET = 12; const CARD_RADIUS = 12; const HEADER_TEXT_X = 16; const HEADER_TITLE_Y = 35; const LOGO_WIDTH = 140; const LOGO_HEIGHT = 24; const LOGO_URL_LIGHT = 'https://assets.tangled.network/tangled_logotype_black_on_trans.svg'; const LOGO_URL_DARK = 'https://assets.tangled.network/tangled_logotype_white_on_trans.svg'; const MIN_CANVAS_WIDTH = 700; const HEADER_CHAR_WIDTH = 8.2; const SUMMARY_CHAR_WIDTH = 6.2; const WEEKDAY_LABELS = [ { label: 'Mon', row: 1 }, { label: 'Wed', row: 3 }, { label: 'Fri', row: 5 }, ]; const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; export function normalizeAccount(input: string | undefined): string | null { if (!input) { return null; } const account = input.trim().replace(/^@/, ''); if (!account || !/^[a-z0-9._-]+$/i.test(account)) { return null; } return account; } export function clampRange(input: string | undefined): number { const parsed = Number.parseInt(input ?? '12', 10); if (Number.isNaN(parsed)) { return 12; } return Math.min(12, Math.max(1, parsed)); } export async function fetchActivityByDay(account: string, range: number): Promise { const feedUrl = `https://tangled.org/${encodeURIComponent(account)}/feed.atom`; const response = await fetch(feedUrl, { headers: { Accept: 'application/atom+xml, application/xml, text/xml;q=0.9, */*;q=0.8', 'User-Agent': 'tangled-activity-graph', }, }); if (!response.ok) { throw new Error(`Feed request failed with ${response.status}`); } const xml = await response.text(); const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, parseTagValue: true, trimValues: true, }); const parsed = parser.parse(xml) as ParsedFeed; const entries = toEntryArray(parsed.feed?.entry); const now = startOfUtcDay(new Date()); const rangeStart = startOfUtcDay(subtractUtcMonths(now, range)); const calendarStart = startOfUtcWeek(rangeStart); const counts = aggregateEntries(entries, rangeStart, now); return buildDays(counts, rangeStart, calendarStart, now); } export async function fetchLogoDataUri(url: string): Promise { try { const response = await fetch(url, { headers: { 'User-Agent': 'tangled-activity-graph' }, }); if (!response.ok) { return undefined; } const contentType = response.headers.get('content-type') ?? 'image/svg+xml'; const buffer = await response.arrayBuffer(); const base64 = Buffer.from(buffer).toString('base64'); return `data:${contentType};base64,${base64}`; } catch { return undefined; } } export function buildGraphSvg({ account, range, days, error, logoLightDataUri, logoDarkDataUri }: GraphSvgOptions): string { const weekCount = days.length === 0 ? 1 : Math.max(...days.map((day) => day.weekIndex)) + 1; const graphWidth = weekCount * (CELL_SIZE + CELL_GAP) - CELL_GAP; const graphHeight = 7 * (CELL_SIZE + CELL_GAP) - CELL_GAP; const graphStartY = TOP_GUTTER + HEADER_HEIGHT; const baseGraphWidth = LEFT_GUTTER + graphWidth + RIGHT_GUTTER; const headerText = `@${account} Tangled activity`; const monthLabels = buildMonthLabels(days); const maxCount = days.reduce((max, day) => Math.max(max, day.count), 0); const activeDays = days.reduce((sum, day) => sum + (day.count > 0 ? 1 : 0), 0); const totalCount = days.reduce((sum, day) => sum + day.count, 0); const summaryText = error ? error : `${totalCount} activities on ${activeDays} active days, peak ${maxCount}/day`; const headerRequiredWidth = HEADER_TEXT_X + Math.ceil(headerText.length * HEADER_CHAR_WIDTH) + RIGHT_GUTTER; const summaryRequiredWidth = LEFT_GUTTER + Math.ceil(summaryText.length * SUMMARY_CHAR_WIDTH) + RIGHT_GUTTER; const width = Math.max(MIN_CANVAS_WIDTH, baseGraphWidth, headerRequiredWidth, summaryRequiredWidth); const height = TOP_GUTTER + HEADER_HEIGHT + graphHeight + FOOTER_HEIGHT; const rects = days .map((day) => { const x = LEFT_GUTTER + day.weekIndex * (CELL_SIZE + CELL_GAP); const y = graphStartY + day.weekdayIndex * (CELL_SIZE + CELL_GAP); const cellClass = day.inRange ? `heat-${day.level}` : 'oor'; const tooltip = `${day.isoDate}: ${day.count} activit${day.count === 1 ? 'y' : 'ies'}`; return `${escapeXml(tooltip)}`; }) .join(''); const weekdays = WEEKDAY_LABELS.map(({ label, row }) => { const y = graphStartY + row * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 1; return `${label}`; }).join(''); const months = monthLabels .map(({ label, weekIndex }) => { const x = LEFT_GUTTER + weekIndex * (CELL_SIZE + CELL_GAP); return `${label}`; }) .join(''); const summary = error ? `${escapeXml(error)}` : `${escapeXml(summaryText)}`; const accountUrl = `https://tangled.org/@${encodeURIComponent(account)}`; return ` ${escapeXml(headerText)} Calendar heatmap of Tangled activities for ${escapeXml(account)} ${escapeXml(headerText)} ${months} ${weekdays} ${rects} ${summary} ${renderLogo(width, height, logoLightDataUri, logoDarkDataUri)} `; } function toEntryArray( entries: Array<{ updated?: string }> | { updated?: string } | undefined, ): Array<{ updated?: string }> { if (!entries) { return []; } return Array.isArray(entries) ? entries : [entries]; } function aggregateEntries( entries: Array<{ updated?: string }>, rangeStart: Date, rangeEnd: Date, ): ActivityMap { const counts: ActivityMap = new Map(); for (const entry of entries) { if (!entry.updated) { continue; } const updatedAt = new Date(entry.updated); if (Number.isNaN(updatedAt.getTime())) { continue; } const day = startOfUtcDay(updatedAt); if (day < rangeStart || day > rangeEnd) { continue; } const key = isoDay(day); counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; } function buildDays( counts: ActivityMap, rangeStart: Date, calendarStart: Date, rangeEnd: Date, ): HeatmapDay[] { const days: HeatmapDay[] = []; for (let cursor = new Date(calendarStart); cursor <= rangeEnd; cursor = addUtcDays(cursor, 1)) { const isoDate = isoDay(cursor); const count = counts.get(isoDate) ?? 0; const inRange = cursor >= rangeStart && cursor <= rangeEnd; const weekIndex = Math.floor(diffDays(calendarStart, cursor) / 7); const weekdayIndex = cursor.getUTCDay(); days.push({ date: new Date(cursor), isoDate, count, level: inRange ? activityLevel(count) : 0, weekIndex, weekdayIndex, inRange, }); } return days; } function buildMonthLabels(days: HeatmapDay[]): Array<{ label: string; weekIndex: number }> { const seen = new Set(); const rawLabels: Array<{ monthName: string; year: number; weekIndex: number }> = []; for (const day of days) { if (!day.inRange) { continue; } const key = `${day.date.getUTCFullYear()}-${day.date.getUTCMonth()}`; if (seen.has(key)) { continue; } seen.add(key); rawLabels.push({ monthName: MONTH_LABELS[day.date.getUTCMonth()], year: day.date.getUTCFullYear(), weekIndex: day.weekIndex, }); } // どの月名が重複するか先に調べる const monthNameCount = new Map(); for (const { monthName } of rawLabels) { monthNameCount.set(monthName, (monthNameCount.get(monthName) ?? 0) + 1); } // 重複する月名は最初の出現のみ表示(右端の同名月はスキップ) const seenMonthNames = new Set(); return rawLabels.flatMap(({ monthName, year, weekIndex }) => { const isDuplicate = (monthNameCount.get(monthName) ?? 0) > 1; if (isDuplicate) { if (seenMonthNames.has(monthName)) { return []; } seenMonthNames.add(monthName); // 最初の出現は年なしで表示 return [{ label: monthName, weekIndex }]; } const isJanuary = monthName === 'Jan'; const label = isJanuary ? `${monthName} '${String(year).slice(2)}` : monthName; return [{ label, weekIndex }]; }); } function activityLevel(count: number): 0 | 1 | 2 | 3 | 4 { if (count <= 0) { return 0; } if (count === 1) { return 1; } if (count <= 3) { return 2; } if (count <= 6) { return 3; } return 4; } function renderLogo(width: number, height: number, logoLightDataUri?: string, logoDarkDataUri?: string): string { const x = width - RIGHT_GUTTER - LOGO_WIDTH; const y = height - FOOTER_HEIGHT + 1; const lightSrc = logoLightDataUri ?? LOGO_URL_LIGHT; const darkSrc = logoDarkDataUri ?? LOGO_URL_DARK; return ( `` + `` ); } function subtractUtcMonths(date: Date, months: number): Date { const targetYear = date.getUTCFullYear(); const targetMonth = date.getUTCMonth() - months; const targetDay = Math.min(date.getUTCDate(), daysInUtcMonth(targetYear, targetMonth)); return new Date(Date.UTC(targetYear, targetMonth, targetDay)); } function startOfUtcWeek(date: Date): Date { return addUtcDays(date, -date.getUTCDay()); } function startOfUtcDay(date: Date): Date { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); } function addUtcDays(date: Date, days: number): Date { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)); } function diffDays(from: Date, to: Date): number { const millisecondsPerDay = 24 * 60 * 60 * 1000; return Math.round((to.getTime() - from.getTime()) / millisecondsPerDay); } function isoDay(date: Date): string { return date.toISOString().slice(0, 10); } function daysInUtcMonth(year: number, month: number): number { return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); } function escapeXml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }