experiments in a post-browser web
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();