Skip to content

Commit 4960e6c

Browse files
authored
feat(generator): add ?opacity= URL parameter for global tower fill-opacity control (JhaSourav07#2088)
## What does this PR do? Adds a new `?opacity=` URL parameter that applies a global scalar multiplier to all tower face fill-opacity values in the isometric monolith. This gives users control over tower density for different embedding contexts without requiring a fork. ## Pillar - [x] 📐 Pillar 2 — Geometric SVG Improvement ## Fixes Closes JhaSourav07#1936 ## How it works The opacity scalar is multiplied against each tower face's existing `faceOpacity.left`, `faceOpacity.right`, and `faceOpacity.top` values inside `renderTowers()`. The result is rounded to 2 decimal places to avoid floating-point noise in the SVG output. ## Parameter spec | Value | Behavior | |-------|----------| | `opacity=1.0` | Default — fully opaque (current behavior, backward compatible) | | `opacity=0.8` | Slightly faded, great on light background embeds | | `opacity=0.5` | Semi-transparent ghost city look | | `opacity=0.1` | Near-invisible wireframe, minimum clamp | | `opacity=2.5` | Clamped to 1.0 | | `opacity=abc` | Falls back to 1.0 (default) | ## Visual Preview Default (opacity=1.0): ![default](https://commitpulse.vercel.app/api/streak?user=jhasourav07) Semi-transparent (opacity=0.5): ![opacity 0.5](https://commitpulse.vercel.app/api/streak?user=jhasourav07&opacity=0.5) ## Changes | File | Change | |------|--------| | `types/index.ts` | Added `opacity?: number` to `BadgeParams` | | `lib/validations.ts` | Added `toOpacityValue()`, added `opacity` field to `streakParamsSchema` | | `lib/svg/generator.ts` | Added `opacity` param to `renderTowers()`, applied scalar to all face opacities, passed through from all 3 SVG generators | | `app/api/streak/route.ts` | Destructured `opacity` from validated params and passed into `BadgeParams` | | `README.md` | Added `opacity` row to parameter table, added 2 example URLs | | `lib/svg/generator.opacity.test.ts` | 16 new tests covering scalar application, auto-theme, clamping, rounding, and schema validation | ## Backward compatibility - Default is `1.0` — existing badge URLs are completely unaffected - No changes to any existing parameter behavior - All 819 existing tests continue to pass ✅ ## Checklist - [x] npm run test passes locally - [x] npm run test:coverage passes (≥ 70% branch coverage) - [x] npm run lint passes locally - [x] npm run format passes locally - [x] README.md updated with new parameter - [x] Fully backward compatible — default opacity=1.0 produces identical output - [x] My commits follow the Conventional Commits format
2 parents b818618 + b13a24c commit 4960e6c

6 files changed

Lines changed: 274 additions & 7 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ URL Parameter > Theme Default > System Fallback
189189
| `labelColor` | `hex` | No || Custom text color for the isometric labels — **without** `#` |
190190
| `versus` | `string` | No || GitHub username of an opponent to compare against in side-by-side versus mode |
191191
| `shading` | `boolean` | No | `false` | Apply intensity-based opacity shading to tower faces so lower intensity levels appear slightly dimmer |
192+
| `opacity` | `number` | No | `1.0` | Global opacity scalar for all tower fill-opacity values (0.1–1.0). `opacity=0.5` = semi-transparent ghost look. `opacity=0.8` = faded, great on light backgrounds. |
192193
| `gradient` | `boolean` | No | `false` | Opt-in to show volumetric gradients on the monolith floor |
193194

194195
### Grace Period Examples
@@ -322,6 +323,14 @@ Explore some of the built-in CommitPulse themes and quickly copy the style you l
322323

323324
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&gradient=true&shading=true)
324325

326+
<!-- Semi-transparent ghost city look -->
327+
328+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&opacity=0.5)
329+
330+
<!-- Slightly faded — perfect for light background embeds -->
331+
332+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&opacity=0.8)
333+
325334
<!-- GitHub-style Heatmap View -->
326335

327336
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&view=heatmap)

app/api/streak/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export async function GET(request: Request) {
9090
versus,
9191
shading,
9292
gradient,
93+
opacity,
9394
tz: tzParam,
9495
disable_particles,
9596
glow,
@@ -163,6 +164,7 @@ export async function GET(request: Request) {
163164
versus,
164165
shading,
165166
gradient,
167+
opacity,
166168
disable_particles,
167169
glow,
168170
animate,

lib/svg/generator.opacity.test.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
// lib/svg/generator.opacity.test.ts
2+
// Tests for the ?opacity= URL parameter — Issue #
3+
4+
import { describe, it, expect } from 'vitest';
5+
import { generateSVG } from './generator';
6+
import type { BadgeParams, ContributionCalendar, StreakStats } from '../../types';
7+
8+
const mockStats: StreakStats = {
9+
currentStreak: 5,
10+
longestStreak: 10,
11+
totalContributions: 100,
12+
todayDate: '2024-06-12',
13+
};
14+
15+
const mockCalendar: ContributionCalendar = {
16+
totalContributions: 100,
17+
weeks: [
18+
{
19+
contributionDays: [
20+
{ contributionCount: 3, date: '2024-06-10' },
21+
{ contributionCount: 8, date: '2024-06-11' },
22+
{ contributionCount: 5, date: '2024-06-12' },
23+
],
24+
},
25+
],
26+
};
27+
28+
describe('?opacity= parameter', () => {
29+
it('defaults to fully opaque (fill-opacity values unchanged) when opacity is omitted', () => {
30+
const svgDefault = generateSVG(
31+
mockStats,
32+
{ user: 'chetan' } as unknown as BadgeParams,
33+
mockCalendar
34+
);
35+
const svgExplicit1 = generateSVG(
36+
mockStats,
37+
{ user: 'chetan', opacity: 1.0 } as unknown as BadgeParams,
38+
mockCalendar
39+
);
40+
// Both should produce identical output
41+
expect(svgDefault).toBe(svgExplicit1);
42+
});
43+
44+
it('applies opacity=0.5 scalar — fill-opacity values are halved', () => {
45+
const svg = generateSVG(
46+
mockStats,
47+
{ user: 'chetan', opacity: 0.5 } as unknown as BadgeParams,
48+
mockCalendar
49+
);
50+
// With opacity=0.5, tower fill-opacity should not exceed 0.5
51+
// (0.7 * 0.5 = 0.35 max for top face)
52+
const towerMatches = [
53+
...svg.matchAll(/<path[^>]*d="M0[^"]*"[^>]*fill-opacity="([\d.]+)"[^>]*>/g),
54+
];
55+
const towerFaces = towerMatches.filter((match) => !match[0].includes('fill="white"'));
56+
expect(towerFaces.length).toBeGreaterThan(0);
57+
for (const match of towerFaces) {
58+
expect(parseFloat(match[1])).toBeLessThanOrEqual(0.5);
59+
}
60+
});
61+
62+
it('opacity=1.0 produces fill-opacity values identical to the default', () => {
63+
const svgDefault = generateSVG(
64+
mockStats,
65+
{ user: 'chetan' } as unknown as BadgeParams,
66+
mockCalendar
67+
);
68+
const svgOpacity1 = generateSVG(
69+
mockStats,
70+
{ user: 'chetan', opacity: 1.0 } as unknown as BadgeParams,
71+
mockCalendar
72+
);
73+
expect(svgDefault).toBe(svgOpacity1);
74+
});
75+
76+
it('opacity=0.8 produces fill-opacity values all at or below 0.8', () => {
77+
const svg = generateSVG(
78+
mockStats,
79+
{ user: 'chetan', opacity: 0.8 } as unknown as BadgeParams,
80+
mockCalendar
81+
);
82+
// Tower fill-opacity values should respect opacity parameter
83+
const towerMatches = [
84+
...svg.matchAll(/<path[^>]*d="M0[^"]*"[^>]*fill-opacity="([\d.]+)"[^>]*>/g),
85+
];
86+
const towerFaces = towerMatches.filter((match) => !match[0].includes('fill="white"'));
87+
expect(towerFaces.length).toBeGreaterThan(0);
88+
for (const match of towerFaces) {
89+
expect(parseFloat(match[1])).toBeLessThanOrEqual(0.8);
90+
}
91+
});
92+
93+
it('opacity=0.1 (minimum) produces very low fill-opacity values', () => {
94+
const svg = generateSVG(
95+
mockStats,
96+
{ user: 'chetan', opacity: 0.1 } as unknown as BadgeParams,
97+
mockCalendar
98+
);
99+
// Find tower fill-opacity values (in <path> elements with fill-opacity)
100+
// The towers should have opacity values <= 0.1 (0.7 * 0.1 = 0.07 max for top face)
101+
const towerMatches = [
102+
...svg.matchAll(/<path[^>]*d="M0[^"]*"[^>]*fill-opacity="([\d.]+)"[^>]*>/g),
103+
];
104+
const towerFaces = towerMatches.filter((match) => !match[0].includes('fill="white"'));
105+
expect(towerFaces.length).toBeGreaterThan(0);
106+
for (const match of towerFaces) {
107+
expect(parseFloat(match[1])).toBeLessThanOrEqual(0.1);
108+
}
109+
});
110+
111+
it('different opacity values produce different SVG output', () => {
112+
const svgFull = generateSVG(
113+
mockStats,
114+
{ user: 'chetan', opacity: 1.0 } as unknown as BadgeParams,
115+
mockCalendar
116+
);
117+
const svgHalf = generateSVG(
118+
mockStats,
119+
{ user: 'chetan', opacity: 0.5 } as unknown as BadgeParams,
120+
mockCalendar
121+
);
122+
// Different opacity should produce different SVG (tower fill-opacity values differ)
123+
expect(svgFull).not.toBe(svgHalf);
124+
// Verify the tower opacities are actually different
125+
const towerMatches1 = [
126+
...svgFull.matchAll(/<path[^>]*d="M0[^"]*"[^>]*fill-opacity="([\d.]+)"/g),
127+
];
128+
const towerMatches2 = [
129+
...svgHalf.matchAll(/<path[^>]*d="M0[^"]*"[^>]*fill-opacity="([\d.]+)"/g),
130+
];
131+
expect(towerMatches1.length).toBeGreaterThan(0);
132+
expect(towerMatches2.length).toBeGreaterThan(0);
133+
// The average opacity should be higher for full opacity
134+
const avg1 = towerMatches1.reduce((sum, m) => sum + parseFloat(m[1]), 0) / towerMatches1.length;
135+
const avg2 = towerMatches2.reduce((sum, m) => sum + parseFloat(m[1]), 0) / towerMatches2.length;
136+
expect(avg1).toBeGreaterThan(avg2);
137+
});
138+
139+
it('opacity works correctly in auto-theme mode', () => {
140+
const svg = generateSVG(
141+
mockStats,
142+
{ user: 'chetan', opacity: 0.5, autoTheme: true } as unknown as BadgeParams,
143+
mockCalendar
144+
);
145+
expect(svg).toContain('--cp-bg');
146+
// Check tower fill-opacity values
147+
const towerMatches = [
148+
...svg.matchAll(/<path[^>]*d="M0[^"]*"[^>]*fill-opacity="([\d.]+)"[^>]*>/g),
149+
];
150+
const towerFaces = towerMatches.filter((match) => !match[0].includes('fill="white"'));
151+
for (const match of towerFaces) {
152+
expect(parseFloat(match[1])).toBeLessThanOrEqual(0.5);
153+
}
154+
});
155+
156+
it('opacity is rounded to 2 decimal places to avoid floating point noise', () => {
157+
const svg = generateSVG(
158+
mockStats,
159+
{ user: 'chetan', opacity: 0.333 } as unknown as BadgeParams,
160+
mockCalendar
161+
);
162+
// fill-opacity values should not have more than 2 decimal places
163+
const towerMatches = [
164+
...svg.matchAll(/<path[^>]*d="M0[^"]*"[^>]*fill-opacity="([\d.]+)"[^>]*>/g),
165+
];
166+
const towerFaces = towerMatches.filter((match) => !match[0].includes('fill="white"'));
167+
for (const match of towerFaces) {
168+
const val = match[1];
169+
const decimalPlaces = val.includes('.') ? val.split('.')[1].length : 0;
170+
expect(decimalPlaces).toBeLessThanOrEqual(2);
171+
}
172+
});
173+
});
174+
175+
describe('toOpacityValue validation', () => {
176+
it('returns 1.0 when opacity param is missing', async () => {
177+
const { streakParamsSchema } = await import('../validations');
178+
const result = streakParamsSchema.safeParse({ user: 'chetan' });
179+
expect(result.success).toBe(true);
180+
if (result.success) expect(result.data.opacity).toBe(1.0);
181+
});
182+
183+
it('parses valid opacity=0.5 correctly', async () => {
184+
const { streakParamsSchema } = await import('../validations');
185+
const result = streakParamsSchema.safeParse({ user: 'chetan', opacity: '0.5' });
186+
expect(result.success).toBe(true);
187+
if (result.success) expect(result.data.opacity).toBe(0.5);
188+
});
189+
190+
it('clamps opacity below 0.1 to 0.1', async () => {
191+
const { streakParamsSchema } = await import('../validations');
192+
const result = streakParamsSchema.safeParse({ user: 'chetan', opacity: '0.0' });
193+
expect(result.success).toBe(true);
194+
if (result.success) expect(result.data.opacity).toBe(0.1);
195+
});
196+
197+
it('clamps opacity above 1.0 to 1.0', async () => {
198+
const { streakParamsSchema } = await import('../validations');
199+
const result = streakParamsSchema.safeParse({ user: 'chetan', opacity: '2.5' });
200+
expect(result.success).toBe(true);
201+
if (result.success) expect(result.data.opacity).toBe(1.0);
202+
});
203+
204+
it('returns 1.0 for non-numeric opacity value', async () => {
205+
const { streakParamsSchema } = await import('../validations');
206+
const result = streakParamsSchema.safeParse({ user: 'chetan', opacity: 'abc' });
207+
expect(result.success).toBe(true);
208+
if (result.success) expect(result.data.opacity).toBe(1.0);
209+
});
210+
211+
it('accepts opacity=1.0 exactly', async () => {
212+
const { streakParamsSchema } = await import('../validations');
213+
const result = streakParamsSchema.safeParse({ user: 'chetan', opacity: '1.0' });
214+
expect(result.success).toBe(true);
215+
if (result.success) expect(result.data.opacity).toBe(1.0);
216+
});
217+
218+
it('accepts opacity=0.1 exactly (minimum boundary)', async () => {
219+
const { streakParamsSchema } = await import('../validations');
220+
const result = streakParamsSchema.safeParse({ user: 'chetan', opacity: '0.1' });
221+
expect(result.success).toBe(true);
222+
if (result.success) expect(result.data.opacity).toBe(0.1);
223+
});
224+
225+
it('accepts negative opacity and clamps to 0.1', async () => {
226+
const { streakParamsSchema } = await import('../validations');
227+
const result = streakParamsSchema.safeParse({ user: 'chetan', opacity: '-0.5' });
228+
expect(result.success).toBe(true);
229+
if (result.success) expect(result.data.opacity).toBe(0.1);
230+
});
231+
});

lib/svg/generator.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ function renderTowers(
279279
text: string,
280280
sf: number,
281281
isAutoTheme: boolean = false,
282+
opacity: number = 1.0,
282283
animate: boolean = true
283284
): string {
284285
let towers = '';
@@ -316,9 +317,10 @@ function renderTowers(
316317
topFillAttr = leftRightFillAttr;
317318
}
318319

319-
let leftFaceOpacity = t.faceOpacity.left;
320-
let rightFaceOpacity = t.faceOpacity.right;
321-
let topFaceOpacity = t.faceOpacity.top;
320+
// opacity scalar: clamp 0.1–1.0, applied globally to all tower faces
321+
let leftFaceOpacity = Math.round(t.faceOpacity.left * opacity * 100) / 100;
322+
let rightFaceOpacity = Math.round(t.faceOpacity.right * opacity * 100) / 100;
323+
let topFaceOpacity = Math.round(t.faceOpacity.top * opacity * 100) / 100;
322324

323325
if (!isGhost && t.intensityLevel > 0 && params.shading === true) {
324326
const mult = opacityMultipliers[t.intensityLevel - 1];
@@ -566,7 +568,16 @@ export function generateSVG(
566568
computeTowers(calendar, params.scale, stats.todayDate, params.mode),
567569
sf
568570
);
569-
const towers = renderTowers(towerData, params, accent, text, sf, false, animate);
571+
const towers = renderTowers(
572+
towerData,
573+
params,
574+
accent,
575+
text,
576+
sf,
577+
false,
578+
params.opacity ?? 1.0,
579+
animate
580+
);
570581

571582
const mainAccent = Array.isArray(accent)
572583
? accent[accent.length - 1] || '00ffaa'
@@ -618,7 +629,7 @@ function generateAutoThemeSVG(
618629
computeTowers(calendar, params.scale, stats.todayDate, params.mode),
619630
sf
620631
);
621-
const towers = renderTowers(towerData, params, '', '', sf, true);
632+
const towers = renderTowers(towerData, params, '', '', sf, true, params.opacity ?? 1.0);
622633

623634
const s = createScaler(sf);
624635
const fs = (n: number): number => Math.round(n * sf * 10) / 10;
@@ -1884,8 +1895,8 @@ export function generateVersusSVG(
18841895
sf
18851896
);
18861897

1887-
const towers1 = renderTowers(towerData1, params, accent, text, sf, false);
1888-
const towers2 = renderTowers(towerData2, params, accent, text, sf, false);
1898+
const towers1 = renderTowers(towerData1, params, accent, text, sf, false, params.opacity ?? 1.0);
1899+
const towers2 = renderTowers(towerData2, params, accent, text, sf, false, params.opacity ?? 1.0);
18891900

18901901
const s = createScaler(sf);
18911902
const unit = params.mode === 'loc' ? 'lines of code' : 'total contributions';

lib/validations.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export function toGraceValue(val?: string): number {
3737
return isNaN(parsed) ? 1 : Math.max(0, Math.min(parsed, 7));
3838
}
3939

40+
export function toOpacityValue(val?: string): number {
41+
if (!val) return 1.0;
42+
const parsed = parseFloat(val);
43+
return isNaN(parsed) ? 1.0 : Math.max(0.1, Math.min(parsed, 1.0));
44+
}
45+
4046
export function toDimensionValue(val?: string): number | undefined {
4147
return val === undefined ? undefined : Number(val);
4248
}
@@ -218,6 +224,7 @@ const baseStreakParamsSchema = z.object({
218224
width: dimensionParam('width', 100, 1200),
219225
height: dimensionParam('height', 80, 800),
220226
grace: z.string().optional().transform(toGraceValue).default(1),
227+
opacity: z.string().optional().transform(toOpacityValue).default(1.0),
221228
mode: z.enum(['commits', 'loc']).catch('commits').default('commits'),
222229
repo: z.string().optional(),
223230
org: z

types/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,13 @@ export interface BadgeParams {
209209
*/
210210
shading?: boolean;
211211

212+
/**
213+
* Global opacity scalar applied to all tower face fill-opacity values (0.1–1.0).
214+
* Default is 1.0 (fully opaque, current behavior). Values below 0.1 are clamped
215+
* to 0.1; values above 1.0 are clamped to 1.0.
216+
*/
217+
opacity?: number;
218+
212219
/** Opt-in to show volumetric gradients on the monolith floor. */
213220
gradient?: boolean;
214221

0 commit comments

Comments
 (0)