diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts
index 2f2789cc..46d6cc07 100644
--- a/app/api/streak/route.ts
+++ b/app/api/streak/route.ts
@@ -52,6 +52,8 @@ export async function GET(request: Request) {
mode,
repo,
org,
+ labels,
+ labelColor,
} = parseResult.data;
const themeName = theme || 'dark';
@@ -107,6 +109,8 @@ export async function GET(request: Request) {
mode,
repo,
org,
+ labels,
+ labelColor,
};
let calendar;
diff --git a/lib/svg/generator.test.ts b/lib/svg/generator.test.ts
index f94f0cf8..9d1d36a7 100644
--- a/lib/svg/generator.test.ts
+++ b/lib/svg/generator.test.ts
@@ -600,6 +600,54 @@ describe('generateSVG', () => {
expect(svg).toContain('height="560"');
});
});
+
+ describe('isometric labels', () => {
+ it('does not render labels when labels parameter is absent', () => {
+ const svg = generateSVG(mockStats, { user: 'avi' } as unknown as BadgeParams, mockCalendar);
+ expect(svg).not.toContain('class="isometric-labels"');
+ });
+
+ it('does not render labels when labels parameter is false', () => {
+ const svg = generateSVG(
+ mockStats,
+ { user: 'avi', labels: false } as unknown as BadgeParams,
+ mockCalendar
+ );
+ expect(svg).not.toContain('class="isometric-labels"');
+ });
+
+ it('renders month and weekday labels when labels=true', () => {
+ const svg = generateSVG(
+ mockStats,
+ { user: 'avi', labels: true } as unknown as BadgeParams,
+ mockCalendar
+ );
+ expect(svg).toContain('class="isometric-labels"');
+ expect(svg).toContain('Jun'); // June is first date in calendar '2024-06-10'
+ expect(svg).toContain('Mon');
+ expect(svg).toContain('Wed');
+ expect(svg).toContain('Fri');
+ });
+
+ it('applies custom labelColor when provided', () => {
+ const svg = generateSVG(
+ mockStats,
+ { user: 'avi', labels: true, labelColor: 'ff00aa' } as unknown as BadgeParams,
+ mockCalendar
+ );
+ expect(svg).toContain('fill="#ff00aa"');
+ });
+
+ it('renders labels in auto-theme mode', () => {
+ const svg = generateSVG(
+ mockStats,
+ { user: 'avi', labels: true, autoTheme: true } as unknown as BadgeParams,
+ mockCalendar
+ );
+ expect(svg).toContain('class="isometric-labels"');
+ expect(svg).toContain('fill="var(--cp-text)"');
+ });
+ });
});
describe('generateMonthlySVG', () => {
diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts
index e0dd46cc..2024aceb 100644
--- a/lib/svg/generator.ts
+++ b/lib/svg/generator.ts
@@ -4,7 +4,7 @@ import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } fro
import { getLabels, type BadgeLabels } from '../i18n/badgeLabels';
import { AUTO_THEME_DARK, AUTO_THEME_LIGHT } from './themes';
import { TOWER_ANIMATION_CSS } from './animations';
-import { computeTowers, type TowerData } from './layout';
+import { computeTowers, projectIsometric, type TowerData } from './layout';
import { sanitizeFont, sanitizeHexColor, sanitizeRadius, sanitizeGoogleFontUrl } from './sanitizer';
import { SVG_WIDTH, SVG_HEIGHT, FONT_MAP, isFontKey } from './generatorConstants';
@@ -176,6 +176,7 @@ function renderStyle(
transform: translateY(var(--scan-start, ${fs(20)}px)) !important;
}
}
+ .isometric-label { font-family: ${selectedFont || '"Roboto", sans-serif'}; font-size: ${fs(10)}px; font-weight: 400; letter-spacing: 1px; fill-opacity: 0.6; }
`;
}
@@ -224,6 +225,92 @@ function renderFooter(
/>`;
}
+const MONTH_NAMES = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+];
+
+// Layout constants for 3D isometric grid positioning to avoid magic numbers
+const GRID_ORIGIN_X = 300;
+const GRID_ORIGIN_Y = 120;
+const TILE_WIDTH_HALF = 16;
+const TILE_HEIGHT_HALF = 9;
+const ISOMETRIC_VERTICAL_OFFSET = 20;
+
+const MONTH_LABEL_ROW_OFFSET = 7.2;
+const WEEKDAY_LABEL_COL_OFFSET = -1.2;
+function renderIsometricLabels(
+ calendar: ContributionCalendar,
+ params: BadgeParams,
+ color: string,
+ sf: number
+): string {
+ if (!params.labels) return '';
+
+ const s = createScaler(sf);
+ let elements = '';
+
+ const weeks = calendar.weeks.slice(-14);
+ const monthLabels: { text: string; col: number }[] = [];
+ let prevMonthStr = '';
+
+ weeks.forEach((week, i) => {
+ if (week.contributionDays.length === 0) return;
+ const firstDay = week.contributionDays[0];
+ const monthNum = parseInt(firstDay.date.substring(5, 7), 10);
+ const monthStr = MONTH_NAMES[monthNum - 1];
+
+ if (i === 0 || monthStr !== prevMonthStr) {
+ monthLabels.push({ text: monthStr, col: i });
+ prevMonthStr = monthStr;
+ }
+ });
+
+ const labelColorHex = params.labelColor ? `#${params.labelColor}` : color;
+
+ monthLabels.forEach((label) => {
+ const tx = s(GRID_ORIGIN_X + (label.col - MONTH_LABEL_ROW_OFFSET) * TILE_WIDTH_HALF + 8);
+ const ty =
+ s(
+ GRID_ORIGIN_Y +
+ (label.col + MONTH_LABEL_ROW_OFFSET) * TILE_HEIGHT_HALF +
+ ISOMETRIC_VERTICAL_OFFSET
+ ) + Math.round(20 * sf);
+ elements += `
+ ${label.text}`;
+ });
+
+ const weekdays = [
+ { text: 'Mon', row: 1 },
+ { text: 'Wed', row: 3 },
+ { text: 'Fri', row: 5 },
+ ];
+
+ weekdays.forEach((day) => {
+ const tx = s(GRID_ORIGIN_X + (WEEKDAY_LABEL_COL_OFFSET - day.row) * TILE_WIDTH_HALF);
+ const ty =
+ s(
+ GRID_ORIGIN_Y +
+ (WEEKDAY_LABEL_COL_OFFSET + day.row) * TILE_HEIGHT_HALF +
+ ISOMETRIC_VERTICAL_OFFSET
+ ) + Math.round(20 * sf);
+ elements += `
+ ${day.text}`;
+ });
+
+ return `${elements}`;
+}
+
// ── Main static-theme renderer ────────────────────────────────────────────
export function generateSVG(
@@ -271,6 +358,7 @@ export function generateSVG(
${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf)}
${towers}
+ ${renderIsometricLabels(calendar, params, text, sf)}
${renderFooter(stats, params, labels, safeUser, accent, sf)}
`;
}
@@ -353,6 +441,7 @@ function generateAutoThemeSVG(
.stats { font-family: ${statsFont}; fill: var(--cp-text); font-size: ${fs(42)}px; font-weight: 500; letter-spacing: 0; }
.total-val { font-family: ${statsFont}; fill: var(--cp-accent); font-size: ${fs(24)}px; font-weight: 500; }
.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; }
+ .isometric-label { font-family: ${selectedFont || '"Roboto", sans-serif'}; font-size: ${fs(10)}px; font-weight: 400; letter-spacing: 1px; fill-opacity: 0.6; }
@media (prefers-reduced-motion: reduce) {
.heat-particles { display: none; }
@@ -367,6 +456,7 @@ function generateAutoThemeSVG(
${towers}
+ ${renderIsometricLabels(calendar, params, 'var(--cp-text)', sf)}
${!params.hide_stats ? renderStatsSection(stats, labels, s, params) : ''}
${
!params.hide_title
diff --git a/lib/svg/layout.ts b/lib/svg/layout.ts
index d58a6ac6..0b9d6173 100644
--- a/lib/svg/layout.ts
+++ b/lib/svg/layout.ts
@@ -56,6 +56,20 @@ function computeFaceOpacity(count: number, isGhostCityMode: boolean): FaceOpacit
return { left: 0.35, right: 0.21, top: 0.7 };
}
+/**
+ * Projects 2D grid coordinates (weekIndex, dayIndex) into 3D isometric screen coordinates.
+ *
+ * @param weekIndex The week column index (0 to 13).
+ * @param dayIndex The day-of-week row index (0 to 6).
+ * @returns Projected x and y coordinate offsets in pixels.
+ */
+export function projectIsometric(weekIndex: number, dayIndex: number): { x: number; y: number } {
+ return {
+ x: 300 + (weekIndex - dayIndex) * 16,
+ y: 120 + (weekIndex + dayIndex) * 9,
+ };
+}
+
/**
* Computes the full isometric tower layout used by the SVG renderer.
*
@@ -103,17 +117,11 @@ export function computeTowers(
? `TODAY: ${day.date}: ${count} ${unit}`
: `${day.date}: ${count} ${unit}`;
- // Isometric projection: Maps 2D grid coordinates (i, j) to a 3D isometric screen space.
- // - Origin: (300, 120) anchors the grid layout on the SVG canvas.
- // - Indices: 'i' represents the week/column index; 'j' represents the day/row index.
- // - Geometry:
- // * (i - j) * 16 handles the horizontal shift. Increasing 'i' moves right; increasing 'j' moves left.
- // * (i + j) * 9 handles the vertical depth. Both indices move the tile downward.
- // - Constants: 16 and 9 represent half-widths and half-heights of the diamond tiles,
- // maintaining a clean ~2:1 aspect ratio for isometric perspective.
+ const coords = projectIsometric(i, j);
+
towers.push({
- x: 300 + (i - j) * 16,
- y: 120 + (i + j) * 9,
+ x: coords.x,
+ y: coords.y,
h: computeTowerHeight(count, scale, shouldShowGhostCity),
hasCommits,
isGhost,
diff --git a/lib/validations.ts b/lib/validations.ts
index c1c7b366..c8d7e5b1 100644
--- a/lib/validations.ts
+++ b/lib/validations.ts
@@ -113,13 +113,17 @@ export const streakParamsSchema = z.object({
return isNaN(parsed) ? 1 : Math.max(0, Math.min(parsed, 7));
})
.default(1),
-
- /* ==========================================================================
- * NEW EPIC FEATURE PARAMS
- * ========================================================================== */
mode: z.enum(['commits', 'loc']).catch('commits').default('commits'),
repo: z.string().optional(),
org: z.string().optional(),
+ labels: z
+ .string()
+ .optional()
+ .transform((val) => val === 'true' || val === '1'),
+ labelColor: z
+ .string()
+ .optional()
+ .transform((val) => (val ? sanitizeHexColor(val, '7f8c8d') : undefined)),
});
export const githubParamsSchema = z.object({
diff --git a/types/index.ts b/types/index.ts
index ad142d57..9ac6a931 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -155,10 +155,6 @@ export interface BadgeParams {
/** Preset size of the badge. 'small', 'medium', or 'large'. Overrides width and height. */
size?: BadgeSize;
- /* ==========================================================================
- * NEW EPIC FEATURE PARAMS
- * ========================================================================== */
-
/** Rendering mode. 'commits' is the default. 'loc' switches to Lines of Code landscape. */
mode?: 'commits' | 'loc';
@@ -167,4 +163,10 @@ export interface BadgeParams {
/** Organization name to generate a Mega-City for. */
org?: string;
+
+ /** When true, renders optional 3D isometric month headers and weekday labels. */
+ labels?: boolean;
+
+ /** Custom text color for the labels. Overrides text parameter. */
+ labelColor?: HexColor;
}