Skip to content

Commit ebe8a16

Browse files
refactor(svg): extract tower layout + animation helpers (JhaSourav07#244)
* refactor(svg): extract tower layout + animation helpers * fix(svg): use shared tower layout helper --------- Co-authored-by: Sourav Jha <souravkjha2007@gmail.com>
1 parent c31ced3 commit ebe8a16

3 files changed

Lines changed: 156 additions & 146 deletions

File tree

lib/svg/animations.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Shared animation CSS injected into both static and auto-theme SVG renderers.
2+
// Defined once here to avoid duplication — any curve/timing change only needs updating in one place.
3+
4+
// TOWER_BASE_Y: the vertical midpoint of the isometric ground floor diamond in local SVG space.
5+
// The diamond paths are drawn from y=0 (back vertex) to y=20 (front vertex), making y=10
6+
// the horizontal center line that acts as the visual ground level for each tower.
7+
// This value is used as the CSS transform-origin for the grow-up animation so towers
8+
// scale upward from their ground tile rather than from the SVG origin.
9+
const TOWER_BASE_Y = 10;
10+
11+
export const TOWER_ANIMATION_CSS = `
12+
.cp-tower {
13+
transform: scaleY(0);
14+
transform-origin: 0 ${TOWER_BASE_Y}px;
15+
animation: grow-up 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
16+
}
17+
@keyframes grow-up {
18+
from { transform: scaleY(0); }
19+
to { transform: scaleY(1); }
20+
}
21+
@media (prefers-reduced-motion: reduce) {
22+
.cp-tower { animation: none !important; transform: scaleY(1) !important; }
23+
}`;

lib/svg/generator.ts

Lines changed: 12 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,15 @@
11
import type { BadgeParams, ContributionCalendar, StreakStats } from '../../types';
22
import { getLabels } from '../i18n/badgeLabels';
33
import { AUTO_DARK_THEME, AUTO_LIGHT_THEME } from './themes';
4-
5-
// constants
6-
const GHOST_HEIGHT_PX = 4;
7-
const LOG_SCALE_MULTIPLIER = 12;
8-
const LINEAR_SCALE_MULTIPLIER = 5;
9-
const MAX_LOG_HEIGHT = 80;
10-
const MAX_LINEAR_HEIGHT = 50;
11-
12-
// TOWER_BASE_Y: the vertical midpoint of the isometric ground floor diamond in local SVG space.
13-
// The diamond paths are drawn from y=0 (back vertex) to y=20 (front vertex), making y=10
14-
// the horizontal center line that acts as the visual ground level for each tower.
15-
// This value is used as the CSS transform-origin for the grow-up animation so towers
16-
// scale upward from their ground tile rather than from the SVG origin.
17-
const TOWER_BASE_Y = 10;
18-
19-
// Shared animation CSS injected into both static and auto-theme SVG renderers.
20-
// Defined once here to avoid duplication — any curve/timing change only needs updating in one place.
21-
const TOWER_ANIMATION_CSS = `
22-
.cp-tower {
23-
transform: scaleY(0);
24-
transform-origin: 0 ${TOWER_BASE_Y}px;
25-
animation: grow-up 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
26-
}
27-
@keyframes grow-up {
28-
from { transform: scaleY(0); }
29-
to { transform: scaleY(1); }
30-
}
31-
@media (prefers-reduced-motion: reduce) {
32-
.cp-tower { animation: none !important; transform: scaleY(1) !important; }
33-
}`;
4+
import { TOWER_ANIMATION_CSS } from './animations';
5+
import { computeTowers, type TowerData } from './layout';
346

357
const FONT_MAP: Record<string, string> = {
368
jetbrains: '"JetBrains Mono", monospace',
379
fira: '"Fira Code", monospace',
3810
roboto: '"Roboto", sans-serif',
3911
};
4012

41-
// types
42-
/** Shared layout data for a single isometric tower. */
43-
interface FaceOpacity {
44-
left: number;
45-
right: number;
46-
top: number;
47-
}
48-
49-
interface TowerData {
50-
x: number;
51-
y: number;
52-
h: number;
53-
hasCommits: boolean;
54-
isGhost: boolean;
55-
isToday: boolean;
56-
isTodayWithCommits: boolean;
57-
tooltip: string;
58-
contributionCount: number;
59-
faceOpacity: FaceOpacity;
60-
strokeOpacity: number;
61-
strokeWidth: number;
62-
/** Grid position used to compute the staggered animation-delay (row + col) * offset */
63-
row: number;
64-
col: number;
65-
}
66-
6713
// helpers
6814
function getSizeScale(size?: 'small' | 'medium' | 'large'): number {
6915
if (size === 'small') return 400 / 600;
@@ -79,95 +25,15 @@ function deterministicRandom(seed: string): number {
7925
}
8026
return (hash >>> 0) / 4294967296;
8127
}
82-
function computeTowerHeight(
83-
count: number,
84-
scale: 'linear' | 'log',
85-
shouldShowGhostCity: boolean
86-
): number {
87-
if (count === 0 && shouldShowGhostCity) return GHOST_HEIGHT_PX;
88-
if (count === 0) return 0;
89-
return scale === 'log'
90-
? Math.min(Math.log2(count + 1) * LOG_SCALE_MULTIPLIER, MAX_LOG_HEIGHT)
91-
: Math.min(count * LINEAR_SCALE_MULTIPLIER, MAX_LINEAR_HEIGHT);
92-
}
93-
94-
function computeFaceOpacity(count: number, isGhostCityMode: boolean): FaceOpacity {
95-
if (isGhostCityMode) {
96-
return { left: 0, right: 0, top: 0.02 };
97-
}
98-
if (count === 0) {
99-
return { left: 0, right: 0, top: 0.02 };
100-
}
101-
return { left: 0.35, right: 0.21, top: 0.7 };
102-
}
10328

104-
/**
105-
* Computes tower positions and heights from the last 14 weeks of
106-
* contribution data. The layout math is identical for both the
107-
* static-theme and auto-theme rendering paths.
108-
*/
109-
function computeTowers(
110-
calendar: ContributionCalendar,
111-
scale: 'linear' | 'log',
112-
todayDate: string,
113-
sf: number = 1
114-
): TowerData[] {
115-
const weeks = calendar.weeks.slice(-14);
116-
const towers: TowerData[] = [];
117-
118-
// Calculate if the entire monolith is empty
119-
let totalVisibleContributions = 0;
120-
weeks.forEach((week) => {
121-
week.contributionDays.forEach((day) => {
122-
totalVisibleContributions += day.contributionCount;
123-
});
124-
});
125-
126-
const shouldShowGhostCity = totalVisibleContributions === 0;
127-
128-
// Pre-check: is todayDate present in the visible 14-week window?
129-
// If not (e.g. stale cache or todayDate outside the window), fall back to
130-
// marking the last visible day as "today" so the pulse always appears.
131-
const todayInWindow = weeks.some((w) => w.contributionDays.some((d) => d.date === todayDate));
132-
133-
weeks.forEach((week, i) => {
134-
week.contributionDays.forEach((day, j) => {
135-
// Use the caller-supplied local date so the pulse animation fires on the
136-
// correct tower for users in non-UTC timezones, not always the last UTC entry.
137-
const isToday =
138-
day.date === todayDate ||
139-
// Fallback: if todayDate isn't in the visible window, keep the old behaviour.
140-
(!todayInWindow && i === weeks.length - 1 && j === week.contributionDays.length - 1);
141-
const hasCommits = day.contributionCount > 0;
142-
const isGhost = !hasCommits && shouldShowGhostCity;
143-
const isTodayWithCommits = isToday && hasCommits;
144-
145-
const tooltip = isTodayWithCommits
146-
? `TODAY: ${day.date}: ${day.contributionCount} contributions`
147-
: `${day.date}: ${day.contributionCount} contributions`;
148-
149-
// If not ghost city and no commits, height is 0, so don't render face if not needed,
150-
// but we return 0 for height so it won't be visible.
151-
towers.push({
152-
x: Math.round((300 + (i - j) * 16) * sf),
153-
y: Math.round((120 + (i + j) * 9) * sf),
154-
h: computeTowerHeight(day.contributionCount, scale, shouldShowGhostCity) * sf,
155-
hasCommits,
156-
isGhost,
157-
isToday,
158-
isTodayWithCommits,
159-
tooltip,
160-
contributionCount: day.contributionCount,
161-
faceOpacity: computeFaceOpacity(day.contributionCount, shouldShowGhostCity),
162-
strokeOpacity: isGhost ? 0.3 : 0,
163-
strokeWidth: isGhost ? 0.5 : 0,
164-
row: i,
165-
col: j,
166-
});
167-
});
168-
});
169-
170-
return towers;
29+
function scaleTowerData(towerData: TowerData[], sf: number): TowerData[] {
30+
if (sf === 1) return towerData;
31+
return towerData.map((t) => ({
32+
...t,
33+
x: Math.round(t.x * sf),
34+
y: Math.round(t.y * sf),
35+
h: t.h * sf,
36+
}));
17137
}
17238

17339
export function escapeXML(str: string): string {
@@ -264,7 +130,7 @@ export function generateSVG(
264130

265131
const W = Math.round(600 * sf);
266132
const H = Math.round(420 * sf);
267-
const towerData = computeTowers(calendar, params.scale, stats.todayDate, sf);
133+
const towerData = scaleTowerData(computeTowers(calendar, params.scale, stats.todayDate), sf);
268134
let towers = '';
269135

270136
for (const t of towerData) {
@@ -388,7 +254,7 @@ function generateAutoThemeSVG(
388254

389255
const W = Math.round(600 * sf);
390256
const H = Math.round(420 * sf);
391-
const towerData = computeTowers(calendar, params.scale, stats.todayDate, sf);
257+
const towerData = scaleTowerData(computeTowers(calendar, params.scale, stats.todayDate), sf);
392258
let towers = '';
393259

394260
for (const t of towerData) {

lib/svg/layout.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { ContributionCalendar } from '../../types';
2+
3+
// constants
4+
const GHOST_HEIGHT_PX = 4;
5+
const LOG_SCALE_MULTIPLIER = 12;
6+
const LINEAR_SCALE_MULTIPLIER = 5;
7+
const MAX_LOG_HEIGHT = 80;
8+
const MAX_LINEAR_HEIGHT = 50;
9+
10+
/** Shared layout data for a single isometric tower. */
11+
export interface FaceOpacity {
12+
left: number;
13+
right: number;
14+
top: number;
15+
}
16+
17+
export interface TowerData {
18+
x: number;
19+
y: number;
20+
h: number;
21+
hasCommits: boolean;
22+
isGhost: boolean;
23+
isToday: boolean;
24+
isTodayWithCommits: boolean;
25+
tooltip: string;
26+
contributionCount: number;
27+
faceOpacity: FaceOpacity;
28+
strokeOpacity: number;
29+
strokeWidth: number;
30+
/** Grid position used to compute the staggered animation-delay (row + col) * offset */
31+
row: number;
32+
col: number;
33+
}
34+
35+
function computeTowerHeight(
36+
count: number,
37+
scale: 'linear' | 'log',
38+
shouldShowGhostCity: boolean
39+
): number {
40+
if (count === 0 && shouldShowGhostCity) return GHOST_HEIGHT_PX;
41+
if (count === 0) return 0;
42+
return scale === 'log'
43+
? Math.min(Math.log2(count + 1) * LOG_SCALE_MULTIPLIER, MAX_LOG_HEIGHT)
44+
: Math.min(count * LINEAR_SCALE_MULTIPLIER, MAX_LINEAR_HEIGHT);
45+
}
46+
47+
function computeFaceOpacity(count: number, isGhostCityMode: boolean): FaceOpacity {
48+
if (isGhostCityMode) {
49+
return { left: 0, right: 0, top: 0.02 };
50+
}
51+
if (count === 0) {
52+
return { left: 0, right: 0, top: 0.02 };
53+
}
54+
return { left: 0.35, right: 0.21, top: 0.7 };
55+
}
56+
57+
/**
58+
* Computes tower positions and heights from the last 14 weeks of
59+
* contribution data. The layout math is identical for both the
60+
* static-theme and auto-theme rendering paths.
61+
*/
62+
export function computeTowers(
63+
calendar: ContributionCalendar,
64+
scale: 'linear' | 'log' = 'linear',
65+
todayDate: string = ''
66+
): TowerData[] {
67+
const weeks = calendar.weeks.slice(-14);
68+
const towers: TowerData[] = [];
69+
70+
// Calculate if the entire monolith is empty
71+
let totalVisibleContributions = 0;
72+
weeks.forEach((week) => {
73+
week.contributionDays.forEach((day) => {
74+
totalVisibleContributions += day.contributionCount;
75+
});
76+
});
77+
78+
const shouldShowGhostCity = totalVisibleContributions === 0;
79+
80+
// Pre-check: is todayDate present in the visible 14-week window?
81+
// If not (e.g. stale cache or todayDate outside the window), fall back to
82+
// marking the last visible day as "today" so the pulse always appears.
83+
const todayInWindow = weeks.some((w) => w.contributionDays.some((d) => d.date === todayDate));
84+
85+
weeks.forEach((week, i) => {
86+
week.contributionDays.forEach((day, j) => {
87+
// Use the caller-supplied local date so the pulse animation fires on the
88+
// correct tower for users in non-UTC timezones, not always the last UTC entry.
89+
const isToday =
90+
day.date === todayDate ||
91+
// Fallback: if todayDate isn't in the visible window, keep the old behaviour.
92+
(!todayInWindow && i === weeks.length - 1 && j === week.contributionDays.length - 1);
93+
const hasCommits = day.contributionCount > 0;
94+
const isGhost = !hasCommits && shouldShowGhostCity;
95+
const isTodayWithCommits = isToday && hasCommits;
96+
97+
const tooltip = isTodayWithCommits
98+
? `TODAY: ${day.date}: ${day.contributionCount} contributions`
99+
: `${day.date}: ${day.contributionCount} contributions`;
100+
101+
towers.push({
102+
x: 300 + (i - j) * 16,
103+
y: 120 + (i + j) * 9,
104+
h: computeTowerHeight(day.contributionCount, scale, shouldShowGhostCity),
105+
hasCommits,
106+
isGhost,
107+
isToday,
108+
isTodayWithCommits,
109+
tooltip,
110+
contributionCount: day.contributionCount,
111+
faceOpacity: computeFaceOpacity(day.contributionCount, shouldShowGhostCity),
112+
strokeOpacity: isGhost ? 0.3 : 0,
113+
strokeWidth: isGhost ? 0.5 : 0,
114+
row: i,
115+
col: j,
116+
});
117+
});
118+
});
119+
120+
return towers;
121+
}

0 commit comments

Comments
 (0)