Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
generateNotFoundSVG,
generateSVG,
generateMonthlySVG,
escapeXML,

Check warning on line 9 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'escapeXML' is defined but never used
} from '../../../lib/svg/generator';
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '../../../utils/time';
import type { BadgeParams } from '../../../types';
Expand Down Expand Up @@ -84,6 +84,8 @@
width,
height,
grace,
labels,
labelColor,
} = parseResult.data;

const themeName = theme || 'dark';
Expand Down Expand Up @@ -133,6 +135,8 @@
height,
size,
grace,
labels,
labelColor,
};

const calendar = await fetchGitHubContributions(user, {
Expand Down
48 changes: 48 additions & 0 deletions lib/svg/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,54 @@ describe('generateSVG', () => {
expect(svg).not.toContain('fill="white" fill-opacity="0.2"');
});
});

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', () => {
Expand Down
79 changes: 78 additions & 1 deletion lib/svg/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,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 } from './constants';
Expand Down Expand Up @@ -163,6 +163,7 @@ function renderStyle(
.stats { font-family: ${statsFont}; fill: ${text}; font-size: ${fs(42)}px; font-weight: 500; letter-spacing: 0; }
.total-val { font-family: ${statsFont}; fill: ${accent}; font-size: ${fs(24)}px; font-weight: 500; }
.label { font-family: "Roboto", sans-serif; fill: ${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; } }
</style>`;
}
Expand Down Expand Up @@ -206,6 +207,79 @@ function renderFooter(
</rect>`;
}

const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];

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 coords = projectIsometric(label.col, MONTH_LABEL_ROW_OFFSET);
const tx = s(coords.x + 8);
const ty = s(coords.y + ISOMETRIC_VERTICAL_OFFSET) + Math.round(20 * sf);
elements += `
<text x="${tx}" y="${ty}" text-anchor="middle" fill="${labelColorHex}" class="isometric-label">${label.text}</text>`;
});

const weekdays = [
{ text: 'Mon', row: 1 },
{ text: 'Wed', row: 3 },
{ text: 'Fri', row: 5 },
];

weekdays.forEach((day) => {
const coords = projectIsometric(WEEKDAY_LABEL_COL_OFFSET, day.row);
const tx = s(coords.x);
const ty = s(coords.y + ISOMETRIC_VERTICAL_OFFSET) + Math.round(20 * sf);
elements += `
<text x="${tx}" y="${ty}" text-anchor="end" fill="${labelColorHex}" class="isometric-label">${day.text}</text>`;
});

return `<g class="isometric-labels">${elements}</g>`;
}

// ── Main static-theme renderer ────────────────────────────────────────────

/**
Expand Down Expand Up @@ -285,6 +359,7 @@ export function generateSVG(
${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf)}
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" />
<g transform="translate(0, ${Math.round(20 * sf)})">${towers}</g>
${renderIsometricLabels(calendar, params, text, sf)}
${renderFooter(stats, params, labels, safeUser, accent, sf)}
</svg>`;
}
Expand Down Expand Up @@ -368,6 +443,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; } }
</style>
Expand All @@ -376,6 +452,7 @@ function generateAutoThemeSVG(
<g transform="translate(0, ${s(20)})">
${towers}
</g>
${renderIsometricLabels(calendar, params, 'var(--cp-text)', sf)}
${!params.hide_stats ? renderStatsSection(stats, labels, s) : ''}
${
!params.hide_title
Expand Down
20 changes: 18 additions & 2 deletions lib/svg/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,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.
*
Expand Down Expand Up @@ -134,9 +148,11 @@ export function computeTowers(
? `TODAY: ${day.date}: ${day.contributionCount} contributions`
: `${day.date}: ${day.contributionCount} contributions`;

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(day.contributionCount, scale, shouldShowGhostCity),
hasCommits,
isGhost,
Expand Down
8 changes: 8 additions & 0 deletions lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ export const streakParamsSchema = z.object({
return isNaN(parsed) ? 1 : Math.max(0, Math.min(parsed, 7));
})
.default(1),
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({
Expand Down
7 changes: 6 additions & 1 deletion types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ export interface BadgeParams {

/** Number of grace days before a streak resets (handles timezone edge cases). Defaults to 1. */
grace?: number;

/** Background fill color as a hex string WITHOUT the leading '#'. Overrides theme default. */
bg: HexColor;

Expand Down Expand Up @@ -148,4 +147,10 @@ export interface BadgeParams {

/** Preset size of the badge. 'small', 'medium', or 'large'. Overrides width and height. */
size?: BadgeSize;

/** When true, renders optional 3D isometric month headers and weekday labels. */
labels?: boolean;

/** Custom text color for the labels. Overrides text parameter. */
labelColor?: HexColor;
}
Loading