| 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 | }; |