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; }