Skip to content

Commit 4901128

Browse files
authored
feat: add dynamic contribution shading and volumetric gradients (JhaSourav07#808)
## Description Fixes JhaSourav07#793 This PR implements premium dynamic contribution-level color shading, multi-accent color mapping, and volumetric tower gradients on the 3D contribution monolith. ## Changes Made - **API Parameters:** Added `shading`, `gradient`, and array-support for `accent` parameters in `BadgeParams` and `streakParamsSchema`. - **Tower Math:** Integrated quartile calculation logic (1 to 4) inside `computeTowers` based on maximum commit counts. - **Volumetric Gradients:** Added dynamic SVG `<linearGradient>` injection fading upward from the background color to the tower's level-specific accent color. Left and right faces use the gradient fill, while the top cap retains a solid glow. - **Multi-accent Mapping:** Allowed comma-separated color lists under the `accent` parameter mapping directly to quartile levels 1-4. - **Safety Refactors:** Resolved array mapping in `generateMonthlySVG` to safe fallback strings. - **Testing:** Added 4 comprehensive test suites in `lib/svg/generator.test.ts` covering shading, disabling shading, gradients, and multi-accent arrays. ## Pillar - [x] 🎨 Pillar 1 — New Theme Design - [x] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [ ] 🛠️ Other ## Visual Preview - Flat Shading (`shading=true`): Towers fade into the floor for lower-intensity contribution days. - Volumetric Gradients (`gradient=true`): Sleek vertical linear gradients fading from the background up to the tower tops. ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [x] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support. ### Multi-accent quartile coloring (`accent=ff4444,ff8800,ffcc00,00ffaa`) <img width="768" height="541" alt="image" src="https://github.com/user-attachments/assets/d6b72f3e-0ed3-48e3-8d9f-c97549d8147d" /> ### Gradient volumetric effect (`gradient=true`) <img width="767" height="534" alt="image" src="https://github.com/user-attachments/assets/b81776ba-5a9f-4816-879e-989529d17ddd" /> ### Combined (`gradient=true&accent=ff4444,ff8800,ffcc00,00ffaa`) <img width="749" height="526" alt="image" src="https://github.com/user-attachments/assets/89338fc9-8901-46f3-9f19-971d5669bf8e" />
1 parent 5a7929c commit 4901128

8 files changed

Lines changed: 398 additions & 54 deletions

File tree

app/api/streak/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export async function GET(request: Request) {
6262
labels,
6363
labelColor,
6464
versus,
65+
shading,
66+
gradient,
6567
} = parseResult.data;
6668

6769
const themeName = theme || 'dark';
@@ -133,6 +135,8 @@ export async function GET(request: Request) {
133135
labels,
134136
labelColor,
135137
versus,
138+
shading,
139+
gradient,
136140
};
137141

138142
let calendar;
@@ -204,7 +208,13 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo
204208
message.toLowerCase().includes('could not resolve');
205209

206210
const errBg = `#${(parseResult.success && parseResult.data.bg) || '0d1117'}`;
207-
const errAccent = `#${(parseResult.success && parseResult.data.accent) || '58a6ff'}`;
211+
const errAccent = `#${
212+
(parseResult.success &&
213+
(Array.isArray(parseResult.data.accent)
214+
? parseResult.data.accent[parseResult.data.accent.length - 1]
215+
: parseResult.data.accent)) ||
216+
'58a6ff'
217+
}`;
208218
const errText = `#${(parseResult.success && parseResult.data.text) || 'c9d1d9'}`;
209219
const errRadius = parseResult.success
210220
? (() => {

components/dashboard/ComparisonStatsCard.test.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,6 @@ describe('ComparisonStatsCard', () => {
138138
/>
139139
);
140140

141-
const progressSegments = container.querySelectorAll(
142-
'.w-full.bg-gray-700\\/50 div, .relative div'
143-
);
144-
145-
const allDivs = Array.from(container.querySelectorAll('div'));
146-
147141
const emeraldElement =
148142
container.querySelector('[className*="emerald"]') ||
149143
container.querySelector('.text-emerald-400');

lib/export3d.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe('generateMonolithSTL', () => {
2121
strokeWidth: 1,
2222
row: 0,
2323
col: 0,
24+
intensityLevel: 2,
2425
},
2526
{
2627
x: 0,
@@ -37,6 +38,7 @@ describe('generateMonolithSTL', () => {
3738
strokeWidth: 1,
3839
row: 1,
3940
col: 1,
41+
intensityLevel: 0,
4042
},
4143
];
4244

@@ -67,6 +69,7 @@ it('generates structurally valid ASCII STL facets', () => {
6769
strokeWidth: 1,
6870
row: 0,
6971
col: 0,
72+
intensityLevel: 2,
7073
},
7174
];
7275

lib/svg/generator.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,158 @@ describe('generateMonthlySVG', () => {
811811
});
812812
});
813813

814+
describe('shading and gradients', () => {
815+
const mockStats: StreakStats = {
816+
currentStreak: 5,
817+
longestStreak: 10,
818+
totalContributions: 100,
819+
todayDate: '2024-06-12',
820+
};
821+
822+
const mockCalendar = {
823+
weeks: [
824+
{
825+
contributionDays: [
826+
{ contributionCount: 0, date: '2024-06-10' },
827+
{ contributionCount: 5, date: '2024-06-11' },
828+
{ contributionCount: 15, date: '2024-06-12' },
829+
],
830+
},
831+
],
832+
} as ContributionCalendar;
833+
834+
it('renders linearGradient definitions when gradient=true', () => {
835+
const svg = generateSVG(
836+
mockStats,
837+
{ user: 'avi', gradient: true } as unknown as BadgeParams,
838+
mockCalendar
839+
);
840+
expect(svg).toContain('<linearGradient id="tower-grad-level-1"');
841+
expect(svg).toContain('<linearGradient id="tower-grad-level-4"');
842+
expect(svg).toContain('fill="url(#tower-grad-level-');
843+
});
844+
845+
it('does not render linearGradient definitions when gradient=false', () => {
846+
const svg = generateSVG(
847+
mockStats,
848+
{ user: 'avi', gradient: false } as unknown as BadgeParams,
849+
mockCalendar
850+
);
851+
expect(svg).not.toContain('<linearGradient id="tower-grad-level-1"');
852+
});
853+
854+
it('supports mapping colors in array accent lists to towers', () => {
855+
const calendarWithAllQuartiles = {
856+
weeks: [
857+
{
858+
contributionDays: [
859+
{ contributionCount: 2, date: '2024-06-10' }, // Level 1 (2/15 <= 0.25)
860+
{ contributionCount: 6, date: '2024-06-11' }, // Level 2 (6/15 <= 0.5)
861+
{ contributionCount: 10, date: '2024-06-12' }, // Level 3 (10/15 <= 0.75)
862+
{ contributionCount: 15, date: '2024-06-13' }, // Level 4
863+
],
864+
},
865+
],
866+
} as ContributionCalendar;
867+
868+
const svg = generateSVG(
869+
mockStats,
870+
{ user: 'avi', accent: ['111111', '222222', '333333', '444444'] } as unknown as BadgeParams,
871+
calendarWithAllQuartiles
872+
);
873+
expect(svg).toContain('fill="#111111"');
874+
expect(svg).toContain('fill="#222222"');
875+
expect(svg).toContain('fill="#333333"');
876+
expect(svg).toContain('fill="#444444"');
877+
});
878+
879+
it('gracefully handles and clamps accent color arrays with fewer than 4 items without crashing', () => {
880+
const calendarWithAllQuartiles = {
881+
weeks: [
882+
{
883+
contributionDays: [
884+
{ contributionCount: 2, date: '2024-06-10' },
885+
{ contributionCount: 6, date: '2024-06-11' },
886+
{ contributionCount: 10, date: '2024-06-12' },
887+
{ contributionCount: 15, date: '2024-06-13' },
888+
],
889+
},
890+
],
891+
} as ContributionCalendar;
892+
893+
const svg = generateSVG(
894+
mockStats,
895+
{ user: 'avi', accent: ['111111'] } as unknown as BadgeParams,
896+
calendarWithAllQuartiles
897+
);
898+
expect(svg).toContain('fill="#111111"');
899+
});
900+
});
901+
902+
describe('shading', () => {
903+
// A calendar with a mix of contribution counts to produce towers at different
904+
// intensity levels — needed so that shading multipliers actually apply.
905+
const shadingCalendar = {
906+
weeks: [
907+
{
908+
contributionDays: [
909+
{ contributionCount: 2, date: '2024-06-10' }, // low intensity
910+
{ contributionCount: 10, date: '2024-06-11' }, // mid intensity
911+
{ contributionCount: 15, date: '2024-06-12' }, // high intensity
912+
],
913+
},
914+
],
915+
} as ContributionCalendar;
916+
917+
const shadingStats: StreakStats = {
918+
currentStreak: 3,
919+
longestStreak: 10,
920+
totalContributions: 27,
921+
todayDate: '2024-06-12',
922+
};
923+
924+
it('applies reduced face-opacity (shading) when shading is not disabled', () => {
925+
// With shading on, low-intensity towers use opacity multiplier 0.4, so their
926+
// face-opacity should be lower than the unshaded base value.
927+
const svgShading = generateSVG(
928+
shadingStats,
929+
{ user: 'avi', shading: true } as unknown as BadgeParams,
930+
shadingCalendar
931+
);
932+
// The shaded SVG should still contain the tower paths
933+
expect(svgShading).toContain('class="cp-tower"');
934+
// For level 1 (mult=0.4), base top face opacity 0.7 becomes 0.28
935+
// Check for that specific derived value to ensure shading actually multiplied it.
936+
expect(svgShading).toContain('fill-opacity="0.28"');
937+
});
938+
939+
it('does not apply shading multipliers when shading=false', () => {
940+
const svgShading = generateSVG(
941+
shadingStats,
942+
{ user: 'avi', shading: true } as unknown as BadgeParams,
943+
shadingCalendar
944+
);
945+
const svgNoShading = generateSVG(
946+
shadingStats,
947+
{ user: 'avi', shading: false } as unknown as BadgeParams,
948+
shadingCalendar
949+
);
950+
// The two renders must differ — shading changes face opacities
951+
expect(svgShading).not.toBe(svgNoShading);
952+
});
953+
954+
it('falls back to default accent #00ffaa when accent array is empty', () => {
955+
const svg = generateSVG(
956+
shadingStats,
957+
// Simulate what validation returns for accent=,,, (empty array is
958+
// now normalised to undefined, but if it somehow reached the renderer
959+
// as [] the fallback should still fire without crashing).
960+
{ user: 'avi', accent: [] } as unknown as BadgeParams,
961+
shadingCalendar
962+
);
963+
expect(svg).toContain('00ffaa');
964+
});
965+
});
814966
describe('escapeXML', () => {
815967
it('escapes ampersands (&)', () => {
816968
expect(escapeXML('foo & bar')).toBe('foo &amp; bar');

0 commit comments

Comments
 (0)