feat: google balls ebd375d7
Steve · 2026-03-14 13:13 3 file(s) · +303 −0
packages/client/src/components/layout/Footer.astro +12 −0
9 9
		Copyright © {year}
10 10
		Steve Simkins
11 11
	</div>
12 +
	<div class="googleballs-trigger flex items-center gap-1" role="button" tabindex="0" aria-label="Easter egg">
13 +
		<span style="background:#4285F4;width:6px;height:6px;border-radius:50%;display:inline-block;"></span>
14 +
		<span style="background:#EA4335;width:6px;height:6px;border-radius:50%;display:inline-block;"></span>
15 +
		<span style="background:#FBBC05;width:6px;height:6px;border-radius:50%;display:inline-block;"></span>
16 +
		<span style="background:#34A853;width:6px;height:6px;border-radius:50%;display:inline-block;"></span>
17 +
	</div>
12 18
	<a href="https://github.com/stevedylandev/stevedylan.dev">Source Code</a>
13 19
</footer>
20 +
21 +
<script>
22 +
	document.querySelector('.googleballs-trigger')?.addEventListener('click', function() {
23 +
		document.dispatchEvent(new CustomEvent('googleballs:open'));
24 +
	});
25 +
</script>
packages/client/src/components/page/GoogleBalls.astro (added) +262 −0
1 +
---
2 +
---
3 +
4 +
<canvas id="googleballs-canvas" width="400" height="400"></canvas>
5 +
6 +
<script>
7 +
(function() {
8 +
	var canvas = document.getElementById("googleballs-canvas");
9 +
	var canvasHeight;
10 +
	var canvasWidth;
11 +
	var ctx;
12 +
	var lastFrameTime = 0;
13 +
	var animationId = null;
14 +
	var pointCollection;
15 +
16 +
	function init() {
17 +
		updateCanvasDimensions();
18 +
19 +
		var g = [new Point(202, 78, 0.0, 9, "#ed9d33"), new Point(348, 83, 0.0, 9, "#d44d61"), new Point(256, 69, 0.0, 9, "#4f7af2"), new Point(214, 59, 0.0, 9, "#ef9a1e"), new Point(265, 36, 0.0, 9, "#4976f3"), new Point(300, 78, 0.0, 9, "#269230"), new Point(294, 59, 0.0, 9, "#1f9e2c"), new Point(45, 88, 0.0, 9, "#1c48dd"), new Point(268, 52, 0.0, 9, "#2a56ea"), new Point(73, 83, 0.0, 9, "#3355d8"), new Point(294, 6, 0.0, 9, "#36b641"), new Point(235, 62, 0.0, 9, "#2e5def"), new Point(353, 42, 0.0, 8, "#d53747"), new Point(336, 52, 0.0, 8, "#eb676f"), new Point(208, 41, 0.0, 8, "#f9b125"), new Point(321, 70, 0.0, 8, "#de3646"), new Point(8, 60, 0.0, 8, "#2a59f0"), new Point(180, 81, 0.0, 8, "#eb9c31"), new Point(146, 65, 0.0, 8, "#c41731"), new Point(145, 49, 0.0, 8, "#d82038"), new Point(246, 34, 0.0, 8, "#5f8af8"), new Point(169, 69, 0.0, 8, "#efa11e"), new Point(273, 99, 0.0, 8, "#2e55e2"), new Point(248, 120, 0.0, 8, "#4167e4"), new Point(294, 41, 0.0, 8, "#0b991a"), new Point(267, 114, 0.0, 8, "#4869e3"), new Point(78, 67, 0.0, 8, "#3059e3"), new Point(294, 23, 0.0, 8, "#10a11d"), new Point(117, 83, 0.0, 8, "#cf4055"), new Point(137, 80, 0.0, 8, "#cd4359"), new Point(14, 71, 0.0, 8, "#2855ea"), new Point(331, 80, 0.0, 8, "#ca273c"), new Point(25, 82, 0.0, 8, "#2650e1"), new Point(233, 46, 0.0, 8, "#4a7bf9"), new Point(73, 13, 0.0, 8, "#3d65e7"), new Point(327, 35, 0.0, 6, "#f47875"), new Point(319, 46, 0.0, 6, "#f36764"), new Point(256, 81, 0.0, 6, "#1d4eeb"), new Point(244, 88, 0.0, 6, "#698bf1"), new Point(194, 32, 0.0, 6, "#fac652"), new Point(97, 56, 0.0, 6, "#ee5257"), new Point(105, 75, 0.0, 6, "#cf2a3f"), new Point(42, 4, 0.0, 6, "#5681f5"), new Point(10, 27, 0.0, 6, "#4577f6"), new Point(166, 55, 0.0, 6, "#f7b326"), new Point(266, 88, 0.0, 6, "#2b58e8"), new Point(178, 34, 0.0, 6, "#facb5e"), new Point(100, 65, 0.0, 6, "#e02e3d"), new Point(343, 32, 0.0, 6, "#f16d6f"), new Point(59, 5, 0.0, 6, "#507bf2"), new Point(27, 9, 0.0, 6, "#5683f7"), new Point(233, 116, 0.0, 6, "#3158e2"), new Point(123, 32, 0.0, 6, "#f0696c"), new Point(6, 38, 0.0, 6, "#3769f6"), new Point(63, 62, 0.0, 6, "#6084ef"), new Point(6, 49, 0.0, 6, "#2a5cf4"), new Point(108, 36, 0.0, 6, "#f4716e"), new Point(169, 43, 0.0, 6, "#f8c247"), new Point(137, 37, 0.0, 6, "#e74653"), new Point(318, 58, 0.0, 6, "#ec4147"), new Point(226, 100, 0.0, 5, "#4876f1"), new Point(101, 46, 0.0, 5, "#ef5c5c"), new Point(226, 108, 0.0, 5, "#2552ea"), new Point(17, 17, 0.0, 5, "#4779f7"), new Point(232, 93, 0.0, 5, "#4b78f1")];
20 +
21 +
		var gLength = g.length;
22 +
		for (var i = 0; i < gLength; i++) {
23 +
			g[i].curPos.x = (canvasWidth/2 - 180) + g[i].curPos.x;
24 +
			g[i].curPos.y = (canvasHeight/2 - 65) + g[i].curPos.y;
25 +
26 +
			g[i].originalPos.x = (canvasWidth/2 - 180) + g[i].originalPos.x;
27 +
			g[i].originalPos.y = (canvasHeight/2 - 65) + g[i].originalPos.y;
28 +
		}
29 +
30 +
		pointCollection = new PointCollection();
31 +
		pointCollection.points = g;
32 +
33 +
		window.addEventListener('resize', updateCanvasDimensions);
34 +
		window.addEventListener('mousemove', onMove);
35 +
36 +
		canvas.ontouchmove = function(e) {
37 +
			e.preventDefault();
38 +
			onTouchMove(e);
39 +
		};
40 +
41 +
		canvas.ontouchstart = function(e) {
42 +
			e.preventDefault();
43 +
		};
44 +
45 +
		lastFrameTime = 0;
46 +
		animationId = requestAnimationFrame(animateRAF);
47 +
	}
48 +
49 +
	function stop() {
50 +
		if (animationId) {
51 +
			cancelAnimationFrame(animationId);
52 +
			animationId = null;
53 +
		}
54 +
		window.removeEventListener('resize', updateCanvasDimensions);
55 +
		window.removeEventListener('mousemove', onMove);
56 +
		canvas.ontouchmove = null;
57 +
		canvas.ontouchstart = null;
58 +
		pointCollection = null;
59 +
		lastFrameTime = 0;
60 +
		if (ctx) {
61 +
			ctx.clearRect(0, 0, canvasWidth, canvasHeight);
62 +
		}
63 +
	}
64 +
65 +
	function animateRAF(timestamp) {
66 +
		if (!lastFrameTime) lastFrameTime = timestamp;
67 +
		var deltaTime = (timestamp - lastFrameTime) / 1000;
68 +
		lastFrameTime = timestamp;
69 +
70 +
		deltaTime = Math.min(deltaTime, 0.1);
71 +
72 +
		draw();
73 +
		update(deltaTime);
74 +
75 +
		animationId = requestAnimationFrame(animateRAF);
76 +
	}
77 +
78 +
	function updateCanvasDimensions() {
79 +
		var modal = document.getElementById('googleballs-modal');
80 +
		if (!modal) return;
81 +
82 +
		var dpr = window.devicePixelRatio || 1;
83 +
		var logicalWidth = modal.clientWidth;
84 +
		var logicalHeight = modal.clientHeight;
85 +
86 +
		canvas.width = logicalWidth * dpr;
87 +
		canvas.height = logicalHeight * dpr;
88 +
		canvas.style.width = logicalWidth + 'px';
89 +
		canvas.style.height = logicalHeight + 'px';
90 +
91 +
		canvasWidth = logicalWidth;
92 +
		canvasHeight = logicalHeight;
93 +
94 +
		if (canvas.getContext) {
95 +
			ctx = canvas.getContext('2d');
96 +
			ctx.scale(dpr, dpr);
97 +
		}
98 +
99 +
		recenterPoints();
100 +
		draw();
101 +
	}
102 +
103 +
	function recenterPoints() {
104 +
		if (!pointCollection) return;
105 +
106 +
		var offsetX = (canvasWidth / 2 - 180);
107 +
		var offsetY = (canvasHeight / 2 - 65);
108 +
109 +
		for (var i = 0; i < pointCollection.points.length; i++) {
110 +
			var point = pointCollection.points[i];
111 +
112 +
			var relX = point.originalPos.x - (canvasWidth / 2 - 180);
113 +
			var relY = point.originalPos.y - (canvasHeight / 2 - 65);
114 +
115 +
			point.originalPos.x = offsetX + relX;
116 +
			point.originalPos.y = offsetY + relY;
117 +
118 +
			point.curPos.x = point.originalPos.x;
119 +
			point.curPos.y = point.originalPos.y;
120 +
		}
121 +
	}
122 +
123 +
	function onMove(e) {
124 +
		if (pointCollection) {
125 +
			var modal = document.getElementById('googleballs-modal');
126 +
			var rect = modal.getBoundingClientRect();
127 +
			pointCollection.mousePos.set(e.clientX - rect.left, e.clientY - rect.top);
128 +
		}
129 +
	}
130 +
131 +
	function onTouchMove(e) {
132 +
		if (pointCollection) {
133 +
			var modal = document.getElementById('googleballs-modal');
134 +
			var rect = modal.getBoundingClientRect();
135 +
			pointCollection.mousePos.set(
136 +
				e.targetTouches[0].clientX - rect.left,
137 +
				e.targetTouches[0].clientY - rect.top
138 +
			);
139 +
		}
140 +
	}
141 +
142 +
	function draw() {
143 +
		if (!ctx) return;
144 +
		ctx.clearRect(0, 0, canvasWidth, canvasHeight);
145 +
		if (pointCollection) pointCollection.draw();
146 +
	}
147 +
148 +
	function update(dt) {
149 +
		if (pointCollection) pointCollection.update(dt);
150 +
	}
151 +
152 +
	function Vector(x, y, z) {
153 +
		this.x = x;
154 +
		this.y = y;
155 +
		this.z = z;
156 +
157 +
		this.addX = function(x) { this.x += x; };
158 +
		this.addY = function(y) { this.y += y; };
159 +
		this.addZ = function(z) { this.z += z; };
160 +
		this.set = function(x, y, z) { this.x = x; this.y = y; this.z = z; };
161 +
	}
162 +
163 +
	function PointCollection() {
164 +
		this.mousePos = new Vector(0, 0);
165 +
		this.points = [];
166 +
167 +
		this.update = function(dt) {
168 +
			var pointsLength = this.points.length;
169 +
			for (var i = 0; i < pointsLength; i++) {
170 +
				var point = this.points[i];
171 +
				if (point == null) continue;
172 +
173 +
				var dx = this.mousePos.x - point.curPos.x;
174 +
				var dy = this.mousePos.y - point.curPos.y;
175 +
				var dd = (dx * dx) + (dy * dy);
176 +
				var d = Math.sqrt(dd);
177 +
178 +
				if (d < 150) {
179 +
					point.targetPos.x = point.curPos.x - dx;
180 +
					point.targetPos.y = point.curPos.y - dy;
181 +
				} else {
182 +
					point.targetPos.x = point.originalPos.x;
183 +
					point.targetPos.y = point.originalPos.y;
184 +
				}
185 +
186 +
				point.update(dt);
187 +
			}
188 +
		};
189 +
190 +
		this.draw = function() {
191 +
			var pointsLength = this.points.length;
192 +
			for (var i = 0; i < pointsLength; i++) {
193 +
				var point = this.points[i];
194 +
				if (point == null) continue;
195 +
				point.draw();
196 +
			}
197 +
		};
198 +
	}
199 +
200 +
	function Point(x, y, z, size, colour) {
201 +
		this.colour = colour;
202 +
		this.curPos = new Vector(x, y, z);
203 +
		this.friction = 0.8;
204 +
		this.originalPos = new Vector(x, y, z);
205 +
		this.radius = size;
206 +
		this.size = size;
207 +
		this.springStrength = 0.1;
208 +
		this.targetPos = new Vector(x, y, z);
209 +
		this.velocity = new Vector(0.0, 0.0, 0.0);
210 +
211 +
		this.update = function(dt) {
212 +
			var targetFrameTime = 0.03;
213 +
			var timeScale = dt / targetFrameTime;
214 +
215 +
			var dx = this.targetPos.x - this.curPos.x;
216 +
			var ax = dx * this.springStrength * timeScale;
217 +
			this.velocity.x += ax;
218 +
			this.velocity.x *= Math.pow(this.friction, timeScale);
219 +
			this.curPos.x += this.velocity.x * timeScale;
220 +
221 +
			var dy = this.targetPos.y - this.curPos.y;
222 +
			var ay = dy * this.springStrength * timeScale;
223 +
			this.velocity.y += ay;
224 +
			this.velocity.y *= Math.pow(this.friction, timeScale);
225 +
			this.curPos.y += this.velocity.y * timeScale;
226 +
227 +
			var dox = this.originalPos.x - this.curPos.x;
228 +
			var doy = this.originalPos.y - this.curPos.y;
229 +
			var dd = (dox * dox) + (doy * doy);
230 +
			var d = Math.sqrt(dd);
231 +
232 +
			this.targetPos.z = d/100 + 1;
233 +
			var dz = this.targetPos.z - this.curPos.z;
234 +
			var az = dz * this.springStrength * timeScale;
235 +
			this.velocity.z += az;
236 +
			this.velocity.z *= Math.pow(this.friction, timeScale);
237 +
			this.curPos.z += this.velocity.z * timeScale;
238 +
239 +
			this.radius = this.size * this.curPos.z;
240 +
			if (this.radius < 1) this.radius = 1;
241 +
		};
242 +
243 +
		this.draw = function() {
244 +
			ctx.fillStyle = this.colour;
245 +
			ctx.beginPath();
246 +
			ctx.arc(this.curPos.x, this.curPos.y, this.radius, 0, Math.PI*2, true);
247 +
			ctx.fill();
248 +
		};
249 +
	}
250 +
251 +
	// Expose init/stop for the modal controller
252 +
	window.__googleballs = { init: init, stop: stop };
253 +
})();
254 +
</script>
255 +
256 +
<style>
257 +
#googleballs-canvas {
258 +
	position: absolute;
259 +
	top: 0;
260 +
	left: 0;
261 +
}
262 +
</style>
packages/client/src/layouts/Base.astro +29 −0
3 3
import BaseHead from "@/components/layout/BaseHead";
4 4
import Header from "@/components/layout/Header";
5 5
import Footer from "@/components/layout/Footer";
6 +
import GoogleBalls from "@/components/page/GoogleBalls.astro";
6 7
import siteConfig from "@/site-config";
7 8
8 9
interface Props {
35 36
			<slot />
36 37
		</main>
37 38
		<Footer />
39 +
		<div id="googleballs-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:#121113;">
40 +
      <a href="https://googleballs.com" target="_blank" rel="noreferrer" style="position:absolute;top:16px;left:16px;color:#fff;font-size:16px;cursor:pointer;z-index:10000;text-decoration:underline;">Google Balls</a>
41 +
			<button id="googleballs-close" style="position:absolute;top:16px;right:16px;z-index:10000;background:none;border:none;color:#fff;font-size:32px;cursor:pointer;line-height:1;" aria-label="Close">&times;</button>
42 +
			<GoogleBalls />
43 +
		</div>
44 +
		<script>
45 +
			(function() {
46 +
				var modal = document.getElementById('googleballs-modal');
47 +
				var closeBtn = document.getElementById('googleballs-close');
48 +
49 +
				document.addEventListener('googleballs:open', function() {
50 +
					modal.style.display = 'block';
51 +
					if (window.__googleballs) window.__googleballs.init();
52 +
				});
53 +
54 +
				closeBtn.addEventListener('click', function() {
55 +
					modal.style.display = 'none';
56 +
					if (window.__googleballs) window.__googleballs.stop();
57 +
				});
58 +
59 +
				document.addEventListener('keydown', function(e) {
60 +
					if (e.key === 'Escape' && modal.style.display === 'block') {
61 +
						modal.style.display = 'none';
62 +
						if (window.__googleballs) window.__googleballs.stop();
63 +
					}
64 +
				});
65 +
			})();
66 +
		</script>
38 67
	</body>
39 68
</html>