Skip to content

Commit ca4993d

Browse files
committed
feat: add graph endpoint for user activity visualization with customizable options
1 parent 110c248 commit ca4993d

5 files changed

Lines changed: 388 additions & 1 deletion

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,25 @@ Example:
6363
GET https://stats.pphat.top/languages?username=pphatdev&theme=default
6464
```
6565

66+
### GET /graph
67+
Returns an SVG activity graph for a user for a specific year or the last 365 days.
68+
69+
Required query params:
70+
- username
71+
72+
Optional query params:
73+
- theme
74+
- year (default: last 365 days)
75+
- bgColor
76+
- borderColor
77+
- textColor
78+
- titleColor
79+
80+
Example:
81+
```
82+
GET https://stats.pphat.top/graph?username=pphatdev&year=2024
83+
```
84+
6685
## Usage in README
6786

6887
Stats card:
@@ -80,6 +99,11 @@ Languages pie chart:
8099
![Top Languages](https://stats.pphat.top/languages?username=YOUR_USERNAME&type=pie)
81100
```
82101

102+
Activity graph:
103+
```markdown
104+
![Activity Graph](https://stats.pphat.top/graph?username=YOUR_USERNAME)
105+
```
106+
83107
## Example Themes
84108

85109
Use the `theme` query param. A few previews:

src/components/graph-renderer.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { ContributionGraphData, GraphCardOptions } from '../types.js';
2+
import { getTheme } from '../utils/themes.js';
3+
4+
export class GraphRenderer {
5+
// Cache static starfield to avoid regenerating
6+
private static readonly STARFIELD_CACHE = new Map<string, string>();
7+
8+
static readonly DIMENSIONS = { WIDTH: 1200, HEIGHT: 600 };
9+
10+
// Generate deterministic starfield based on theme (cached)
11+
private static getStarfield(width: number, height: number, textColor: string): string {
12+
const cacheKey = `${width}-${height}-${textColor}`;
13+
if (this.STARFIELD_CACHE.has(cacheKey)) {
14+
return this.STARFIELD_CACHE.get(cacheKey)!;
15+
}
16+
17+
let seed = 54321;
18+
const seededRandom = () => {
19+
seed = (seed * 9301 + 49297) % 233280;
20+
return seed / 233280;
21+
};
22+
23+
const stars = Array.from({ length: 40 }, () => {
24+
const x = Math.floor(seededRandom() * width);
25+
const y = Math.floor(seededRandom() * height);
26+
const r = (seededRandom() * 1 + 0.5).toFixed(1);
27+
const opacity = (seededRandom() * 0.5 + 0.2).toFixed(1);
28+
const duration = (seededRandom() * 3 + 2).toFixed(1);
29+
const delay = (seededRandom() * 5).toFixed(1);
30+
return `<circle cx="${x}" cy="${y}" r="${r}" fill="${textColor}" opacity="${opacity}">
31+
<animate attributeName="opacity" values="${opacity};${(parseFloat(opacity) * 0.3).toFixed(1)};${opacity}" dur="${duration}s" begin="${delay}s" repeatCount="indefinite" />
32+
</circle>`;
33+
}).join('');
34+
35+
this.STARFIELD_CACHE.set(cacheKey, stars);
36+
return stars;
37+
}
38+
39+
private static adjustColor(hex: string, percent: number): string {
40+
// Simple hex adjust
41+
try {
42+
const num = parseInt(hex.replace('#', ''), 16);
43+
const amt = Math.round(2.55 * percent);
44+
const R = (num >> 16) + amt;
45+
const G = (num >> 8 & 0x00FF) + amt;
46+
const B = (num & 0x0000FF) + amt;
47+
const final = (0x1000000 + (R < 255 ? R < 0 ? 0 : R : 255) * 0x10000 + (G < 255 ? G < 0 ? 0 : G : 255) * 0x100 + (B < 255 ? B < 0 ? 0 : B : 255)).toString(16).slice(1);
48+
return `#${final}`;
49+
} catch {
50+
return hex;
51+
}
52+
}
53+
54+
static generateGraphCard(data: ContributionGraphData, options: GraphCardOptions): string {
55+
const theme = getTheme(options.theme, {
56+
bgColor: options.bgColor,
57+
borderColor: options.borderColor,
58+
textColor: options.textColor,
59+
titleColor: options.titleColor,
60+
});
61+
62+
const width = this.DIMENSIONS.WIDTH;
63+
const height = this.DIMENSIONS.HEIGHT;
64+
const fontName = theme.fontName || 'Orbitron';
65+
const fontFamily = theme.fontFamily || `'${fontName}', 'Ubuntu', 'sans-serif'`;
66+
const fontUrl = theme.fontUrl || '/fonts/orbitron.woff2';
67+
const fontFace = fontUrl
68+
? `@font-face{font-family:'${fontName}';font-style:normal;font-weight:400 900;font-display:swap;src:url(${fontUrl})format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}`
69+
: '';
70+
71+
const levelColors = [
72+
this.adjustColor(theme.bgColor, 15), // Level 0
73+
this.adjustColor(theme.iconColor, -50), // Level 1
74+
this.adjustColor(theme.iconColor, -25), // Level 2
75+
theme.iconColor, // Level 3
76+
this.adjustColor(theme.iconColor, 25), // Level 4
77+
];
78+
79+
const cellSize = 14;
80+
const gap = 4;
81+
const gridWidth = data.weeks.length * (cellSize + gap);
82+
const gridHeight = 7 * (cellSize + gap);
83+
84+
const startX = (width - gridWidth) / 2;
85+
const startY = (height - gridHeight) / 2 + 60;
86+
87+
const stars = this.getStarfield(width, height, theme.textColor);
88+
89+
const cells = data.weeks.map((week, x) => {
90+
return week.map((day, y) => {
91+
const color = levelColors[day.level] || levelColors[0];
92+
const opacity = day.level === 0 ? 0.3 : 0.85;
93+
const xPos = (startX + x * (cellSize + gap)).toFixed(1);
94+
const yPos = (startY + y * (cellSize + gap)).toFixed(1);
95+
96+
if (day.level === 0 || options.animate === 'none') {
97+
return `<rect x="${xPos}" y="${yPos}" width="${cellSize}" height="${cellSize}" fill="${color}" rx="2" opacity="${opacity}" />`;
98+
}
99+
100+
let isAnimating = true;
101+
let animation = '';
102+
const duration = (2.0 + (x % 4) * 0.5).toFixed(1);
103+
const waveDelay = (x * 0.05 + y * 0.02).toFixed(2);
104+
const pulseDelay = ((x + y) * 0.04).toFixed(2);
105+
106+
if (options.animate === 'wave') {
107+
animation = `<animate attributeName="opacity" values="0.1;0.8;0.1" dur="3s" begin="${waveDelay}s" repeatCount="indefinite" />`;
108+
} else if (options.animate === 'pulse') {
109+
// Selection logic for 10-20 random cells across random columns
110+
const cellId = x * 7 + y;
111+
const seed = (cellId * 1337) % 1000;
112+
isAnimating = (seed % 22 === 0); // ~4.5% chance -> approx 16 cells for a full year
113+
114+
if (isAnimating) {
115+
const randomDur = (1.5 + (seed % 10) * 0.1).toFixed(1);
116+
const randomDelay = (seed % 20 * 0.1).toFixed(1);
117+
animation = `<animate attributeName="opacity" values="0.1;1;0.1" dur="${randomDur}s" begin="${randomDelay}s" repeatCount="indefinite" />`;
118+
}
119+
} else {
120+
// Default glow
121+
animation = `<animate attributeName="opacity" values="0.2;0.7;0.2" dur="${duration}s" begin="${(x * 0.04).toFixed(2)}s" repeatCount="indefinite" />`;
122+
}
123+
124+
if (!isAnimating) {
125+
return `<rect x="${xPos}" y="${yPos}" width="${cellSize}" height="${cellSize}" fill="${color}" rx="2" opacity="${opacity}" />`;
126+
}
127+
128+
return `
129+
<g>
130+
<rect x="${xPos}" y="${yPos}" width="${cellSize}" height="${cellSize}" fill="${color}" rx="2" opacity="0.4" filter="url(#glowSmall)">
131+
${animation}
132+
</rect>
133+
<rect x="${xPos}" y="${yPos}" width="${cellSize}" height="${cellSize}" fill="${color}" rx="2" opacity="${opacity}" />
134+
</g>`;
135+
}).join('');
136+
}).join('');
137+
138+
// Month labels
139+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
140+
const monthElements: string[] = [];
141+
let currentMonth = -1;
142+
143+
data.weeks.forEach((week, i) => {
144+
if (week[0]) {
145+
const date = new Date(week[0].date);
146+
const month = date.getMonth();
147+
if (month !== currentMonth) {
148+
currentMonth = month;
149+
const x = startX + i * (cellSize + gap);
150+
monthElements.push(`<text x="${x.toFixed(1)}" y="${(startY - 10).toFixed(1)}" fill="${theme.textColor}" font-size="10" opacity="0.5" font-family="${fontFamily}">${months[month]}</text>`);
151+
}
152+
}
153+
});
154+
155+
return `
156+
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" fill="none" xmlns="http://www.w3.org/2000/svg">
157+
<defs>
158+
<radialGradient id="spaceGradient" cx="50%" cy="50%">
159+
<stop offset="0%" style="stop-color:${theme.bgColor};stop-opacity:1" />
160+
<stop offset="60%" style="stop-color:#02030a;stop-opacity:1" />
161+
<stop offset="100%" style="stop-color:#000000;stop-opacity:1" />
162+
</radialGradient>
163+
<filter id="glowSmall" x="-100%" y="-100%" width="300%" height="300%">
164+
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
165+
<feComponentTransfer in="blur" result="brightBlur">
166+
<feFuncA type="linear" slope="1.5"/>
167+
</feComponentTransfer>
168+
<feMerge>
169+
<feMergeNode in="brightBlur"/>
170+
<feMergeNode in="SourceGraphic"/>
171+
</feMerge>
172+
</filter>
173+
<filter id="dividerGlow" x="-10%" y="-150%" width="120%" height="400%">
174+
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur"/>
175+
<feMerge>
176+
<feMergeNode in="blur"/>
177+
<feMergeNode in="SourceGraphic"/>
178+
</feMerge>
179+
</filter>
180+
<linearGradient id="glowSpot" x1="0%" y1="0%" x2="100%" y2="0%">
181+
<stop offset="0%" stop-color="${theme.titleColor}" stop-opacity="0"/>
182+
<stop offset="50%" stop-color="${theme.titleColor}" stop-opacity="1"/>
183+
<stop offset="100%" stop-color="${theme.titleColor}" stop-opacity="0"/>
184+
</linearGradient>
185+
<clipPath id="dividerClip">
186+
<rect x="${width / 2 - (width - 160) / 4}" y="108" width="${(width - 160) / 2}" height="8"/>
187+
</clipPath>
188+
<linearGradient id="dividerFade" x1="0%" y1="0%" x2="100%" y2="0%">
189+
<stop offset="0%" stop-color="${theme.iconColor}" stop-opacity="0"/>
190+
<stop offset="15%" stop-color="${theme.iconColor}" stop-opacity="0.2"/>
191+
<stop offset="85%" stop-color="${theme.iconColor}" stop-opacity="0.2"/>
192+
<stop offset="100%" stop-color="${theme.iconColor}" stop-opacity="0"/>
193+
</linearGradient>
194+
<filter id="textGlow" x="-20%" y="-60%" width="140%" height="220%">
195+
<feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="blur">
196+
<animate attributeName="stdDeviation" values="0.5;2.5;0.8;3;0.5;1.5;2;0.5" dur="9s" repeatCount="indefinite"/>
197+
</feGaussianBlur>
198+
<feMerge>
199+
<feMergeNode in="blur"/>
200+
<feMergeNode in="SourceGraphic"/>
201+
</feMerge>
202+
</filter>
203+
</defs>
204+
205+
<style>
206+
${fontFace}
207+
text { font-family: ${fontFamily}; }
208+
</style>
209+
210+
<rect width="100%" height="100%" fill="url(#spaceGradient)" />
211+
${stars}
212+
213+
<!-- Grid lines (subtle) -->
214+
<g opacity="0.12">
215+
${Array.from({ length: 24 }, (_, i) => {
216+
const x = ((i + 1) * (width / 24)) | 0;
217+
return `<line x1="${x}" y1="0" x2="${x}" y2="${height}" stroke="${theme.iconColor}" stroke-width="0.5"/>`;
218+
}).join('')}
219+
${Array.from({ length: 12 }, (_, i) => {
220+
const y = ((i + 1) * (height / 12)) | 0;
221+
return `<line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="${theme.iconColor}" stroke-width="0.5"/>`;
222+
}).join('')}
223+
</g>
224+
225+
<!-- Title -->
226+
<text x="50%" y="90" text-anchor="middle" fill="${theme.titleColor}" font-size="42" font-family="${fontFamily}" font-weight="700" style="filter: drop-shadow(0 0 12px ${theme.titleColor}66)">${data.username}'s Activity ${data.year}</text>
227+
228+
<text x="50%" y="125" text-anchor="middle" fill="${theme.textColor}" font-size="18" opacity="0.8" font-family="${fontFamily}" filter="url(#textGlow)">${data.totalContributions.toLocaleString()} total contributions</text>
229+
230+
<g>
231+
${cells}
232+
${monthElements.join('')}
233+
</g>
234+
235+
<!-- Legend -->
236+
<g transform="translate(${startX + gridWidth - 100}, ${startY + gridHeight + 25})">
237+
<text x="-35" y="10" fill="${theme.textColor}" font-size="10" opacity="0.5" font-family="${fontFamily}">Less</text>
238+
${levelColors.map((c, i) => `<rect x="${i * 15}" y="0" width="12" height="12" fill="${c}" rx="2" opacity="${i === 0 ? 0.3 : 0.9}" />`).join('')}
239+
<text x="${levelColors.length * 15 + 5}" y="10" fill="${theme.textColor}" font-size="10" opacity="0.5" font-family="${fontFamily}">More</text>
240+
</g>
241+
</svg>`;
242+
}
243+
}

src/controllers/graph.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Request, Response } from 'express';
2+
import { GitHubClient } from '../utils/github-client.js';
3+
import { GraphRenderer } from '../components/graph-renderer.js';
4+
5+
export class GraphController {
6+
private static githubClient: GitHubClient;
7+
private static cache: Map<string, { data: string; timestamp: number }>;
8+
private static CACHE_DURATION: number;
9+
10+
static routeDocs = {
11+
requiredParams: ['username'],
12+
optionalParams: [
13+
'theme',
14+
'year',
15+
'animate',
16+
'bgColor',
17+
'borderColor',
18+
'textColor',
19+
'titleColor'
20+
],
21+
payload: null,
22+
example: '/graph?username=pphatdev&animate=wave'
23+
};
24+
25+
static initialize(githubClient: GitHubClient, cache: Map<string, { data: string; timestamp: number }>, cacheDuration: number) {
26+
this.githubClient = githubClient;
27+
this.cache = cache;
28+
this.CACHE_DURATION = cacheDuration;
29+
}
30+
31+
static async getSvg(req: Request, res: Response) {
32+
try {
33+
const { username, theme = 'default', year, animate, bgColor, borderColor, textColor, titleColor } = req.query;
34+
35+
if (!username || typeof username !== 'string') {
36+
return res.status(400).send('Username is required');
37+
}
38+
39+
let from: string;
40+
let to: string;
41+
let cacheKeyExtra: string;
42+
let displayYear: string | number;
43+
44+
if (year) {
45+
const y = parseInt(year as string, 10);
46+
from = `${y}-01-01T00:00:00Z`;
47+
to = `${y}-12-31T23:59:59Z`;
48+
cacheKeyExtra = y.toString();
49+
displayYear = y;
50+
} else {
51+
const now = new Date();
52+
const oneYearAgo = new Date();
53+
oneYearAgo.setFullYear(now.getFullYear() - 1);
54+
55+
from = oneYearAgo.toISOString();
56+
to = now.toISOString();
57+
cacheKeyExtra = 'last-year';
58+
displayYear = `${oneYearAgo.getFullYear()}-${now.getFullYear()}`;
59+
}
60+
61+
const cacheKey = `graph-${username}-${theme}-${cacheKeyExtra}-${animate || ''}-${bgColor || ''}-${borderColor || ''}-${textColor || ''}-${titleColor || ''}`;
62+
const cached = GraphController.cache.get(cacheKey);
63+
if (cached && Date.now() - cached.timestamp < GraphController.CACHE_DURATION) {
64+
res.setHeader('Content-Type', 'image/svg+xml');
65+
res.setHeader('Cache-Control', 'public, max-age=600');
66+
return res.send(cached.data);
67+
}
68+
69+
const contributions = await GraphController.githubClient.fetchUserContributions(username, from, to, cacheKeyExtra);
70+
71+
const svg = GraphRenderer.generateGraphCard({ ...contributions, year: displayYear }, {
72+
theme: theme as string,
73+
animate: animate as 'none' | 'glow' | 'wave' | 'pulse' | undefined,
74+
bgColor: bgColor as string | undefined,
75+
borderColor: borderColor as string | undefined,
76+
textColor: textColor as string | undefined,
77+
titleColor: titleColor as string | undefined,
78+
});
79+
80+
GraphController.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
81+
82+
res.setHeader('Content-Type', 'image/svg+xml');
83+
res.setHeader('Cache-Control', 'public, max-age=600');
84+
res.send(svg);
85+
} catch (error) {
86+
console.error('Error generating graph:', error);
87+
res.status(500).send(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)