|
| 1 | +<template> |
| 2 | + <canvas ref="cv" class="net" aria-hidden="true"></canvas> |
| 3 | +</template> |
| 4 | + |
| 5 | +<script setup> |
| 6 | +import { onMounted, onBeforeUnmount, ref } from "vue"; |
| 7 | +
|
| 8 | +const cv = ref(null); |
| 9 | +
|
| 10 | +let raf = 0; |
| 11 | +let ctx = null; |
| 12 | +
|
| 13 | +let w = 0; |
| 14 | +let h = 0; |
| 15 | +let dpr = 1; |
| 16 | +
|
| 17 | +const nodes = []; |
| 18 | +const baseNodes = 42; |
| 19 | +
|
| 20 | +const mouse = { |
| 21 | + x: 0, |
| 22 | + y: 0, |
| 23 | + active: false, |
| 24 | + down: false, |
| 25 | + lastSpawnX: 0, |
| 26 | + lastSpawnY: 0, |
| 27 | +}; |
| 28 | +
|
| 29 | +function rand(min, max) { |
| 30 | + return min + Math.random() * (max - min); |
| 31 | +} |
| 32 | +
|
| 33 | +function clamp(v, a, b) { |
| 34 | + return Math.max(a, Math.min(b, v)); |
| 35 | +} |
| 36 | +
|
| 37 | +function resize() { |
| 38 | + const el = cv.value; |
| 39 | + if (!el) return; |
| 40 | +
|
| 41 | + const rect = el.getBoundingClientRect(); |
| 42 | + dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); |
| 43 | +
|
| 44 | + w = Math.floor(rect.width); |
| 45 | + h = Math.floor(rect.height); |
| 46 | +
|
| 47 | + el.width = Math.floor(w * dpr); |
| 48 | + el.height = Math.floor(h * dpr); |
| 49 | +
|
| 50 | + ctx = el.getContext("2d"); |
| 51 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 52 | +
|
| 53 | + ensureNodeCount(); |
| 54 | +} |
| 55 | +
|
| 56 | +function ensureNodeCount() { |
| 57 | + const target = baseNodes + Math.floor((w * h) / 70000); |
| 58 | + while (nodes.length < target) spawnNode(rand(0, w), rand(0, h), true); |
| 59 | + while (nodes.length > target) nodes.pop(); |
| 60 | +} |
| 61 | +
|
| 62 | +function spawnNode(x, y, calm = false) { |
| 63 | + const n = { |
| 64 | + x, |
| 65 | + y, |
| 66 | + vx: calm ? rand(-0.15, 0.15) : rand(-0.45, 0.45), |
| 67 | + vy: calm ? rand(-0.15, 0.15) : rand(-0.45, 0.45), |
| 68 | + s: Math.random() < 0.12 ? 8 : 6, // pixel size |
| 69 | + a: rand(0.22, 0.62), // alpha |
| 70 | + tone: pickTone(), |
| 71 | + }; |
| 72 | + nodes.push(n); |
| 73 | +} |
| 74 | +
|
| 75 | +function pickTone() { |
| 76 | + const r = Math.random(); |
| 77 | + if (r < 0.72) return "teal"; |
| 78 | + if (r < 0.90) return "slate"; |
| 79 | + return "yellow"; |
| 80 | +} |
| 81 | +
|
| 82 | +function toneColor(tone, alpha) { |
| 83 | + if (tone === "yellow") return `rgba(250, 204, 21, ${alpha})`; |
| 84 | + if (tone === "slate") return `rgba(148, 163, 184, ${alpha})`; |
| 85 | + return `rgba(94, 234, 212, ${alpha})`; // teal |
| 86 | +} |
| 87 | +
|
| 88 | +function lineColor(alpha) { |
| 89 | + return `rgba(94, 234, 212, ${alpha})`; |
| 90 | +} |
| 91 | +
|
| 92 | +function step() { |
| 93 | + raf = requestAnimationFrame(step); |
| 94 | +
|
| 95 | + if (!ctx) return; |
| 96 | + ctx.clearRect(0, 0, w, h); |
| 97 | +
|
| 98 | + const maxDist = Math.min(220, Math.max(140, w * 0.18)); |
| 99 | + const maxDist2 = maxDist * maxDist; |
| 100 | +
|
| 101 | + // Update nodes |
| 102 | + for (const n of nodes) { |
| 103 | + // subtle attraction to mouse when active |
| 104 | + if (mouse.active) { |
| 105 | + const dx = mouse.x - n.x; |
| 106 | + const dy = mouse.y - n.y; |
| 107 | + const d2 = dx * dx + dy * dy; |
| 108 | + const pullR = 220; |
| 109 | + if (d2 < pullR * pullR) { |
| 110 | + const d = Math.max(1, Math.sqrt(d2)); |
| 111 | + const f = (1 - d / pullR) * 0.015; |
| 112 | + n.vx += (dx / d) * f; |
| 113 | + n.vy += (dy / d) * f; |
| 114 | + } |
| 115 | + } |
| 116 | +
|
| 117 | + // integrate |
| 118 | + n.x += n.vx; |
| 119 | + n.y += n.vy; |
| 120 | +
|
| 121 | + // gentle damping |
| 122 | + n.vx *= 0.995; |
| 123 | + n.vy *= 0.995; |
| 124 | +
|
| 125 | + // bounce |
| 126 | + if (n.x < 0) { |
| 127 | + n.x = 0; |
| 128 | + n.vx = Math.abs(n.vx); |
| 129 | + } |
| 130 | + if (n.x > w) { |
| 131 | + n.x = w; |
| 132 | + n.vx = -Math.abs(n.vx); |
| 133 | + } |
| 134 | + if (n.y < 0) { |
| 135 | + n.y = 0; |
| 136 | + n.vy = Math.abs(n.vy); |
| 137 | + } |
| 138 | + if (n.y > h) { |
| 139 | + n.y = h; |
| 140 | + n.vy = -Math.abs(n.vy); |
| 141 | + } |
| 142 | + } |
| 143 | +
|
| 144 | + // Lines |
| 145 | + ctx.lineWidth = 1.15; |
| 146 | + for (let i = 0; i < nodes.length; i++) { |
| 147 | + const a = nodes[i]; |
| 148 | + for (let j = i + 1; j < nodes.length; j++) { |
| 149 | + const b = nodes[j]; |
| 150 | + const dx = a.x - b.x; |
| 151 | + const dy = a.y - b.y; |
| 152 | + const d2 = dx * dx + dy * dy; |
| 153 | + if (d2 > maxDist2) continue; |
| 154 | +
|
| 155 | + const d = Math.sqrt(d2); |
| 156 | + let alpha = (1 - d / maxDist) * 0.42; |
| 157 | +
|
| 158 | + // boost near mouse |
| 159 | + if (mouse.active) { |
| 160 | + const mdx = (a.x + b.x) * 0.5 - mouse.x; |
| 161 | + const mdy = (a.y + b.y) * 0.5 - mouse.y; |
| 162 | + const md2 = mdx * mdx + mdy * mdy; |
| 163 | + if (md2 < 240 * 240) alpha *= 1.45; |
| 164 | + } |
| 165 | +
|
| 166 | + alpha = clamp(alpha, 0.02, 0.55); |
| 167 | +
|
| 168 | + ctx.strokeStyle = lineColor(alpha); |
| 169 | + ctx.beginPath(); |
| 170 | + ctx.moveTo(a.x, a.y); |
| 171 | + ctx.lineTo(b.x, b.y); |
| 172 | + ctx.stroke(); |
| 173 | + } |
| 174 | + } |
| 175 | +
|
| 176 | + // Nodes (small squares) |
| 177 | + for (const n of nodes) { |
| 178 | + const size = n.s; |
| 179 | + const aa = mouse.active ? n.a * 1.05 : n.a; |
| 180 | + ctx.fillStyle = toneColor(n.tone, clamp(aa, 0.12, 0.92)); |
| 181 | + ctx.fillRect(n.x - size / 2, n.y - size / 2, size, size); |
| 182 | + } |
| 183 | +
|
| 184 | + // soft vignette like JSR |
| 185 | + const g = ctx.createRadialGradient(w * 0.5, h * 0.55, 0, w * 0.5, h * 0.55, Math.max(w, h) * 0.65); |
| 186 | + g.addColorStop(0, "rgba(0,0,0,0)"); |
| 187 | + g.addColorStop(1, "rgba(0,0,0,0.38)"); |
| 188 | + ctx.fillStyle = g; |
| 189 | + ctx.fillRect(0, 0, w, h); |
| 190 | +} |
| 191 | +
|
| 192 | +function pointerPos(e) { |
| 193 | + const rect = cv.value.getBoundingClientRect(); |
| 194 | + return { |
| 195 | + x: e.clientX - rect.left, |
| 196 | + y: e.clientY - rect.top, |
| 197 | + }; |
| 198 | +} |
| 199 | +
|
| 200 | +function maybeSpawnTree(x, y) { |
| 201 | + const dx = x - mouse.lastSpawnX; |
| 202 | + const dy = y - mouse.lastSpawnY; |
| 203 | + const d2 = dx * dx + dy * dy; |
| 204 | +
|
| 205 | + // spacing between new nodes while dragging |
| 206 | + if (d2 < 18 * 18) return; |
| 207 | +
|
| 208 | + mouse.lastSpawnX = x; |
| 209 | + mouse.lastSpawnY = y; |
| 210 | +
|
| 211 | + // spawn 1-2 nodes for a dense "tree" |
| 212 | + const count = Math.random() < 0.35 ? 2 : 1; |
| 213 | + for (let i = 0; i < count; i++) { |
| 214 | + spawnNode( |
| 215 | + x + rand(-6, 6), |
| 216 | + y + rand(-6, 6), |
| 217 | + false |
| 218 | + ); |
| 219 | + } |
| 220 | +} |
| 221 | +
|
| 222 | +function onMove(e) { |
| 223 | + if (!cv.value) return; |
| 224 | + const p = pointerPos(e); |
| 225 | + mouse.x = p.x; |
| 226 | + mouse.y = p.y; |
| 227 | + mouse.active = true; |
| 228 | +
|
| 229 | + if (mouse.down) { |
| 230 | + maybeSpawnTree(p.x, p.y); |
| 231 | + } |
| 232 | +} |
| 233 | +
|
| 234 | +function onDown(e) { |
| 235 | + if (!cv.value) return; |
| 236 | + const p = pointerPos(e); |
| 237 | + mouse.down = true; |
| 238 | + mouse.active = true; |
| 239 | + mouse.x = p.x; |
| 240 | + mouse.y = p.y; |
| 241 | + mouse.lastSpawnX = p.x; |
| 242 | + mouse.lastSpawnY = p.y; |
| 243 | +
|
| 244 | + // immediate burst |
| 245 | + for (let i = 0; i < 3; i++) { |
| 246 | + spawnNode(p.x + rand(-10, 10), p.y + rand(-10, 10), false); |
| 247 | + } |
| 248 | +} |
| 249 | +
|
| 250 | +function onUp() { |
| 251 | + mouse.down = false; |
| 252 | +} |
| 253 | +
|
| 254 | +function onLeave() { |
| 255 | + mouse.active = false; |
| 256 | + mouse.down = false; |
| 257 | +} |
| 258 | +
|
| 259 | +onMounted(() => { |
| 260 | + const el = cv.value; |
| 261 | + if (!el) return; |
| 262 | +
|
| 263 | + // initial nodes after first resize |
| 264 | + resize(); |
| 265 | +
|
| 266 | + // seed calm nodes |
| 267 | + if (nodes.length === 0) { |
| 268 | + for (let i = 0; i < baseNodes; i++) spawnNode(rand(0, w), rand(0, h), true); |
| 269 | + ensureNodeCount(); |
| 270 | + } |
| 271 | +
|
| 272 | + // events |
| 273 | + el.addEventListener("pointermove", onMove, { passive: true }); |
| 274 | + el.addEventListener("pointerdown", onDown, { passive: true }); |
| 275 | + window.addEventListener("pointerup", onUp, { passive: true }); |
| 276 | + el.addEventListener("pointerleave", onLeave, { passive: true }); |
| 277 | +
|
| 278 | + const ro = new ResizeObserver(() => resize()); |
| 279 | + ro.observe(el); |
| 280 | +
|
| 281 | + raf = requestAnimationFrame(step); |
| 282 | +
|
| 283 | + onBeforeUnmount(() => { |
| 284 | + cancelAnimationFrame(raf); |
| 285 | + el.removeEventListener("pointermove", onMove); |
| 286 | + el.removeEventListener("pointerdown", onDown); |
| 287 | + window.removeEventListener("pointerup", onUp); |
| 288 | + el.removeEventListener("pointerleave", onLeave); |
| 289 | + ro.disconnect(); |
| 290 | + }); |
| 291 | +}); |
| 292 | +</script> |
| 293 | + |
| 294 | +<style scoped> |
| 295 | +.net{ |
| 296 | + position: absolute; |
| 297 | + inset: 0; |
| 298 | + width: 100%; |
| 299 | + height: 100%; |
| 300 | +} |
| 301 | +</style> |
0 commit comments