From 941d3c4f347a06b799a18dd3a145e7d25f8983c4 Mon Sep 17 00:00:00 2001 From: Animesh Date: Sun, 17 May 2026 17:57:25 +0530 Subject: [PATCH] feat: implement Ghost City mode (#97) --- README.md | 6 +- lib/svg/generator.test.ts | 47 +++++ lib/svg/generator.ts | 393 ++++++++++++++------------------------ 3 files changed, 195 insertions(+), 251 deletions(-) diff --git a/README.md b/README.md index 78640e7..b9dc263 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,11 @@ Most GitHub stat badges are **flat**. Flat bars, flat text, flat colors. They bl **CommitPulse is different.** -We render your contribution data as a **3D Isometric City** — a grid of glowing towers where each column's height is directly proportional to your commit count that day. The more you grind, the taller your skyline grows. This is not decoration. This is a **live, animated data visualization** that makes your dedication impossible to ignore. +We render your contribution data as a **3D Isometric City** — a grid of glowing towers where each column's height is directly proportional to your commit count that day. The more you grind, the taller your skyline grows. + +**Ghost City Architecture:** In this mode, zero-contribution days aren't just empty space. They are rendered as thin, wireframe-style **blueprint foundations** (4px high). This gives your commit landscape a structured, architectural "work-in-progress" look even during rest days, maintaining the premium 3D aesthetic across the entire calendar. + +This is not decoration. This is a **live, animated data visualization** that makes your dedication impossible to ignore. ### Why Isometric > Flat diff --git a/lib/svg/generator.test.ts b/lib/svg/generator.test.ts index 1d73f7a..36b4ee9 100644 --- a/lib/svg/generator.test.ts +++ b/lib/svg/generator.test.ts @@ -202,4 +202,51 @@ describe('generateSVG', () => { expect(svg).toContain('prefers-reduced-motion'); }); }); + + // Ghost City Placeholder Mode tests + describe('Ghost City Mode', () => { + const emptyCalendar: ContributionCalendar = { + totalContributions: 0, + weeks: [ + { + contributionDays: [ + { contributionCount: 0, date: '2024-06-10' }, + { contributionCount: 0, date: '2024-06-11' }, + ], + }, + ], + }; + + const activeCalendar: ContributionCalendar = { + totalContributions: 5, + weeks: [ + { + contributionDays: [ + { contributionCount: 0, date: '2024-06-10' }, + { contributionCount: 5, date: '2024-06-11' }, + ], + }, + ], + }; + + it('renders Ghost City blueprint when user has 0 total contributions', () => { + const svg = generateSVG(mockStats, { user: 'avi' } as unknown as BadgeParams, emptyCalendar); + + // Should contain wireframe strokes + expect(svg).toContain('stroke-width="0.5"'); + expect(svg).toContain('stroke-opacity="0.3"'); + // Should use the GHOST_HEIGHT_PX which is 4 (10 + 4 = 14) + expect(svg).toContain('L0 14 L-16 4 L-16 0 Z'); + }); + + it('does not render Ghost City when user has active contributions', () => { + const svg = generateSVG(mockStats, { user: 'avi' } as unknown as BadgeParams, activeCalendar); + + // Should NOT contain wireframe strokes + expect(svg).not.toContain('stroke-width="0.5"'); + expect(svg).not.toContain('stroke-opacity="0.3"'); + // Active mode empty days should have h=0 (10 + 0 = 10) + expect(svg).toContain('L0 10 L-16 0 L-16 0 Z'); + }); + }); }); diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts index 653e6f8..dd22970 100644 --- a/lib/svg/generator.ts +++ b/lib/svg/generator.ts @@ -1,12 +1,43 @@ import type { BadgeParams, ContributionCalendar, StreakStats } from '../../types'; import { AUTO_DARK_THEME, AUTO_LIGHT_THEME } from './themes'; +// constants +const GHOST_HEIGHT_PX = 4; +const LOG_SCALE_MULTIPLIER = 12; +const LINEAR_SCALE_MULTIPLIER = 5; +const MAX_LOG_HEIGHT = 80; +const MAX_LINEAR_HEIGHT = 50; + const FONT_MAP: Record = { jetbrains: '"JetBrains Mono", monospace', fira: '"Fira Code", monospace', roboto: '"Roboto", sans-serif', }; +// types +/** Shared layout data for a single isometric tower. */ +interface FaceOpacity { + left: number; + right: number; + top: number; +} + +interface TowerData { + x: number; + y: number; + h: number; + hasCommits: boolean; + isGhost: boolean; + isToday: boolean; + isTodayWithCommits: boolean; + tooltip: string; + contributionCount: number; + faceOpacity: FaceOpacity; + strokeOpacity: number; + strokeWidth: number; +} + +// helpers function deterministicRandom(seed: string): number { let hash = 2166136261; for (let i = 0; i < seed.length; i++) { @@ -15,6 +46,80 @@ function deterministicRandom(seed: string): number { } return (hash >>> 0) / 4294967296; } +function computeTowerHeight( + count: number, + scale: 'linear' | 'log', + shouldShowGhostCity: boolean +): number { + if (count === 0 && shouldShowGhostCity) return GHOST_HEIGHT_PX; + if (count === 0) return 0; + return scale === 'log' + ? Math.min(Math.log2(count + 1) * LOG_SCALE_MULTIPLIER, MAX_LOG_HEIGHT) + : Math.min(count * LINEAR_SCALE_MULTIPLIER, MAX_LINEAR_HEIGHT); +} + +function computeFaceOpacity(count: number, isGhostCityMode: boolean): FaceOpacity { + if (isGhostCityMode) { + return { left: 0, right: 0, top: 0.02 }; + } + if (count === 0) { + return { left: 0, right: 0, top: 0.02 }; + } + return { left: 0.35, right: 0.21, top: 0.7 }; +} + +/** + * Computes tower positions and heights from the last 14 weeks of + * contribution data. The layout math is identical for both the + * static-theme and auto-theme rendering paths. + */ +function computeTowers(calendar: ContributionCalendar, scale: 'linear' | 'log'): TowerData[] { + const weeks = calendar.weeks.slice(-14); + const towers: TowerData[] = []; + + // Calculate if the entire monolith is empty + let totalVisibleContributions = 0; + weeks.forEach((week) => { + week.contributionDays.forEach((day) => { + totalVisibleContributions += day.contributionCount; + }); + }); + + const shouldShowGhostCity = totalVisibleContributions === 0; + + weeks.forEach((week, i) => { + week.contributionDays.forEach((day, j) => { + const isToday = i === weeks.length - 1 && j === week.contributionDays.length - 1; + const hasCommits = day.contributionCount > 0; + const isGhost = !hasCommits && shouldShowGhostCity; + const isTodayWithCommits = isToday && hasCommits; + + const tooltip = isTodayWithCommits + ? `TODAY: ${day.date}: ${day.contributionCount} contributions` + : `${day.date}: ${day.contributionCount} contributions`; + + // If not ghost city and no commits, height is 0, so don't render face if not needed, + // but we return 0 for height so it won't be visible. + towers.push({ + x: 300 + (i - j) * 16, + y: 120 + (i + j) * 9, + h: computeTowerHeight(day.contributionCount, scale, shouldShowGhostCity), + hasCommits, + isGhost, + isToday, + isTodayWithCommits, + tooltip, + contributionCount: day.contributionCount, + faceOpacity: computeFaceOpacity(day.contributionCount, shouldShowGhostCity), + strokeOpacity: isGhost ? 0.3 : 0, + strokeWidth: isGhost ? 0.5 : 0, + }); + }); + }); + + return towers; +} + function escapeXML(str: string): string { return str .replace(/&/g, '&') @@ -40,26 +145,14 @@ function generateParticles( particles += ` - - + + `; } - return `${particles}`; } -// Auto-theme variant: particles reference a CSS class instead of an -// inline fill so the color can switch via prefers-color-scheme. function generateAutoParticles(x: number, y: number, height: number, count: number): string { let particles = ''; const particleCount = Math.min(5, Math.max(3, Math.floor(count / 4))); @@ -71,80 +164,15 @@ function generateAutoParticles(x: number, y: number, height: number, count: numb particles += ` - - + + `; } - return `${particles}`; } -/** Shared layout data for a single isometric tower. */ -interface TowerData { - x: number; - y: number; - h: number; - hasCommits: boolean; - isToday: boolean; - isTodayWithCommits: boolean; - tooltip: string; - contributionCount: number; - opacity: number; -} - -/** Computes tower positions and heights from the last 14 weeks of - * contribution data. The layout math is identical for both the - * static-theme and auto-theme rendering paths. */ -function computeTowers(calendar: ContributionCalendar, scale: 'linear' | 'log'): TowerData[] { - const weeks = calendar.weeks.slice(-14); - const towers: TowerData[] = []; - - weeks.forEach((week, i) => { - week.contributionDays.forEach((day, j) => { - const isToday = i === weeks.length - 1 && j === week.contributionDays.length - 1; - const hasCommits = day.contributionCount > 0; - const isTodayWithCommits = isToday && hasCommits; - - const tooltip = isTodayWithCommits - ? `TODAY: ${day.date}: ${day.contributionCount} contributions` - : `${day.date}: ${day.contributionCount} contributions`; - - const h = - scale === 'log' - ? Math.min(day.contributionCount > 0 ? Math.log2(day.contributionCount + 1) * 12 : 0, 80) - : Math.min(day.contributionCount * 5, 50); - - const x = 300 + (i - j) * 16; - const y = 120 + (i + j) * 9; - const opacity = hasCommits ? 0.7 : 0.05; - - towers.push({ - x, - y, - h, - hasCommits, - isToday, - isTodayWithCommits, - tooltip, - contributionCount: day.contributionCount, - opacity, - }); - }); - }); - - return towers; -} - +// main renderers export function generateSVG( stats: StreakStats, params: BadgeParams, @@ -163,22 +191,15 @@ export function generateSVG( const sanitizeFont = (name: string) => name.replace(/[^a-zA-Z0-9\s-]/g, '').trim(); const sanitizedFont = params.font ? sanitizeFont(params.font) : null; - const predefinedFont = sanitizedFont ? FONT_MAP[sanitizedFont.toLowerCase()] : null; const isPredefinedFont = Boolean(predefinedFont); - const selectedFont = isPredefinedFont ? predefinedFont : sanitizedFont ? `"${sanitizedFont}", sans-serif` : null; - const defaultTitleFont = '"Syncopate", sans-serif'; - const defaultBodyFont = '"Space Grotesk", sans-serif'; - - const statsFont = selectedFont || defaultBodyFont; - const labelFont = '"Roboto", sans-serif'; - + const statsFont = selectedFont || '"Space Grotesk", sans-serif'; const parsedRadius = Number(params.radius); const radius = Math.max(0, Math.min(Number.isNaN(parsedRadius) ? 8 : parsedRadius, 50)); @@ -186,41 +207,25 @@ export function generateSVG( let towers = ''; for (const t of towerData) { - const color = t.hasCommits ? accent : text; + const color = t.isGhost ? text : accent; towers += ` - ${ - t.isTodayWithCommits - ? '' - : '' - } + ${t.isTodayWithCommits ? '' : ''} ${t.tooltip} - - - - ${ - t.contributionCount > 5 - ? `` - : '' - } + + + + ${t.contributionCount > 5 ? `` : ''} `; - - if (t.contributionCount >= 10) { + if (t.contributionCount >= 10) towers += generateParticles(t.x, t.y, t.h, accent, t.contributionCount); - } } // dynamic google fonts import const googleFontsImport = sanitizedFont && !isPredefinedFont - ? `@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent( - sanitizedFont - ).replace(/%20/g, '+')}&display=swap');` + ? `@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(sanitizedFont).replace(/%20/g, '+')}&display=swap');` : ''; return ` @@ -237,59 +242,24 @@ export function generateSVG( ${params.user || 'This user'} has ${stats.totalContributions} total contributions and a longest streak of ${stats.longestStreak} days. - - - - + - - ${towers} - + ${towers} CURRENT_STREAK @@ -333,17 +303,10 @@ function generateAutoThemeSVG( const light = AUTO_LIGHT_THEME; const dark = AUTO_DARK_THEME; const safeUser = escapeXML(params.user || 'GitHub User'); - const selectedFont = params.font ? FONT_MAP[params.font.toLowerCase()] || '"JetBrains Mono", monospace' : null; - - const defaultTitleFont = '"Syncopate", sans-serif'; - const defaultBodyFont = '"Space Grotesk", sans-serif'; - - const statsFont = selectedFont || defaultBodyFont; - const labelFont = '"Roboto", sans-serif'; - + const statsFont = selectedFont || '"Space Grotesk", sans-serif'; const parsedRadius = Number(params.radius); const radius = Math.max(0, Math.min(Number.isNaN(parsedRadius) ? 8 : parsedRadius, 50)); @@ -351,35 +314,19 @@ function generateAutoThemeSVG( let towers = ''; for (const t of towerData) { - // Use CSS classes for fill so the color switches via the media query. - // cp-accent-fill → var(--cp-accent), cp-text-fill → var(--cp-text) const fillClass = t.hasCommits ? 'cp-accent-fill' : 'cp-text-fill'; towers += ` - ${ - t.isTodayWithCommits - ? '' - : '' - } + ${t.isTodayWithCommits ? '' : ''} ${t.tooltip} - - - - ${ - t.contributionCount > 5 - ? `` - : '' - } + + + + ${t.contributionCount > 5 ? `` : ''} `; - - if (t.contributionCount >= 10) { + if (t.contributionCount >= 10) towers += generateAutoParticles(t.x, t.y, t.h, t.contributionCount); - } } return ` @@ -396,77 +343,23 @@ function generateAutoThemeSVG( ${params.user || 'This user'} has ${stats.totalContributions} total contributions and a longest streak of ${stats.longestStreak} days. - - - - + - ${towers}