Skip to content

Commit bcd0718

Browse files
authored
feat: add optional 3D isometric month headers and weekday labels (JhaSourav07#800)
* feat: add optional 3D isometric month headers and weekday labels * refactor: extract projectIsometric helper from layout.ts and reuse in renderIsometricLabels * refactor: address Copilot PR review suggestions for layout and styling
1 parent d1bae41 commit bcd0718

6 files changed

Lines changed: 175 additions & 19 deletions

File tree

app/api/streak/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export async function GET(request: Request) {
5252
mode,
5353
repo,
5454
org,
55+
labels,
56+
labelColor,
5557
} = parseResult.data;
5658

5759
const themeName = theme || 'dark';
@@ -107,6 +109,8 @@ export async function GET(request: Request) {
107109
mode,
108110
repo,
109111
org,
112+
labels,
113+
labelColor,
110114
};
111115

112116
let calendar;

lib/svg/generator.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,54 @@ describe('generateSVG', () => {
600600
expect(svg).toContain('height="560"');
601601
});
602602
});
603+
604+
describe('isometric labels', () => {
605+
it('does not render labels when labels parameter is absent', () => {
606+
const svg = generateSVG(mockStats, { user: 'avi' } as unknown as BadgeParams, mockCalendar);
607+
expect(svg).not.toContain('class="isometric-labels"');
608+
});
609+
610+
it('does not render labels when labels parameter is false', () => {
611+
const svg = generateSVG(
612+
mockStats,
613+
{ user: 'avi', labels: false } as unknown as BadgeParams,
614+
mockCalendar
615+
);
616+
expect(svg).not.toContain('class="isometric-labels"');
617+
});
618+
619+
it('renders month and weekday labels when labels=true', () => {
620+
const svg = generateSVG(
621+
mockStats,
622+
{ user: 'avi', labels: true } as unknown as BadgeParams,
623+
mockCalendar
624+
);
625+
expect(svg).toContain('class="isometric-labels"');
626+
expect(svg).toContain('Jun'); // June is first date in calendar '2024-06-10'
627+
expect(svg).toContain('Mon');
628+
expect(svg).toContain('Wed');
629+
expect(svg).toContain('Fri');
630+
});
631+
632+
it('applies custom labelColor when provided', () => {
633+
const svg = generateSVG(
634+
mockStats,
635+
{ user: 'avi', labels: true, labelColor: 'ff00aa' } as unknown as BadgeParams,
636+
mockCalendar
637+
);
638+
expect(svg).toContain('fill="#ff00aa"');
639+
});
640+
641+
it('renders labels in auto-theme mode', () => {
642+
const svg = generateSVG(
643+
mockStats,
644+
{ user: 'avi', labels: true, autoTheme: true } as unknown as BadgeParams,
645+
mockCalendar
646+
);
647+
expect(svg).toContain('class="isometric-labels"');
648+
expect(svg).toContain('fill="var(--cp-text)"');
649+
});
650+
});
603651
});
604652

605653
describe('generateMonthlySVG', () => {

lib/svg/generator.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } fro
44
import { getLabels, type BadgeLabels } from '../i18n/badgeLabels';
55
import { AUTO_THEME_DARK, AUTO_THEME_LIGHT } from './themes';
66
import { TOWER_ANIMATION_CSS } from './animations';
7-
import { computeTowers, type TowerData } from './layout';
7+
import { computeTowers, projectIsometric, type TowerData } from './layout';
88
import { sanitizeFont, sanitizeHexColor, sanitizeRadius, sanitizeGoogleFontUrl } from './sanitizer';
99

1010
import { 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

229316
export 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

lib/svg/layout.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ function computeFaceOpacity(count: number, isGhostCityMode: boolean): FaceOpacit
5656
return { left: 0.35, right: 0.21, top: 0.7 };
5757
}
5858

59+
/**
60+
* Projects 2D grid coordinates (weekIndex, dayIndex) into 3D isometric screen coordinates.
61+
*
62+
* @param weekIndex The week column index (0 to 13).
63+
* @param dayIndex The day-of-week row index (0 to 6).
64+
* @returns Projected x and y coordinate offsets in pixels.
65+
*/
66+
export function projectIsometric(weekIndex: number, dayIndex: number): { x: number; y: number } {
67+
return {
68+
x: 300 + (weekIndex - dayIndex) * 16,
69+
y: 120 + (weekIndex + dayIndex) * 9,
70+
};
71+
}
72+
5973
/**
6074
* Computes the full isometric tower layout used by the SVG renderer.
6175
*
@@ -103,17 +117,11 @@ export function computeTowers(
103117
? `TODAY: ${day.date}: ${count} ${unit}`
104118
: `${day.date}: ${count} ${unit}`;
105119

106-
// Isometric projection: Maps 2D grid coordinates (i, j) to a 3D isometric screen space.
107-
// - Origin: (300, 120) anchors the grid layout on the SVG canvas.
108-
// - Indices: 'i' represents the week/column index; 'j' represents the day/row index.
109-
// - Geometry:
110-
// * (i - j) * 16 handles the horizontal shift. Increasing 'i' moves right; increasing 'j' moves left.
111-
// * (i + j) * 9 handles the vertical depth. Both indices move the tile downward.
112-
// - Constants: 16 and 9 represent half-widths and half-heights of the diamond tiles,
113-
// maintaining a clean ~2:1 aspect ratio for isometric perspective.
120+
const coords = projectIsometric(i, j);
121+
114122
towers.push({
115-
x: 300 + (i - j) * 16,
116-
y: 120 + (i + j) * 9,
123+
x: coords.x,
124+
y: coords.y,
117125
h: computeTowerHeight(count, scale, shouldShowGhostCity),
118126
hasCommits,
119127
isGhost,

lib/validations.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,17 @@ export const streakParamsSchema = z.object({
113113
return isNaN(parsed) ? 1 : Math.max(0, Math.min(parsed, 7));
114114
})
115115
.default(1),
116-
117-
/* ==========================================================================
118-
* NEW EPIC FEATURE PARAMS
119-
* ========================================================================== */
120116
mode: z.enum(['commits', 'loc']).catch('commits').default('commits'),
121117
repo: z.string().optional(),
122118
org: z.string().optional(),
119+
labels: z
120+
.string()
121+
.optional()
122+
.transform((val) => val === 'true' || val === '1'),
123+
labelColor: z
124+
.string()
125+
.optional()
126+
.transform((val) => (val ? sanitizeHexColor(val, '7f8c8d') : undefined)),
123127
});
124128

125129
export const githubParamsSchema = z.object({

types/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,6 @@ export interface BadgeParams {
155155
/** Preset size of the badge. 'small', 'medium', or 'large'. Overrides width and height. */
156156
size?: BadgeSize;
157157

158-
/* ==========================================================================
159-
* NEW EPIC FEATURE PARAMS
160-
* ========================================================================== */
161-
162158
/** Rendering mode. 'commits' is the default. 'loc' switches to Lines of Code landscape. */
163159
mode?: 'commits' | 'loc';
164160

@@ -167,4 +163,10 @@ export interface BadgeParams {
167163

168164
/** Organization name to generate a Mega-City for. */
169165
org?: string;
166+
167+
/** When true, renders optional 3D isometric month headers and weekday labels. */
168+
labels?: boolean;
169+
170+
/** Custom text color for the labels. Overrides text parameter. */
171+
labelColor?: HexColor;
170172
}

0 commit comments

Comments
 (0)