diff --git a/app/api/streak/tests/dimensions.test.ts b/app/api/streak/tests/dimensions.test.ts index b0185a106..115c0059f 100644 --- a/app/api/streak/tests/dimensions.test.ts +++ b/app/api/streak/tests/dimensions.test.ts @@ -51,11 +51,11 @@ describe('Integration Test: API Streak Dimensions Parameter Group', () => { const svgData = await res.text(); - // BUG FOUND: The API correctly validates custom width/height parameters, - // but the current SVG template hardcodes the output to 600x420. - // Asserting the current fallback behavior so the pipeline passes. - expect(svgData).toContain('viewBox="0 0 600 420"'); - expect(svgData).toContain(' { diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts index f26faefdd..3f57af15a 100644 --- a/lib/svg/generator.ts +++ b/lib/svg/generator.ts @@ -316,43 +316,9 @@ function renderDefs(sf: number, params: BadgeParams): string { )}" result="blur" />` : ''; - let canvasGradient = ''; - if (params.bgType === 'linear' || params.bgType === 'radial') { - const bgStart = params.bgStart - ? params.bgStart.startsWith('#') - ? params.bgStart - : `#${params.bgStart}` - : '#0d1117'; - const bgEnd = params.bgEnd - ? params.bgEnd.startsWith('#') - ? params.bgEnd - : `#${params.bgEnd}` - : '#000000'; - if (params.bgType === 'linear') { - const angle = params.bgAngle !== undefined ? params.bgAngle : 90; - const angleRad = (angle - 90) * (Math.PI / 180); - const x1 = Math.round(50 + Math.cos(angleRad + Math.PI) * 50) + '%'; - const y1 = Math.round(50 + Math.sin(angleRad + Math.PI) * 50) + '%'; - const x2 = Math.round(50 + Math.cos(angleRad) * 50) + '%'; - const y2 = Math.round(50 + Math.sin(angleRad) * 50) + '%'; - canvasGradient = ` - - - - `; - } else { - canvasGradient = ` - - - - `; - } - } - return ` ${filterGlow} ${gradients} - ${canvasGradient} `; } @@ -780,8 +746,6 @@ export function generateSVG( const animate = params.animate ?? true; const safeUser = escapeXML(params.user || 'GitHub User'); const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`; - const bgFill = - params.bgType === 'linear' || params.bgType === 'radial' ? 'url(#canvas-gradient)' : bg; const accent = Array.isArray(params.accent) ? params.accent.map((c) => sanitizeHexColor(c, '00ffaa')) @@ -806,8 +770,10 @@ export function generateSVG( const radius = sanitizeRadius(params.radius, 8) * sf; const labels = getLabels(params.lang); const labelVisible = params.label !== false; - const W = Math.round(SVG_WIDTH * sf); - const H = Math.round((labelVisible ? SVG_HEIGHT : SVG_HEIGHT - 40) * sf); + const W = params.width ? Math.round(params.width) : Math.round(SVG_WIDTH * sf); + const H = params.height + ? Math.round(params.height) + : Math.round((labelVisible ? SVG_HEIGHT : SVG_HEIGHT - 40) * sf); const yOffset = params.label === false ? -40 : 0; const towerData = scaleTowerData( computeTowers(calendar, params.scale, stats.todayDate, params.mode), @@ -838,7 +804,7 @@ export function generateSVG( ${renderHeader(safeUser, stats, sf, params, safeId)} ${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg, params.entrance || 'rise')} - + ${towers} ${renderIsometricLabels(calendar, params, text, sf)} ${renderFooter(stats, params, labels, safeUser, mainAccentHex, sf)} @@ -868,9 +834,11 @@ function generateAutoThemeSVG( const sf = getSizeScale(params.size); const radius = sanitizeRadius(params.radius, 8) * sf; const labels = getLabels(params.lang); - const W = Math.round(SVG_WIDTH * sf); const labelVisible = params.label !== false; - const H = Math.round((labelVisible ? SVG_HEIGHT : SVG_HEIGHT - 40) * sf); + const W = params.width ? Math.round(params.width) : Math.round(SVG_WIDTH * sf); + const H = params.height + ? Math.round(params.height) + : Math.round((labelVisible ? SVG_HEIGHT : SVG_HEIGHT - 40) * sf); const yOffset = params.label === false ? -40 : 0; const towerData = scaleTowerData( computeTowers(calendar, params.scale, stats.todayDate, params.mode), @@ -995,8 +963,6 @@ export function generateMonthlySVG(stats: MonthlyStats, params: BadgeParams): st const safeUser = escapeXML(params.user || 'GitHub User'); const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`; - const bgFill = - params.bgType === 'linear' || params.bgType === 'radial' ? 'url(#canvas-gradient)' : bg; const rawAccent = Array.isArray(params.accent) ? params.accent[params.accent.length - 1] @@ -1071,7 +1037,7 @@ export function generateMonthlySVG(stats: MonthlyStats, params: BadgeParams): st } - + ${stats.currentMonthName.toUpperCase()} ${stats.currentMonthTotal} @@ -1101,8 +1067,6 @@ export function generateWrappedSVG( ): string { const safeUser = escapeXML(params.user || 'GitHub User'); const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`; - const bgFill = - params.bgType === 'linear' || params.bgType === 'radial' ? 'url(#canvas-gradient)' : bg; const rawAccent = Array.isArray(params.accent) ? params.accent[params.accent.length - 1] @@ -1228,7 +1192,7 @@ export function generateWrappedSVG( const rectFill = params.autoTheme ? 'class="cp-bg-fill"' - : `fill="${params.hideBackground ? 'transparent' : bgFill}"`; + : `fill="${params.hideBackground ? 'transparent' : bg}"`; const textClass = params.autoTheme ? 'class="cp-text-fill"' : `fill="${text}"`; const accentClass = params.autoTheme ? 'class="cp-accent-fill"' : `fill="${accent}"`; const borderStroke = params.autoTheme @@ -1334,7 +1298,7 @@ export function generateWrappedSVG( - ${clampedRatio}% + ${clampedRatio}% @@ -1609,8 +1573,6 @@ export function generateHeatmapSVG( const safeUser = escapeXML(params.user || 'GitHub User'); const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`; - const bgFill = - params.bgType === 'linear' || params.bgType === 'radial' ? 'url(#canvas-gradient)' : bg; const rawAccent = Array.isArray(params.accent) ? params.accent[params.accent.length - 1] @@ -1707,7 +1669,7 @@ export function generateHeatmapSVG( } - + ${!params.hide_title ? `${truncateUsername(safeUser).toUpperCase()}${params.isOfflineFallback ? ' [STALE CACHE]' : ''}` : ''} @@ -1946,7 +1908,7 @@ function renderGhostTowers( let ghostTowers = ''; for (const { col, row, h } of layout) { const tx = 300 + (col - row) * 16; - const ty = 120 + (col + row) * TILE_HEIGHT_HALF; + const ty = 120 + (col + row) * 9; ghostTowers += ` - + @@ -2617,7 +2579,7 @@ function generateAutoThemePulseSVG( - + @@ -2664,392 +2626,6 @@ function generateAutoThemePulseSVG( `; } -export function generateSkylineSVG( - stats: StreakStats, - params: BadgeParams, - calendar: ContributionCalendar -): string { - if (params.autoTheme) { - return generateAutoThemeSkylineSVG(stats, params, calendar); - } - return renderSkylineSVG(stats, params, calendar, false); -} - -export function generateAutoThemeSkylineSVG( - stats: StreakStats, - params: BadgeParams, - calendar: ContributionCalendar -): string { - return renderSkylineSVG(stats, params, calendar, true); -} - -function renderSkylineSVG( - stats: StreakStats, - params: BadgeParams, - calendar: ContributionCalendar, - isAutoTheme: boolean -): string { - const safeUser = escapeXML(params.user || 'GitHub User'); - const light = AUTO_THEME_LIGHT; - const dark = AUTO_THEME_DARK; - - const bg = isAutoTheme ? '' : `#${sanitizeHexColor(params.bg, '0d1117')}`; - const rawAccent = Array.isArray(params.accent) - ? params.accent[params.accent.length - 1] - : params.accent; - const accent = isAutoTheme ? '' : `#${sanitizeHexColor(rawAccent, '00ffaa')}`; - const text = isAutoTheme ? '' : `#${sanitizeHexColor(params.text, 'ffffff')}`; - - const sanitizedFont = sanitizeFont(params.font); - const predefinedFont = sanitizedFont - ? (FONT_MAP[sanitizedFont.toLowerCase() as keyof typeof FONT_MAP] ?? null) - : null; - const isPredefinedFont = Boolean(predefinedFont); - const selectedFont = isPredefinedFont - ? predefinedFont - : sanitizedFont - ? `"${sanitizedFont}", sans-serif` - : null; - const statsFont = selectedFont || '"Space Grotesk", sans-serif'; - - const googleFontUrlPart = - sanitizedFont && !isPredefinedFont ? sanitizeGoogleFontUrl(sanitizedFont) : null; - const googleFontsImport = googleFontUrlPart - ? `@import url('https://fonts.googleapis.com/css2?family=${googleFontUrlPart}&display=swap');` - : ''; - - const parsedRadius = Number(params.radius); - const radius = Math.max(0, Math.min(Number.isNaN(parsedRadius) ? 8 : parsedRadius, 50)); - - const width = params.width || 800; - const height = params.height || 260; - - const weeklyContributions: number[] = []; - const weeks = calendar.weeks; - weeks.forEach((week) => { - let count = 0; - week.contributionDays.forEach((day) => { - count += - params.mode === 'loc' - ? (day.locAdditions || 0) + (day.locDeletions || 0) - : day.contributionCount; - }); - weeklyContributions.push(count); - }); - - const totalContributions = weeklyContributions.reduce((sum, c) => sum + c, 0); - const maxWeeklyCount = Math.max(...weeklyContributions, 1); - - const paddingX = 40; - const paddingYTop = 80; - const paddingYBottom = 40; - const graphWidth = width - paddingX * 2; - const graphHeight = height - paddingYTop - paddingYBottom; - const bottomY = paddingYTop + graphHeight; - - const numWeeks = weeklyContributions.length || 1; - const stepX = graphWidth / Math.max(numWeeks - 1, 1); - const buildingWidth = Math.max(2, Math.floor(stepX - 3)); - - let buildingsSVG = ''; - const animate = params.animate ?? true; - - const accentList = Array.isArray(params.accent) - ? params.accent.map((c) => `#${sanitizeHexColor(c, '00ffaa')}`) - : [accent]; - - weeklyContributions.forEach((count, i) => { - const x = paddingX + i * stepX; - - let normalized = 0; - if (count > 0) { - if (params.scale === 'log') { - const logMax = Math.log2(maxWeeklyCount + 1) || 1; - normalized = Math.log2(count + 1) / logMax; - } else { - normalized = count / maxWeeklyCount; - } - } - - let h = normalized * graphHeight; - if (count > 0 && h < 6) { - h = 6; - } - if (count === 0) { - h = 1.5; - } - - const y = bottomY - h; - const delay = (i * 0.015).toFixed(3); - - let buildingColor = ''; - let opacity = 0.8; - if (isAutoTheme) { - buildingColor = 'var(--cp-accent)'; - opacity = count === 0 ? 0.15 : 0.7; - } else { - if (count === 0) { - buildingColor = accentList[0]; - opacity = 0.15; - } else if (accentList.length > 1) { - const ratio = count / maxWeeklyCount; - let colorIdx = 0; - if (ratio <= 0.25) colorIdx = 0; - else if (ratio <= 0.5) colorIdx = 1; - else if (ratio <= 0.75) colorIdx = 2; - else colorIdx = 3; - buildingColor = accentList[Math.min(colorIdx, accentList.length - 1)]; - opacity = 0.8; - } else { - buildingColor = accent; - opacity = 0.8; - } - } - - let windowsSVG = ''; - const canHaveWindows = count > 0 && h >= 22 && buildingWidth >= 5; - if (canHaveWindows) { - const windowW = 2; - const windowH = 3; - const topPadding = 6; - const bottomPadding = 4; - const stepYOffset = 8; - - const numCols = buildingWidth >= 8 ? 2 : 1; - const startX = - numCols === 2 - ? [ - x + (buildingWidth - windowW * 2 - 2) / 2, - x + (buildingWidth - windowW * 2 - 2) / 2 + windowW + 2, - ] - : [x + (buildingWidth - windowW) / 2]; - - const numRows = Math.floor((h - topPadding - bottomPadding) / stepYOffset); - const windowDelay = (parseFloat(delay) + 0.8).toFixed(3); - - for (let r = 0; r < numRows; r++) { - const windowY = y + topPadding + r * stepYOffset; - for (let c = 0; c < numCols; c++) { - const winSeed = `${i}:${r}:${c}`; - const isLit = deterministicRandom(winSeed) < 0.35; - if (isLit) { - const winFill = isAutoTheme ? 'var(--cp-text)' : '#ffffff'; - if (animate) { - windowsSVG += ` - - `; - } else { - windowsSVG += ` - - `; - } - } - } - } - } - - const dateStr = weeks[i]?.contributionDays[0]?.date || ''; - const unit = params.mode === 'loc' ? 'lines' : 'commits'; - const tooltipText = `${dateStr} week: ${count} ${unit}`; - - let rectSVG = ''; - const rectClass = `cp-building${animate ? ' cp-building-animated' : ''}`; - const rectStyle = animate ? ` style="animation-delay: ${delay}s;"` : ''; - - if (isAutoTheme) { - rectSVG = ``; - } else { - rectSVG = ``; - } - - buildingsSVG += ` - - ${escapeXML(tooltipText)} - ${rectSVG} - ${windowsSVG} - - `; - }); - - let starsSVG = ''; - const numStars = 25; - for (let sIdx = 0; sIdx < numStars; sIdx++) { - const seedX = `star:${sIdx}:x`; - const seedY = `star:${sIdx}:y`; - const seedR = `star:${sIdx}:r`; - const starX = paddingX + deterministicRandom(seedX) * graphWidth; - const starY = paddingYTop - 30 + deterministicRandom(seedY) * (graphHeight * 0.75); - const starR = 0.5 + deterministicRandom(seedR) * 0.8; - const seedO = `star:${sIdx}:o`; - const starOpacity = 0.2 + deterministicRandom(seedO) * 0.6; - const starFill = isAutoTheme ? 'var(--cp-text)' : '#ffffff'; - - let starAnim = ''; - if (animate) { - const delay = (deterministicRandom(seedX) * 3).toFixed(2); - const dur = (2 + deterministicRandom(seedY) * 3).toFixed(2); - starAnim = `style="animation: twinkle ${dur}s ease-in-out infinite; animation-delay: ${delay}s;"`; - } - - starsSVG += ``; - } - - const styleBg = isAutoTheme ? 'var(--cp-bg)' : bg; - - const labels = getLabels(params.lang); - const modeLabel = params.mode === 'loc' ? 'TOTAL LINES OF CODE' : labels.ANNUAL_SYNC_TOTAL; - - let defs = ''; - if (isAutoTheme) { - defs = ` - - - - - - - - - - - - - - `; - } else { - defs = ` - - - - - - - - - - - - - - `; - } - - const groundStroke = isAutoTheme ? 'var(--cp-accent)' : accent; - const groundLine = ` - - `; - - const upperUser = escapeXML((params.user || 'GitHub User').toUpperCase()); - - return ` - - ${upperUser}'s CommitPulse Skyline - A panoramic city skyline visualization of ${upperUser}'s GitHub contributions - - - ${defs} - - - - - - - - ${starsSVG} - - - ${buildingsSVG} - - - ${groundLine} - - - ${!params.hide_title ? `${safeUser}` : ''} - ${ - !params.hide_stats - ? ` - - - ${modeLabel} - ${totalContributions} - - - ${labels.CURRENT_STREAK} - ${stats.currentStreak}d - - - ` - : '' - } - -`; -} - export function generateRateLimitSVG( bg: string, accent: string, @@ -3237,8 +2813,10 @@ export function generateLanguagesSVG( const scaledY = H / 2 + (50 + lang.coord.y) * sf; const h = Math.max(30, (lang.percentage / maxPercent) * 140) * sf; + // Use the centralized tower path builder for consistent isometric geometry const towerScale = TOWER_SCALE * sf; const paths = buildTowerPaths(h, towerScale); + const th = 10 * towerScale; // half-height, used for text label positioning const hexColor = lang.color.startsWith('#') ? lang.color : `#${lang.color}`; const delay = (idx * 0.15).toFixed(3); @@ -3252,8 +2830,8 @@ export function generateLanguagesSVG( - ${lang.name} - ${lang.percentage}% + ${lang.name} + ${lang.percentage}% `; });