@@ -4,7 +4,7 @@ import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } fro
44import { getLabels , type BadgeLabels } from '../i18n/badgeLabels' ;
55import { AUTO_THEME_DARK , AUTO_THEME_LIGHT } from './themes' ;
66import { TOWER_ANIMATION_CSS } from './animations' ;
7- import { computeTowers , type TowerData } from './layout' ;
7+ import { computeTowers , projectIsometric , type TowerData } from './layout' ;
88import { sanitizeFont , sanitizeHexColor , sanitizeRadius , sanitizeGoogleFontUrl } from './sanitizer' ;
99
1010import { SVG_WIDTH , SVG_HEIGHT , FONT_MAP , isFontKey } from './generatorConstants' ;
@@ -176,6 +176,7 @@ function renderStyle(
176176 transform: translateY(var(--scan-start, ${ fs ( 20 ) } px)) !important;
177177 }
178178 }
179+ .isometric-label { font-family: ${ selectedFont || '"Roboto", sans-serif' } ; font-size: ${ fs ( 10 ) } px; font-weight: 400; letter-spacing: 1px; fill-opacity: 0.6; }
179180 </style>` ;
180181}
181182
@@ -224,6 +225,92 @@ function renderFooter(
224225 />` ;
225226}
226227
228+ const MONTH_NAMES = [
229+ 'Jan' ,
230+ 'Feb' ,
231+ 'Mar' ,
232+ 'Apr' ,
233+ 'May' ,
234+ 'Jun' ,
235+ 'Jul' ,
236+ 'Aug' ,
237+ 'Sep' ,
238+ 'Oct' ,
239+ 'Nov' ,
240+ 'Dec' ,
241+ ] ;
242+
243+ // Layout constants for 3D isometric grid positioning to avoid magic numbers
244+ const GRID_ORIGIN_X = 300 ;
245+ const GRID_ORIGIN_Y = 120 ;
246+ const TILE_WIDTH_HALF = 16 ;
247+ const TILE_HEIGHT_HALF = 9 ;
248+ const ISOMETRIC_VERTICAL_OFFSET = 20 ;
249+
250+ const MONTH_LABEL_ROW_OFFSET = 7.2 ;
251+ const WEEKDAY_LABEL_COL_OFFSET = - 1.2 ;
252+ function renderIsometricLabels (
253+ calendar : ContributionCalendar ,
254+ params : BadgeParams ,
255+ color : string ,
256+ sf : number
257+ ) : string {
258+ if ( ! params . labels ) return '' ;
259+
260+ const s = createScaler ( sf ) ;
261+ let elements = '' ;
262+
263+ const weeks = calendar . weeks . slice ( - 14 ) ;
264+ const monthLabels : { text : string ; col : number } [ ] = [ ] ;
265+ let prevMonthStr = '' ;
266+
267+ weeks . forEach ( ( week , i ) => {
268+ if ( week . contributionDays . length === 0 ) return ;
269+ const firstDay = week . contributionDays [ 0 ] ;
270+ const monthNum = parseInt ( firstDay . date . substring ( 5 , 7 ) , 10 ) ;
271+ const monthStr = MONTH_NAMES [ monthNum - 1 ] ;
272+
273+ if ( i === 0 || monthStr !== prevMonthStr ) {
274+ monthLabels . push ( { text : monthStr , col : i } ) ;
275+ prevMonthStr = monthStr ;
276+ }
277+ } ) ;
278+
279+ const labelColorHex = params . labelColor ? `#${ params . labelColor } ` : color ;
280+
281+ monthLabels . forEach ( ( label ) => {
282+ const tx = s ( GRID_ORIGIN_X + ( label . col - MONTH_LABEL_ROW_OFFSET ) * TILE_WIDTH_HALF + 8 ) ;
283+ const ty =
284+ s (
285+ GRID_ORIGIN_Y +
286+ ( label . col + MONTH_LABEL_ROW_OFFSET ) * TILE_HEIGHT_HALF +
287+ ISOMETRIC_VERTICAL_OFFSET
288+ ) + Math . round ( 20 * sf ) ;
289+ elements += `
290+ <text x="${ tx } " y="${ ty } " text-anchor="middle" fill="${ labelColorHex } " class="isometric-label">${ label . text } </text>` ;
291+ } ) ;
292+
293+ const weekdays = [
294+ { text : 'Mon' , row : 1 } ,
295+ { text : 'Wed' , row : 3 } ,
296+ { text : 'Fri' , row : 5 } ,
297+ ] ;
298+
299+ weekdays . forEach ( ( day ) => {
300+ const tx = s ( GRID_ORIGIN_X + ( WEEKDAY_LABEL_COL_OFFSET - day . row ) * TILE_WIDTH_HALF ) ;
301+ const ty =
302+ s (
303+ GRID_ORIGIN_Y +
304+ ( WEEKDAY_LABEL_COL_OFFSET + day . row ) * TILE_HEIGHT_HALF +
305+ ISOMETRIC_VERTICAL_OFFSET
306+ ) + Math . round ( 20 * sf ) ;
307+ elements += `
308+ <text x="${ tx } " y="${ ty } " text-anchor="end" fill="${ labelColorHex } " class="isometric-label">${ day . text } </text>` ;
309+ } ) ;
310+
311+ return `<g class="isometric-labels">${ elements } </g>` ;
312+ }
313+
227314// ── Main static-theme renderer ────────────────────────────────────────────
228315
229316export function generateSVG (
@@ -271,6 +358,7 @@ export function generateSVG(
271358 ${ renderStyle ( selectedFont , statsFont , googleFontsImport , text , accent , sf ) }
272359 <rect width="${ W } " height="${ H } " rx="${ radius } " fill="${ params . hideBackground ? 'transparent' : bg } " />
273360 <g transform="translate(0, ${ Math . round ( 20 * sf ) } )">${ towers } </g>
361+ ${ renderIsometricLabels ( calendar , params , text , sf ) }
274362 ${ renderFooter ( stats , params , labels , safeUser , accent , sf ) }
275363</svg>` ;
276364}
@@ -353,6 +441,7 @@ function generateAutoThemeSVG(
353441 .stats { font-family: ${ statsFont } ; fill: var(--cp-text); font-size: ${ fs ( 42 ) } px; font-weight: 500; letter-spacing: 0; }
354442 .total-val { font-family: ${ statsFont } ; fill: var(--cp-accent); font-size: ${ fs ( 24 ) } px; font-weight: 500; }
355443 .label { font-family: "Roboto", sans-serif; fill: var(--cp-accent); font-size: ${ fs ( 11 ) } px; font-weight: 400; letter-spacing: ${ fs ( 2 ) } px; opacity: 0.7; }
444+ .isometric-label { font-family: ${ selectedFont || '"Roboto", sans-serif' } ; font-size: ${ fs ( 10 ) } px; font-weight: 400; letter-spacing: 1px; fill-opacity: 0.6; }
356445
357446 @media (prefers-reduced-motion: reduce) {
358447 .heat-particles { display: none; }
@@ -367,6 +456,7 @@ function generateAutoThemeSVG(
367456 <g transform="translate(0, ${ s ( 20 ) } )">
368457 ${ towers }
369458 </g>
459+ ${ renderIsometricLabels ( calendar , params , 'var(--cp-text)' , sf ) }
370460 ${ ! params . hide_stats ? renderStatsSection ( stats , labels , s , params ) : '' }
371461${
372462 ! params . hide_title
0 commit comments