experiments in a post-browser web
at main 530 lines 16 kB view raw
1/** 2 * Help Docs Overlay Animation Script 3 * 4 * Renders organic animated strands that creep in from screen edges, 5 * undulate gently, and flee from the cursor. 6 * 7 * Each strand is a chain of segments rendered as a smooth bezier curve. 8 * Control points animate with sinusoidal wobble for organic motion. 9 * Cursor proximity triggers a fear/recoil response. 10 */ 11 12const canvas = document.getElementById('overlay-canvas'); 13const ctx = canvas.getContext('2d'); 14 15// --- Configuration --- 16const CONFIG = { 17 // Spawn timing 18 spawnIntervalMin: 800, // faster spawning 19 spawnIntervalMax: 2500, 20 21 // Strand properties 22 minSegments: 8, 23 maxSegments: 18, 24 minSegmentLength: 14, 25 maxSegmentLength: 30, 26 minThickness: 8, 27 maxThickness: 22, 28 29 // Animation 30 creepSpeed: 0.8, // pixels per frame when creeping in 31 retreatSpeed: 0.15, // pixels per frame when retreating naturally 32 fleeSpeed: 3.0, // pixels per frame when fleeing from cursor 33 wobbleSpeed: 0.015, // base radians per frame for wobble 34 wobbleAmplitude: 0.08, // base wobble at root (increases toward tip) 35 tipAmplitude: 0.35, // max wobble at tip 36 curlStrength: 0.4, // tendency to curl (0 = straight, 1 = tight spiral) 37 curlDriftSpeed: 0.003, // how fast the curl direction changes 38 39 // Lifecycle 40 creepInDuration: 3000, // ms to creep in 41 lingerDurationMin: 4000, // ms to linger at max extension 42 lingerDurationMax: 10000, // ms to linger at max extension 43 retreatDuration: 4000, // ms to retreat 44 45 // Fear response 46 fearRadius: 100, // px distance to trigger fear 47 fearRecoveryTime: 2000, // ms before strand can return after fear 48 49 // Visual 50 maxAlpha: 1.0, 51 colors: [ 52 { r: 60, g: 30, b: 90 }, // deep purple 53 { r: 30, g: 65, b: 55 }, // dark teal 54 { r: 40, g: 40, b: 75 }, // deep slate blue 55 { r: 55, g: 30, b: 65 }, // dark violet 56 { r: 25, g: 55, b: 45 }, // deep sea green 57 { r: 45, g: 25, b: 50 }, // near-black plum 58 ], 59 60 // Max simultaneous strands 61 maxStrands: 30, 62}; 63 64// --- State --- 65let strands = []; 66let mouseX = -1000; 67let mouseY = -1000; 68let animationId = null; 69let spawnTimer = null; 70let lastTime = 0; 71 72// --- Edge enum --- 73const EDGE = { TOP: 0, RIGHT: 1, BOTTOM: 2, LEFT: 3 }; 74 75// --- Utility --- 76const rand = (min, max) => Math.random() * (max - min) + min; 77const randInt = (min, max) => Math.floor(rand(min, max + 1)); 78const lerp = (a, b, t) => a + (b - a) * t; 79const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); 80const dist = (x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); 81 82/** 83 * Create a new strand emerging from a specific or random edge. 84 * @param {number} [forceEdge] - If provided, use this edge (0-3). Otherwise random. 85 */ 86function createStrand(forceEdge) { 87 const edge = forceEdge !== undefined ? forceEdge : randInt(0, 3); 88 const numSegments = randInt(CONFIG.minSegments, CONFIG.maxSegments); 89 const segmentLength = rand(CONFIG.minSegmentLength, CONFIG.maxSegmentLength); 90 const thickness = rand(CONFIG.minThickness, CONFIG.maxThickness); 91 const color = CONFIG.colors[randInt(0, CONFIG.colors.length - 1)]; 92 93 // Base position along the edge 94 let baseX, baseY, angle; 95 const W = canvas.width; 96 const H = canvas.height; 97 const margin = 80; 98 99 switch (edge) { 100 case EDGE.TOP: 101 baseX = rand(margin, W - margin); 102 baseY = 0; 103 angle = Math.PI / 2 + rand(-0.3, 0.3); // mostly downward 104 break; 105 case EDGE.BOTTOM: 106 baseX = rand(margin, W - margin); 107 baseY = H; 108 angle = -Math.PI / 2 + rand(-0.3, 0.3); // mostly upward 109 break; 110 case EDGE.LEFT: 111 baseX = 0; 112 baseY = rand(margin, H - margin); 113 angle = rand(-0.3, 0.3); // mostly rightward 114 break; 115 case EDGE.RIGHT: 116 baseX = W; 117 baseY = rand(margin, H - margin); 118 angle = Math.PI + rand(-0.3, 0.3); // mostly leftward 119 break; 120 } 121 122 // Each segment has a base angle offset and independent wobble parameters 123 const segments = []; 124 for (let i = 0; i < numSegments; i++) { 125 segments.push({ 126 length: segmentLength * (1 - i * 0.03), // taper slightly 127 angleOffset: rand(-0.1, 0.1), // slight random curvature 128 wobblePhase: rand(0, Math.PI * 2), // random initial phase 129 wobbleFreq: rand(0.5, 1.5), // varied wobble frequency 130 // Secondary slower wobble for more complex motion 131 wobblePhase2: rand(0, Math.PI * 2), 132 wobbleFreq2: rand(0.15, 0.4), // much slower secondary wave 133 }); 134 } 135 136 const lingerDuration = rand(CONFIG.lingerDurationMin, CONFIG.lingerDurationMax); 137 138 return { 139 edge, 140 baseX, 141 baseY, 142 baseAngle: angle, 143 segments, 144 thickness, 145 color, 146 numSegments, 147 148 // Extension: 0 = fully retracted, 1 = fully extended 149 extension: 0, 150 targetExtension: 1, 151 152 // Lifecycle phase: 'creeping', 'lingering', 'retreating', 'dead' 153 phase: 'creeping', 154 phaseTime: 0, 155 lingerDuration, 156 157 // Fear state 158 afraid: false, 159 fearTime: 0, 160 161 // Wobble time accumulator 162 wobbleTime: rand(0, 100), 163 164 // Curl state: a slow-drifting bias that makes segments curl in one direction 165 curlBias: rand(-1, 1), // current curl direction (-1 to 1) 166 curlPhase: rand(0, Math.PI * 2), // phase for curl drift 167 168 // Alpha 169 alpha: 0, 170 }; 171} 172 173/** 174 * Compute the joint positions of a strand 175 * Returns array of {x, y} points from base to tip 176 * 177 * Motion model: 178 * - Base segments are relatively stable (anchored to the edge) 179 * - Tip segments are much more active and mobile 180 * - A slow-drifting curl bias makes the whole strand curl/uncurl over time 181 * - Two wobble frequencies per segment create complex, non-repetitive motion 182 * - Curl accumulates along the strand (each segment adds to the curve) 183 */ 184function computePoints(s) { 185 const points = [{ x: s.baseX, y: s.baseY }]; 186 let currentAngle = s.baseAngle; 187 let x = s.baseX; 188 let y = s.baseY; 189 190 // Only draw segments up to current extension 191 const visibleSegments = Math.ceil(s.extension * s.numSegments); 192 const partialFrac = (s.extension * s.numSegments) - Math.floor(s.extension * s.numSegments); 193 194 for (let i = 0; i < visibleSegments && i < s.segments.length; i++) { 195 const seg = s.segments[i]; 196 197 // How far along the strand (0 = base, 1 = tip) 198 const t = i / s.numSegments; 199 200 // Wobble amplitude increases toward tip (tentacle-like: base stable, tip active) 201 const amplitude = lerp(CONFIG.wobbleAmplitude, CONFIG.tipAmplitude, t * t); 202 203 // Primary wobble: faster, smaller oscillation 204 const wobble1 = Math.sin(s.wobbleTime * seg.wobbleFreq + seg.wobblePhase + i * 0.6) 205 * amplitude; 206 207 // Secondary wobble: slower, creates longer sweeping motions 208 const wobble2 = Math.sin(s.wobbleTime * seg.wobbleFreq2 + seg.wobblePhase2 + i * 0.3) 209 * amplitude * 0.7; 210 211 // Curl: accumulates along the strand, creating spiral/curl shapes 212 // The curl bias drifts slowly so the strand curls and uncurls over time 213 const curl = s.curlBias * CONFIG.curlStrength * t; 214 215 currentAngle += seg.angleOffset + wobble1 + wobble2 + curl; 216 217 // If this is the last visible segment and partial, shorten it 218 let len = seg.length; 219 if (i === visibleSegments - 1 && visibleSegments < s.numSegments) { 220 len *= partialFrac || 1; 221 } 222 223 x += Math.cos(currentAngle) * len; 224 y += Math.sin(currentAngle) * len; 225 points.push({ x, y }); 226 } 227 228 return points; 229} 230 231/** 232 * Draw a smooth strand through points using quadratic bezier curves 233 */ 234function drawStrand(s) { 235 const points = computePoints(s); 236 if (points.length < 2) return; 237 238 const { r, g, b } = s.color; 239 const alpha = s.alpha; 240 241 // Draw the strand as a tapered, smooth path 242 // We'll draw it as a filled shape with varying width 243 ctx.save(); 244 245 for (let i = 0; i < points.length - 1; i++) { 246 const p0 = points[i]; 247 const p1 = points[i + 1]; 248 249 // Thickness tapers from base to tip — gradual taper, base stays thick 250 const t0 = s.thickness * (1 - (i / points.length) * 0.5); 251 const t1 = s.thickness * (1 - ((i + 1) / points.length) * 0.5); 252 253 // Direction perpendicular to segment 254 const dx = p1.x - p0.x; 255 const dy = p1.y - p0.y; 256 const len = Math.sqrt(dx * dx + dy * dy) || 1; 257 const nx = -dy / len; 258 const ny = dx / len; 259 260 // Draw a quadrilateral for each segment 261 ctx.beginPath(); 262 ctx.moveTo(p0.x + nx * t0 / 2, p0.y + ny * t0 / 2); 263 ctx.lineTo(p1.x + nx * t1 / 2, p1.y + ny * t1 / 2); 264 ctx.lineTo(p1.x - nx * t1 / 2, p1.y - ny * t1 / 2); 265 ctx.lineTo(p0.x - nx * t0 / 2, p0.y - ny * t0 / 2); 266 ctx.closePath(); 267 268 // Solid color — no per-segment alpha fade 269 ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`; 270 ctx.fill(); 271 } 272 273 // Draw a smooth overlay using bezier curves for the center line 274 // This gives the "core" of the strand a richer look 275 ctx.beginPath(); 276 ctx.moveTo(points[0].x, points[0].y); 277 278 if (points.length === 2) { 279 ctx.lineTo(points[1].x, points[1].y); 280 } else { 281 // Smooth curve through points 282 for (let i = 0; i < points.length - 1; i++) { 283 const p0 = points[i]; 284 const p1 = points[i + 1]; 285 286 if (i === 0) { 287 // First segment: quadratic to midpoint 288 const midX = (p0.x + p1.x) / 2; 289 const midY = (p0.y + p1.y) / 2; 290 ctx.quadraticCurveTo(p0.x, p0.y, midX, midY); 291 } else if (i === points.length - 2) { 292 // Last segment: quadratic to end 293 ctx.quadraticCurveTo(points[i].x, points[i].y, p1.x, p1.y); 294 } else { 295 // Middle segments: quadratic through midpoints 296 const midX = (p0.x + p1.x) / 2; 297 const midY = (p0.y + p1.y) / 2; 298 ctx.quadraticCurveTo(p0.x, p0.y, midX, midY); 299 } 300 } 301 } 302 303 // Core line (slightly lighter, thinner) 304 ctx.strokeStyle = `rgba(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)}, ${alpha * 0.6})`; 305 ctx.lineWidth = Math.max(1, s.thickness * 0.3); 306 ctx.lineCap = 'round'; 307 ctx.lineJoin = 'round'; 308 ctx.stroke(); 309 310 // Tip: small circle at the end 311 if (points.length >= 2) { 312 const tip = points[points.length - 1]; 313 const tipRadius = s.thickness * 0.2 * (1 - ((points.length - 1) / s.numSegments) * 0.5); 314 ctx.beginPath(); 315 ctx.arc(tip.x, tip.y, Math.max(1, tipRadius), 0, Math.PI * 2); 316 ctx.fillStyle = `rgba(${Math.min(255, r + 40)}, ${Math.min(255, g + 40)}, ${Math.min(255, b + 40)}, ${alpha * 0.8})`; 317 ctx.fill(); 318 } 319 320 // Spots along the strand for extra organic feel 321 for (let i = 1; i < points.length - 1; i += 2) { 322 const p = points[i]; 323 const spotSize = s.thickness * 0.12 * (1 - (i / points.length) * 0.5); 324 ctx.beginPath(); 325 ctx.arc(p.x, p.y, Math.max(0.5, spotSize), 0, Math.PI * 2); 326 ctx.fillStyle = `rgba(${Math.max(0, r - 20)}, ${Math.max(0, g - 20)}, ${Math.max(0, b - 20)}, ${alpha * 0.4})`; 327 ctx.fill(); 328 } 329 330 ctx.restore(); 331} 332 333/** 334 * Check if the cursor is near any point of a strand 335 */ 336function isCursorNear(s) { 337 const points = computePoints(s); 338 for (const p of points) { 339 if (dist(mouseX, mouseY, p.x, p.y) < CONFIG.fearRadius) { 340 return true; 341 } 342 } 343 return false; 344} 345 346/** 347 * Update a single strand's state 348 */ 349function updateStrand(s, dt) { 350 // Advance wobble time 351 s.wobbleTime += CONFIG.wobbleSpeed * (dt / 16); 352 353 // Drift the curl bias — slow sinusoidal drift creates curling/uncurling over time 354 s.curlPhase += CONFIG.curlDriftSpeed * (dt / 16); 355 s.curlBias = Math.sin(s.curlPhase) * 0.8 + Math.sin(s.curlPhase * 0.37) * 0.2; 356 357 // Check for cursor proximity 358 const cursorNear = isCursorNear(s); 359 360 if (cursorNear && !s.afraid && s.phase !== 'dead') { 361 // FEAR! Rapidly retract 362 s.afraid = true; 363 s.fearTime = 0; 364 s.phase = 'fleeing'; 365 s.targetExtension = 0; 366 } 367 368 // Update phase 369 s.phaseTime += dt; 370 371 switch (s.phase) { 372 case 'creeping': 373 // Slowly extend 374 s.extension = clamp(s.extension + CONFIG.creepSpeed * (dt / 1000), 0, 1); 375 s.alpha = clamp(s.alpha + 0.03 * (dt / 16), 0, CONFIG.maxAlpha); 376 377 if (s.extension >= 1) { 378 s.phase = 'lingering'; 379 s.phaseTime = 0; 380 } 381 break; 382 383 case 'lingering': 384 // Just wobble in place 385 s.alpha = CONFIG.maxAlpha; 386 if (s.phaseTime >= s.lingerDuration) { 387 s.phase = 'retreating'; 388 s.phaseTime = 0; 389 s.targetExtension = 0; 390 } 391 break; 392 393 case 'retreating': 394 // Slowly retract 395 s.extension = clamp(s.extension - CONFIG.retreatSpeed * (dt / 1000), 0, 1); 396 s.alpha = clamp(s.extension * CONFIG.maxAlpha, 0, CONFIG.maxAlpha); 397 398 if (s.extension <= 0) { 399 s.phase = 'dead'; 400 } 401 break; 402 403 case 'fleeing': 404 // Rapidly retract with increased wobble 405 s.extension = clamp(s.extension - CONFIG.fleeSpeed * (dt / 1000), 0, 1); 406 s.alpha = clamp(s.extension * CONFIG.maxAlpha, 0, CONFIG.maxAlpha); 407 408 // Extra frantic wobble when afraid 409 s.wobbleTime += CONFIG.wobbleSpeed * 3 * (dt / 16); 410 411 if (s.extension <= 0) { 412 s.phase = 'dead'; 413 } 414 break; 415 } 416} 417 418/** 419 * Main animation loop 420 */ 421function animate(timestamp) { 422 const dt = lastTime ? Math.min(timestamp - lastTime, 100) : 16; // cap dt to avoid jumps 423 lastTime = timestamp; 424 425 // Clear canvas 426 ctx.clearRect(0, 0, canvas.width, canvas.height); 427 428 // Update and draw all strands 429 for (const s of strands) { 430 updateStrand(s, dt); 431 if (s.phase !== 'dead') { 432 drawStrand(s); 433 } 434 } 435 436 // Remove dead strands 437 strands = strands.filter(s => s.phase !== 'dead'); 438 439 animationId = requestAnimationFrame(animate); 440} 441 442/** 443 * Edge rotation for spawning — cycles through edges to ensure strands 444 * come from at least 3 sides. Uses a shuffled queue that refills when empty. 445 */ 446let edgeQueue = []; 447 448function nextEdge() { 449 if (edgeQueue.length === 0) { 450 // Refill with all 4 edges, shuffled 451 edgeQueue = [0, 1, 2, 3]; 452 for (let i = edgeQueue.length - 1; i > 0; i--) { 453 const j = Math.floor(Math.random() * (i + 1)); 454 [edgeQueue[i], edgeQueue[j]] = [edgeQueue[j], edgeQueue[i]]; 455 } 456 } 457 return edgeQueue.pop(); 458} 459 460/** 461 * Spawn a new strand if under the limit 462 */ 463function spawnStrand() { 464 if (strands.length < CONFIG.maxStrands) { 465 strands.push(createStrand(nextEdge())); 466 } 467 scheduleNextSpawn(); 468} 469 470/** 471 * Schedule the next strand spawn 472 */ 473function scheduleNextSpawn() { 474 const delay = rand(CONFIG.spawnIntervalMin, CONFIG.spawnIntervalMax); 475 spawnTimer = setTimeout(spawnStrand, delay); 476} 477 478/** 479 * Resize canvas to fill the window 480 */ 481function resizeCanvas() { 482 const dpr = window.devicePixelRatio || 1; 483 canvas.width = window.innerWidth * dpr; 484 canvas.height = window.innerHeight * dpr; 485 canvas.style.width = window.innerWidth + 'px'; 486 canvas.style.height = window.innerHeight + 'px'; 487 ctx.scale(dpr, dpr); 488} 489 490/** 491 * Initialize 492 */ 493function init() { 494 resizeCanvas(); 495 window.addEventListener('resize', resizeCanvas); 496 497 // Track mouse position (forwarded even though window is click-through) 498 document.addEventListener('mousemove', (e) => { 499 mouseX = e.clientX; 500 mouseY = e.clientY; 501 }); 502 503 // Also handle mouse leaving the window 504 document.addEventListener('mouseleave', () => { 505 mouseX = -1000; 506 mouseY = -1000; 507 }); 508 509 // Start animation loop 510 animationId = requestAnimationFrame(animate); 511 512 // Spawn initial batch — guarantee at least one strand per edge, 513 // plus 2 extra from random edges, for immediate multi-side presence. 514 const initialEdges = [0, 1, 2, 3]; 515 // Shuffle so the order isn't predictable 516 for (let i = initialEdges.length - 1; i > 0; i--) { 517 const j = Math.floor(Math.random() * (i + 1)); 518 [initialEdges[i], initialEdges[j]] = [initialEdges[j], initialEdges[i]]; 519 } 520 for (const edge of initialEdges) { 521 strands.push(createStrand(edge)); 522 } 523 // Plus 6 extra from the rotating queue for a full initial presence 524 for (let i = 0; i < 6; i++) { 525 strands.push(createStrand(nextEdge())); 526 } 527 scheduleNextSpawn(); 528} 529 530init();