11import type { BadgeParams , ContributionCalendar , StreakStats } from '../../types' ;
22import { getLabels } from '../i18n/badgeLabels' ;
33import { AUTO_DARK_THEME , AUTO_LIGHT_THEME } from './themes' ;
4-
5- // constants
6- const GHOST_HEIGHT_PX = 4 ;
7- const LOG_SCALE_MULTIPLIER = 12 ;
8- const LINEAR_SCALE_MULTIPLIER = 5 ;
9- const MAX_LOG_HEIGHT = 80 ;
10- const MAX_LINEAR_HEIGHT = 50 ;
11-
12- // TOWER_BASE_Y: the vertical midpoint of the isometric ground floor diamond in local SVG space.
13- // The diamond paths are drawn from y=0 (back vertex) to y=20 (front vertex), making y=10
14- // the horizontal center line that acts as the visual ground level for each tower.
15- // This value is used as the CSS transform-origin for the grow-up animation so towers
16- // scale upward from their ground tile rather than from the SVG origin.
17- const TOWER_BASE_Y = 10 ;
18-
19- // Shared animation CSS injected into both static and auto-theme SVG renderers.
20- // Defined once here to avoid duplication — any curve/timing change only needs updating in one place.
21- const TOWER_ANIMATION_CSS = `
22- .cp-tower {
23- transform: scaleY(0);
24- transform-origin: 0 ${ TOWER_BASE_Y } px;
25- animation: grow-up 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
26- }
27- @keyframes grow-up {
28- from { transform: scaleY(0); }
29- to { transform: scaleY(1); }
30- }
31- @media (prefers-reduced-motion: reduce) {
32- .cp-tower { animation: none !important; transform: scaleY(1) !important; }
33- }` ;
4+ import { TOWER_ANIMATION_CSS } from './animations' ;
5+ import { computeTowers , type TowerData } from './layout' ;
346
357const FONT_MAP : Record < string , string > = {
368 jetbrains : '"JetBrains Mono", monospace' ,
379 fira : '"Fira Code", monospace' ,
3810 roboto : '"Roboto", sans-serif' ,
3911} ;
4012
41- // types
42- /** Shared layout data for a single isometric tower. */
43- interface FaceOpacity {
44- left : number ;
45- right : number ;
46- top : number ;
47- }
48-
49- interface TowerData {
50- x : number ;
51- y : number ;
52- h : number ;
53- hasCommits : boolean ;
54- isGhost : boolean ;
55- isToday : boolean ;
56- isTodayWithCommits : boolean ;
57- tooltip : string ;
58- contributionCount : number ;
59- faceOpacity : FaceOpacity ;
60- strokeOpacity : number ;
61- strokeWidth : number ;
62- /** Grid position used to compute the staggered animation-delay (row + col) * offset */
63- row : number ;
64- col : number ;
65- }
66-
6713// helpers
6814function getSizeScale ( size ?: 'small' | 'medium' | 'large' ) : number {
6915 if ( size === 'small' ) return 400 / 600 ;
@@ -79,95 +25,15 @@ function deterministicRandom(seed: string): number {
7925 }
8026 return ( hash >>> 0 ) / 4294967296 ;
8127}
82- function computeTowerHeight (
83- count : number ,
84- scale : 'linear' | 'log' ,
85- shouldShowGhostCity : boolean
86- ) : number {
87- if ( count === 0 && shouldShowGhostCity ) return GHOST_HEIGHT_PX ;
88- if ( count === 0 ) return 0 ;
89- return scale === 'log'
90- ? Math . min ( Math . log2 ( count + 1 ) * LOG_SCALE_MULTIPLIER , MAX_LOG_HEIGHT )
91- : Math . min ( count * LINEAR_SCALE_MULTIPLIER , MAX_LINEAR_HEIGHT ) ;
92- }
93-
94- function computeFaceOpacity ( count : number , isGhostCityMode : boolean ) : FaceOpacity {
95- if ( isGhostCityMode ) {
96- return { left : 0 , right : 0 , top : 0.02 } ;
97- }
98- if ( count === 0 ) {
99- return { left : 0 , right : 0 , top : 0.02 } ;
100- }
101- return { left : 0.35 , right : 0.21 , top : 0.7 } ;
102- }
10328
104- /**
105- * Computes tower positions and heights from the last 14 weeks of
106- * contribution data. The layout math is identical for both the
107- * static-theme and auto-theme rendering paths.
108- */
109- function computeTowers (
110- calendar : ContributionCalendar ,
111- scale : 'linear' | 'log' ,
112- todayDate : string ,
113- sf : number = 1
114- ) : TowerData [ ] {
115- const weeks = calendar . weeks . slice ( - 14 ) ;
116- const towers : TowerData [ ] = [ ] ;
117-
118- // Calculate if the entire monolith is empty
119- let totalVisibleContributions = 0 ;
120- weeks . forEach ( ( week ) => {
121- week . contributionDays . forEach ( ( day ) => {
122- totalVisibleContributions += day . contributionCount ;
123- } ) ;
124- } ) ;
125-
126- const shouldShowGhostCity = totalVisibleContributions === 0 ;
127-
128- // Pre-check: is todayDate present in the visible 14-week window?
129- // If not (e.g. stale cache or todayDate outside the window), fall back to
130- // marking the last visible day as "today" so the pulse always appears.
131- const todayInWindow = weeks . some ( ( w ) => w . contributionDays . some ( ( d ) => d . date === todayDate ) ) ;
132-
133- weeks . forEach ( ( week , i ) => {
134- week . contributionDays . forEach ( ( day , j ) => {
135- // Use the caller-supplied local date so the pulse animation fires on the
136- // correct tower for users in non-UTC timezones, not always the last UTC entry.
137- const isToday =
138- day . date === todayDate ||
139- // Fallback: if todayDate isn't in the visible window, keep the old behaviour.
140- ( ! todayInWindow && i === weeks . length - 1 && j === week . contributionDays . length - 1 ) ;
141- const hasCommits = day . contributionCount > 0 ;
142- const isGhost = ! hasCommits && shouldShowGhostCity ;
143- const isTodayWithCommits = isToday && hasCommits ;
144-
145- const tooltip = isTodayWithCommits
146- ? `TODAY: ${ day . date } : ${ day . contributionCount } contributions`
147- : `${ day . date } : ${ day . contributionCount } contributions` ;
148-
149- // If not ghost city and no commits, height is 0, so don't render face if not needed,
150- // but we return 0 for height so it won't be visible.
151- towers . push ( {
152- x : Math . round ( ( 300 + ( i - j ) * 16 ) * sf ) ,
153- y : Math . round ( ( 120 + ( i + j ) * 9 ) * sf ) ,
154- h : computeTowerHeight ( day . contributionCount , scale , shouldShowGhostCity ) * sf ,
155- hasCommits,
156- isGhost,
157- isToday,
158- isTodayWithCommits,
159- tooltip,
160- contributionCount : day . contributionCount ,
161- faceOpacity : computeFaceOpacity ( day . contributionCount , shouldShowGhostCity ) ,
162- strokeOpacity : isGhost ? 0.3 : 0 ,
163- strokeWidth : isGhost ? 0.5 : 0 ,
164- row : i ,
165- col : j ,
166- } ) ;
167- } ) ;
168- } ) ;
169-
170- return towers ;
29+ function scaleTowerData ( towerData : TowerData [ ] , sf : number ) : TowerData [ ] {
30+ if ( sf === 1 ) return towerData ;
31+ return towerData . map ( ( t ) => ( {
32+ ...t ,
33+ x : Math . round ( t . x * sf ) ,
34+ y : Math . round ( t . y * sf ) ,
35+ h : t . h * sf ,
36+ } ) ) ;
17137}
17238
17339export function escapeXML ( str : string ) : string {
@@ -264,7 +130,7 @@ export function generateSVG(
264130
265131 const W = Math . round ( 600 * sf ) ;
266132 const H = Math . round ( 420 * sf ) ;
267- const towerData = computeTowers ( calendar , params . scale , stats . todayDate , sf ) ;
133+ const towerData = scaleTowerData ( computeTowers ( calendar , params . scale , stats . todayDate ) , sf ) ;
268134 let towers = '' ;
269135
270136 for ( const t of towerData ) {
@@ -388,7 +254,7 @@ function generateAutoThemeSVG(
388254
389255 const W = Math . round ( 600 * sf ) ;
390256 const H = Math . round ( 420 * sf ) ;
391- const towerData = computeTowers ( calendar , params . scale , stats . todayDate , sf ) ;
257+ const towerData = scaleTowerData ( computeTowers ( calendar , params . scale , stats . todayDate ) , sf ) ;
392258 let towers = '' ;
393259
394260 for ( const t of towerData ) {
0 commit comments