1// Partially vibe coded. A lot of manually tuning with the heart scale function
2// and how the mouse interacts with the hearts.
3(globalThis as any).canvas_2023 = function(canvas: HTMLCanvasElement) {
4 const isStandalone = canvas.getAttribute("data-standalone") === "true";
5 const config = {
6 heartBaseSize: 50,
7 heartMaxSize: 100,
8 spacing: 150,
9 rowSpeed: 0.1,
10 heartColor: "#FF90D9",
11 heartOpacity: isStandalone ? 0.5 : 0.04,
12 mouseInfluenceRadius: 1000,
13 heartScaleFunction: (distance: number, radius: number): number => {
14 if (distance > radius) return 1;
15
16 const normalizedDistance = distance / radius;
17 const scaleFactor = 1
18 + (1 - normalizedDistance)
19 * (config.heartMaxSize / config.heartBaseSize - 1);
20
21 return 1 + (scaleFactor - 1) * Math.pow(1 - normalizedDistance, 2);
22 },
23 };
24
25 const heart = new Path2D(
26 "M23.9451 45.3973L20.8672 42.6493C16.9551 39.0174 13.7054 35.8927 11.1181 33.275C8.53056 30.6574 6.46731 28.286 4.92839 26.1608C3.38946 24.0356 2.31772 22.1028 1.71314 20.3624C1.10856 18.6219 0.806274 16.8705 0.806274 15.1081C0.806274 11.4718 2.03118 8.42016 4.481 5.95312C6.93118 3.48608 9.93831 2.25256 13.5024 2.25256C15.5649 2.25256 17.482 2.70142 19.2536 3.59912C21.0255 4.49682 22.5893 5.80674 23.9451 7.52887C25.484 5.73346 27.1059 4.40522 28.8108 3.54416C30.5161 2.6831 32.3751 2.25256 34.3877 2.25256C38.0141 2.25256 41.0551 3.48663 43.5108 5.95477C45.9661 8.42291 47.1938 11.4758 47.1938 15.1136C47.1938 16.8712 46.8823 18.6115 46.2594 20.3343C45.6365 22.0568 44.5648 23.9807 43.0442 26.1059C41.5236 28.231 39.4721 30.6136 36.8896 33.2536C34.3068 35.8936 31.0362 39.0255 27.0779 42.6493L23.9451 45.3973ZM23.9176 38.802C27.6088 35.431 30.6339 32.5547 32.9928 30.173C35.3518 27.7913 37.2091 25.7211 38.5648 23.9624C39.9205 22.2036 40.864 20.6137 41.3953 19.1928C41.9266 17.7715 42.1923 16.4101 42.1923 15.1086C42.1923 12.8768 41.4529 11.0098 39.974 9.50748C38.4952 8.0052 36.6461 7.25406 34.4268 7.25406C32.631 7.25406 30.9572 7.6811 29.4055 8.87193C27.8537 10.0628 25.5389 13.0434 25.5389 13.0434L23.9451 15.3299L22.3512 13.0434C22.3512 13.0434 20.0643 10.2311 18.4638 9.04031C16.8634 7.84948 15.2194 7.25406 13.4991 7.25406C11.2929 7.25406 9.46857 7.98816 8.02602 9.45637C6.58383 10.9246 5.86273 12.8162 5.86273 15.1311C5.86273 16.4784 6.13644 17.8679 6.68386 19.2994C7.23127 20.731 8.18394 22.3333 9.54185 24.1064C10.8998 25.879 12.7329 27.9562 15.0413 30.3379C17.3497 32.7196 20.3084 35.5409 23.9176 38.802Z",
27 );
28
29 const ctx = canvas.getContext("2d");
30 if (!ctx) {
31 console.error("Could not get canvas context");
32 return () => {};
33 }
34
35 if (isStandalone) {
36 canvas.style.backgroundColor = "#2F1C21";
37 } else {
38 canvas.style.backgroundColor = "transparent";
39 }
40
41 let width = canvas.width;
42 let height = canvas.height;
43 let animationFrameId: number;
44 let lastFrameTime = 0;
45
46 let mouseX = width / 2;
47 let mouseY = height / 2;
48
49 let offset = config.spacing / 2;
50
51 const updateDimensions = () => {
52 width = canvas.width = canvas.clientWidth;
53 height = canvas.height = canvas.clientHeight;
54
55 mouseX = width / 2;
56 mouseY = height / 2;
57 };
58
59 const drawHeart = (x: number, y: number, size: number) => {
60 const scale = size / 30;
61
62 ctx.save();
63 ctx.translate(x, y);
64 ctx.scale(scale, scale);
65
66 ctx.fillStyle = config.heartColor;
67 ctx.fill(heart);
68
69 ctx.restore();
70 };
71
72 const c = 400;
73 const h = 40;
74 const k = solveForK(c, h);
75
76 const drawHeartGrid = () => {
77 ctx.clearRect(0, 0, width, height);
78
79 ctx.globalAlpha = config.heartOpacity;
80
81 const numRows = Math.ceil(height / config.spacing) + 1;
82
83 for (let row = 0; row < numRows; row++) {
84 const direction = row % 2 === 0 ? 1 : -1;
85 const rowOffset = (offset * direction) % config.spacing;
86
87 const posYInit = row * config.spacing + config.spacing / 2;
88
89 for (
90 let posXInit = -config.spacing + rowOffset;
91 posXInit < width + config.spacing;
92 posXInit += config.spacing
93 ) {
94 const dx = (posXInit + config.heartBaseSize / 2) - mouseX;
95 const dy = (posYInit + config.heartBaseSize / 2) - mouseY;
96 const distance = Math.sqrt(dx * dx + dy * dy);
97
98 const pushIntensity = asymmetricBump(distance, h, c, k, 0.00002);
99
100 const pushAngle = Math.atan2(dy, dx);
101
102 const pushDistanceX = pushIntensity * Math.cos(pushAngle);
103 const pushDistanceY = pushIntensity * Math.sin(pushAngle);
104 const posX = posXInit + pushDistanceX * 1;
105 const posY = posYInit + pushDistanceY * 2;
106
107 const scaleFactor = config.heartScaleFunction(
108 distance,
109 config.mouseInfluenceRadius,
110 );
111 const heartSize = config.heartBaseSize * scaleFactor;
112
113 if (
114 posX > -config.heartMaxSize
115 && posX < width + config.heartMaxSize
116 && posY > -config.heartMaxSize
117 && posY < height + config.heartMaxSize
118 ) {
119 drawHeart(posX - heartSize / 2, posY - heartSize / 2, heartSize);
120 }
121 }
122 }
123
124 ctx.globalAlpha = 1.0;
125 };
126
127 function solveForK(c: number, k: number) {
128 // input -> f(x)=h*e^{(-k*(x-c)^{2})}
129 // desired result is (0, 0.45). (0, 0) is unsolvable but 0.45px will round down to 0.
130 //
131 // solution: -\frac{\ln\left(\frac{0.45}{h}\right)}{c^{2}}
132 return -Math.log(0.45 / h) / (c * c);
133 }
134
135 function asymmetricBump(
136 x: number,
137 h: number,
138 c: number,
139 leftK: number,
140 rightK: number,
141 ) {
142 const k = (x <= c) ? leftK : rightK;
143 return h * Math.exp(-k * Math.pow(x - c, 2));
144 }
145
146 const updateOffset = (elapsed: number) => {
147 offset += config.rowSpeed * elapsed;
148
149 if (offset > 1000000) {
150 offset -= 1000000;
151 }
152 };
153
154 const animate = (currentTime: number) => {
155 animationFrameId = requestAnimationFrame(animate);
156
157 const elapsed = currentTime - lastFrameTime;
158 lastFrameTime = currentTime;
159
160 updateOffset(elapsed * 0.05);
161
162 drawHeartGrid();
163 };
164
165 const handleMouseMove = (event: MouseEvent) => {
166 const rect = canvas.getBoundingClientRect();
167 mouseX = event.clientX - rect.left;
168 mouseY = event.clientY - rect.top;
169 };
170
171 const handleTouchMove = (event: TouchEvent) => {
172 if (event.touches.length > 0) {
173 event.preventDefault();
174 const rect = canvas.getBoundingClientRect();
175 mouseX = event.touches[0].clientX - rect.left;
176 mouseY = event.touches[0].clientY - rect.top;
177 }
178 };
179
180 const init = () => {
181 window.addEventListener("resize", updateDimensions);
182 document.addEventListener("mousemove", handleMouseMove);
183 document.addEventListener("touchmove", handleTouchMove, { passive: false });
184
185 updateDimensions();
186
187 lastFrameTime = performance.now();
188 animationFrameId = requestAnimationFrame(animate);
189 };
190
191 init();
192
193 return () => {
194 window.removeEventListener("resize", updateDimensions);
195 document.removeEventListener("mousemove", handleMouseMove);
196 document.removeEventListener("touchmove", handleTouchMove);
197 cancelAnimationFrame(animationFrameId);
198 };
199};