|
| 1 | +import type { BadgeParams, StreakStats } from '../../types'; |
| 2 | +import { escapeXML, sanitizeHexColor } from './sanitizer'; |
| 3 | +import { truncateUsername, getSizeScale } from './generator'; |
| 4 | + |
| 5 | +const WIDTH = 800; |
| 6 | +const HEIGHT = 400; |
| 7 | +const TILE_W_HALF = 12; |
| 8 | +const TILE_H_HALF = 6.5; |
| 9 | + |
| 10 | +const ORIGIN_X = WIDTH / 2 - ((24 - 7) * TILE_W_HALF) / 2 - 20; |
| 11 | +const ORIGIN_Y = 160; |
| 12 | + |
| 13 | +export function generatePunchcardSVG( |
| 14 | + punchCardData: number[][], |
| 15 | + stats: StreakStats, |
| 16 | + params: BadgeParams |
| 17 | +): string { |
| 18 | + const sf = getSizeScale(params.size); |
| 19 | + const safeUser = escapeXML(truncateUsername(params.user)); |
| 20 | + const bg = sanitizeHexColor(params.bg, '0d1117'); |
| 21 | + const text = sanitizeHexColor(Array.isArray(params.accent) ? undefined : params.text, 'c9d1d9'); |
| 22 | + const accent = sanitizeHexColor( |
| 23 | + Array.isArray(params.accent) ? params.accent[params.accent.length - 1] : params.accent, |
| 24 | + '58a6ff' |
| 25 | + ); |
| 26 | + |
| 27 | + let maxCount = 1; |
| 28 | + for (const day of punchCardData) { |
| 29 | + for (const count of day) { |
| 30 | + if (count > maxCount) maxCount = count; |
| 31 | + } |
| 32 | + } |
| 33 | + |
| 34 | + let towers = ''; |
| 35 | + // Iterate in back-to-front painter's algorithm order for isometric |
| 36 | + for (let day = 0; day < 7; day++) { |
| 37 | + for (let hour = 0; hour < 24; hour++) { |
| 38 | + const count = punchCardData[day][hour]; |
| 39 | + if (count === 0) continue; |
| 40 | + |
| 41 | + const ratio = count / maxCount; |
| 42 | + const h = 4 + ratio * 46; |
| 43 | + |
| 44 | + const px = ORIGIN_X + (hour - day) * TILE_W_HALF; |
| 45 | + const py = ORIGIN_Y + (hour + day) * TILE_H_HALF; |
| 46 | + |
| 47 | + const opacity = (0.3 + 0.7 * ratio).toFixed(2); |
| 48 | + const color = `#${accent}`; |
| 49 | + |
| 50 | + towers += ` |
| 51 | + <g transform="translate(${px.toFixed(1)}, ${py.toFixed(1)})" opacity="${opacity}"> |
| 52 | + <polygon points="0,${-h} ${TILE_W_HALF},${-h + TILE_H_HALF} 0,${-h + 2 * TILE_H_HALF} ${-TILE_W_HALF},${-h + TILE_H_HALF}" fill="${color}" opacity="0.9"/> |
| 53 | + <polygon points="${-TILE_W_HALF},${-h + TILE_H_HALF} 0,${-h + 2 * TILE_H_HALF} 0,${2 * TILE_H_HALF} ${-TILE_W_HALF},${TILE_H_HALF}" fill="${color}" opacity="0.7"/> |
| 54 | + <polygon points="0,${-h + 2 * TILE_H_HALF} ${TILE_W_HALF},${-h + TILE_H_HALF} ${TILE_W_HALF},${TILE_H_HALF} 0,${2 * TILE_H_HALF}" fill="${color}" opacity="0.5"/> |
| 55 | + </g>`; |
| 56 | + } |
| 57 | + } |
| 58 | + |
| 59 | + const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; |
| 60 | + let dayLabels = ''; |
| 61 | + for (let day = 0; day < 7; day++) { |
| 62 | + const px = ORIGIN_X + (-2 - day) * TILE_W_HALF; |
| 63 | + const py = ORIGIN_Y + (-2 + day) * TILE_H_HALF; |
| 64 | + dayLabels += `<text x="${px.toFixed(1)}" y="${py.toFixed(1)}" fill="#${text}" font-family="'Inter',sans-serif" font-size="11" opacity="0.6" text-anchor="end" font-weight="600">${dayNames[day]}</text>\n`; |
| 65 | + } |
| 66 | + |
| 67 | + let hourLabels = ''; |
| 68 | + const hoursToLabel = [0, 6, 12, 18]; |
| 69 | + const hourNames = ['12a', '6a', '12p', '6p']; |
| 70 | + for (let i = 0; i < hoursToLabel.length; i++) { |
| 71 | + const hour = hoursToLabel[i]; |
| 72 | + const px = ORIGIN_X + (hour - -1) * TILE_W_HALF; |
| 73 | + const py = ORIGIN_Y + (hour + -1) * TILE_H_HALF - 12; |
| 74 | + hourLabels += `<text x="${px.toFixed(1)}" y="${py.toFixed(1)}" fill="#${text}" font-family="'Inter',sans-serif" font-size="11" opacity="0.6" text-anchor="middle" font-weight="600">${hourNames[i]}</text>\n`; |
| 75 | + } |
| 76 | + |
| 77 | + const rx = params.radius ?? 8; |
| 78 | + const titleText = params.hide_title |
| 79 | + ? '' |
| 80 | + : `<text x="40" y="50" fill="#${text}" font-family="'Inter',sans-serif" font-size="18" font-weight="700">${escapeXML(params.custom_title || 'Circadian Rhythm : ' + safeUser)}</text>`; |
| 81 | + const totalCommits = stats.totalContributions; |
| 82 | + const statsPanel = params.hide_stats |
| 83 | + ? '' |
| 84 | + : ` |
| 85 | + <text x="40" y="74" fill="#${text}" font-family="'Inter',sans-serif" font-size="13" opacity="0.7">Total Commits: <tspan font-weight="700" fill="#${accent}">${totalCommits}</tspan></text> |
| 86 | + `; |
| 87 | + |
| 88 | + return `<svg style="max-width: 100%; height: auto;" xmlns="http://www.w3.org/2000/svg" width="${Math.round(WIDTH * sf)}" height="${Math.round(HEIGHT * sf)}" viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img" aria-labelledby="cp-punch-title" aria-describedby="cp-punch-desc"> |
| 89 | + <title id="cp-punch-title">CommitPulse Punch Card Heatmap for ${safeUser}</title> |
| 90 | + <desc id="cp-punch-desc">A 3D isometric grid showing ${safeUser}'s commit frequency by day of week and hour of day.</desc> |
| 91 | + <defs> |
| 92 | + <style> |
| 93 | + @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"); |
| 94 | + </style> |
| 95 | + </defs> |
| 96 | + ${params.hideBackground ? '' : `<rect width="${WIDTH}" height="${HEIGHT}" fill="#${bg}" rx="${rx}"/>`} |
| 97 | + ${titleText} |
| 98 | + ${statsPanel} |
| 99 | + <g transform="translate(0, 40)"> |
| 100 | + ${dayLabels} |
| 101 | + ${hourLabels} |
| 102 | + ${towers} |
| 103 | + </g> |
| 104 | +</svg>`; |
| 105 | +} |
0 commit comments