motion-library 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +37 -2
  3. package/anims/basics/anticipation.js +56 -0
  4. package/anims/basics/card-flip.js +39 -0
  5. package/anims/basics/easing-race.js +57 -0
  6. package/anims/basics/fade-scale.js +44 -0
  7. package/anims/basics/stagger-grid.js +43 -0
  8. package/anims/chrome/black-hole.js +81 -0
  9. package/anims/chrome/cloth-reveal.js +156 -0
  10. package/anims/chrome/dna-helix.js +52 -0
  11. package/anims/chrome/dust-bust.js +109 -0
  12. package/anims/chrome/extruded-type.js +64 -0
  13. package/anims/chrome/fractal-tree.js +75 -0
  14. package/anims/chrome/gem.js +111 -0
  15. package/anims/chrome/geode.js +102 -0
  16. package/anims/chrome/glass-lens.js +73 -0
  17. package/anims/chrome/glass-prism.js +95 -0
  18. package/anims/chrome/gyroid.js +69 -0
  19. package/anims/chrome/holo-card.js +52 -0
  20. package/anims/chrome/infinite-tunnel.js +81 -0
  21. package/anims/chrome/inflated-type.js +141 -0
  22. package/anims/chrome/isometric-city.js +97 -0
  23. package/anims/chrome/jelly-cube.js +329 -0
  24. package/anims/chrome/klein-bottle.js +80 -0
  25. package/anims/chrome/liquid-metal.js +94 -0
  26. package/anims/chrome/low-poly-planet.js +163 -0
  27. package/anims/chrome/mandelbulb.js +79 -0
  28. package/anims/chrome/melting-logo.js +126 -0
  29. package/anims/chrome/menger-sponge.js +79 -0
  30. package/anims/chrome/neon-geodesic.js +149 -0
  31. package/anims/chrome/paper-fold.js +135 -0
  32. package/anims/chrome/path-spheres.js +112 -0
  33. package/anims/chrome/point-globe.js +81 -0
  34. package/anims/chrome/portal.js +105 -0
  35. package/anims/chrome/rubiks-cube.js +74 -0
  36. package/anims/chrome/sdf-morph.js +129 -0
  37. package/anims/chrome/shatter.js +120 -0
  38. package/anims/chrome/soap-bubble.js +86 -0
  39. package/anims/chrome/terrain-flyover.js +78 -0
  40. package/anims/chrome/tesseract.js +148 -0
  41. package/anims/chrome/torus-knot.js +89 -0
  42. package/anims/chrome/volumetric-clouds.js +70 -0
  43. package/anims/flow/bio-shore.js +140 -0
  44. package/anims/flow/calligraphy.js +116 -0
  45. package/anims/flow/chladni.js +152 -0
  46. package/anims/flow/cloud-chamber.js +101 -0
  47. package/anims/flow/confetti.js +46 -0
  48. package/anims/flow/curl-flow.js +52 -0
  49. package/anims/flow/dandelion.js +80 -0
  50. package/anims/flow/dla-crystal.js +57 -0
  51. package/anims/flow/drone-show.js +357 -0
  52. package/anims/flow/embers.js +63 -0
  53. package/anims/flow/firefly-sync.js +107 -0
  54. package/anims/flow/galaxy.js +69 -0
  55. package/anims/flow/galton-board.js +155 -0
  56. package/anims/flow/gpu-particles.js +73 -0
  57. package/anims/flow/harmonograph.js +61 -0
  58. package/anims/flow/holi.js +153 -0
  59. package/anims/flow/iron-filings.js +112 -0
  60. package/anims/flow/lightning.js +72 -0
  61. package/anims/flow/murmuration.js +105 -0
  62. package/anims/flow/orbitals.js +195 -0
  63. package/anims/flow/particle-life.js +87 -0
  64. package/anims/flow/physarum.js +171 -0
  65. package/anims/flow/pin-art.js +181 -0
  66. package/anims/flow/rainbow.js +127 -0
  67. package/anims/flow/roche-limit.js +161 -0
  68. package/anims/flow/sand-mandala.js +121 -0
  69. package/anims/flow/scramble-crossing.js +152 -0
  70. package/anims/flow/snowfall.js +84 -0
  71. package/anims/flow/sparks.js +71 -0
  72. package/anims/flow/spider-web.js +123 -0
  73. package/anims/flow/starry-night.js +121 -0
  74. package/anims/flow/states-of-matter.js +113 -0
  75. package/anims/flow/strange-attractor.js +54 -0
  76. package/anims/flow/wheat-field.js +186 -0
  77. package/anims/flow/wind-tunnel.js +227 -0
  78. package/anims/fluid/bubbles.js +152 -0
  79. package/anims/fluid/caustics.js +45 -0
  80. package/anims/fluid/dripping-text.js +53 -0
  81. package/anims/fluid/ferrofluid.js +56 -0
  82. package/anims/fluid/fire-smoke.js +83 -0
  83. package/anims/fluid/honey-drip.js +167 -0
  84. package/anims/fluid/ink-drop.js +50 -0
  85. package/anims/fluid/ink-fluid.js +125 -0
  86. package/anims/fluid/lava-lamp.js +56 -0
  87. package/anims/fluid/liquid-distort.js +64 -0
  88. package/anims/fluid/liquid-glass-fill.js +53 -0
  89. package/anims/fluid/marbling.js +53 -0
  90. package/anims/fluid/mercury.js +177 -0
  91. package/anims/fluid/ocean-waves.js +50 -0
  92. package/anims/fluid/paint-swirl.js +153 -0
  93. package/anims/fluid/raindrops-glass.js +60 -0
  94. package/anims/fluid/ripples.js +57 -0
  95. package/anims/fluid/sph-water.js +75 -0
  96. package/anims/fluid/underwater.js +55 -0
  97. package/anims/fluid/water-surface-3d.js +98 -0
  98. package/anims/hover/cursor-trail.js +37 -0
  99. package/anims/hover/magnetic.js +41 -0
  100. package/anims/hover/ripple.js +31 -0
  101. package/anims/hover/spotlight.js +31 -0
  102. package/anims/hover/tilt.js +36 -0
  103. package/anims/micro/arc-spinner.js +22 -0
  104. package/anims/micro/like-burst.js +42 -0
  105. package/anims/micro/skeleton.js +36 -0
  106. package/anims/micro/submit-success.js +45 -0
  107. package/anims/micro/toggle.js +35 -0
  108. package/anims/morph/gooey.js +36 -0
  109. package/anims/morph/icon-morph.js +45 -0
  110. package/anims/morph/line-draw.js +34 -0
  111. package/anims/morph/liquid-button.js +53 -0
  112. package/anims/morph/shape-morph.js +61 -0
  113. package/anims/registry-more.js +327 -0
  114. package/anims/registry.js +294 -0
  115. package/anims/scroll/parallax.js +50 -0
  116. package/anims/scroll/pinned-track.js +40 -0
  117. package/anims/scroll/scrub-reveal.js +34 -0
  118. package/anims/scroll/velocity-skew.js +38 -0
  119. package/anims/scroll/zoom-through.js +41 -0
  120. package/anims/shaders/aurora.js +47 -0
  121. package/anims/shaders/dither.js +69 -0
  122. package/anims/shaders/domain-warp.js +52 -0
  123. package/anims/shaders/guilloche.js +42 -0
  124. package/anims/shaders/kaleidoscope.js +64 -0
  125. package/anims/shaders/reaction-diffusion.js +85 -0
  126. package/anims/shaders/rgb-glitch.js +56 -0
  127. package/anims/shaders/truchet.js +57 -0
  128. package/anims/shaders/voronoi.js +50 -0
  129. package/anims/springs/ball-collisions.js +47 -0
  130. package/anims/springs/pendulum-wave.js +35 -0
  131. package/anims/springs/rubber-band.js +50 -0
  132. package/anims/springs/squash-bounce.js +50 -0
  133. package/anims/springs/verlet-rope.js +46 -0
  134. package/anims/text/letter-rise.js +40 -0
  135. package/anims/text/line-mask.js +26 -0
  136. package/anims/text/scramble.js +32 -0
  137. package/anims/text/typewriter.js +35 -0
  138. package/anims/text/vf-wave.js +26 -0
  139. package/anims/transitions/circle-reveal.js +42 -0
  140. package/anims/transitions/push-slide.js +40 -0
  141. package/anims/transitions/shared-expand.js +43 -0
  142. package/anims/transitions/staircase-wipe.js +41 -0
  143. package/anims/transitions/view-transition.js +43 -0
  144. package/bin.mjs +155 -0
  145. package/js/code.js +122 -0
  146. package/package.json +10 -4
@@ -0,0 +1,152 @@
1
+ // Scramble crossing in the rain, from above: umbrellas wait on four corners while the cars go, then every light
2
+ // turns to walk at once and the crowds pour across in all directions, diagonals included, and stream through each
3
+ // other. Each walker follows the social force model: a pull toward where they're going and a soft push away from
4
+ // everyone near them. Nobody plans lanes, yet lanes form: people fall in behind others heading their way. The
5
+ // asphalt is wet, so signs and headlights smear across it, and rain rings it.
6
+ const PARAMS = { people: 260, rain: 1, pace: 1 };
7
+
8
+ export default function (stage) {
9
+ const canvas = document.createElement('canvas');
10
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;background:#101216';
11
+ stage.append(canvas);
12
+ const ctx = canvas.getContext('2d');
13
+ let W = 1, H = 1, S = 1, cx = 0, cy = 0, ground = null;
14
+ // the world, in metres: the crossing is a 24 m square of road, with corners of pavement beyond it
15
+ const R = 12, PAVE = 26;
16
+ const toX = x => cx + x * S, toY = y => cy + y * S;
17
+ function paintGround() {
18
+ ground = document.createElement('canvas'); ground.width = canvas.width; ground.height = canvas.height;
19
+ const g = ground.getContext('2d'), d = canvas.width / W; g.setTransform(d, 0, 0, d, 0, 0);
20
+ g.fillStyle = '#2a2c31'; g.fillRect(0, 0, W, H); // pavement
21
+ g.fillStyle = '#121418'; g.fillRect(toX(-R), 0, 2 * R * S, H); g.fillRect(0, toY(-R), W, 2 * R * S); // the two roads
22
+ for (let i = 0; i < 4000; i++) { g.fillStyle = `rgba(255,255,255,${Math.random() * 0.025})`; g.fillRect(Math.random() * W, Math.random() * H, 1 + Math.random() * 2, 1); }
23
+ // zebra crossings on all four sides, and the two diagonals
24
+ g.fillStyle = 'rgba(225,228,232,.78)';
25
+ const zebra = (x0, y0, x1, y1, w) => {
26
+ const len = Math.hypot(x1 - x0, y1 - y0), ux = (x1 - x0) / len, uy = (y1 - y0) / len, n = Math.floor(len / 1.1);
27
+ for (let i = 0; i < n; i++) { const x = x0 + ux * (i + 0.5) * 1.1, y = y0 + uy * (i + 0.5) * 1.1; g.save(); g.translate(toX(x), toY(y)); g.rotate(Math.atan2(uy, ux)); g.fillRect(-0.28 * S, -w / 2 * S, 0.56 * S, w * S); g.restore(); }
28
+ };
29
+ zebra(-R + 1, -R - 2.2, R - 1, -R - 2.2, 3.2); zebra(-R + 1, R + 2.2, R - 1, R + 2.2, 3.2); zebra(-R - 2.2, -R + 1, -R - 2.2, R - 1, 3.2); zebra(R + 2.2, -R + 1, R + 2.2, R - 1, 3.2);
30
+ g.globalAlpha = 0.6; zebra(-R + 2, -R + 2, R - 2, R - 2, 3); zebra(R - 2, -R + 2, -R + 2, R - 2, 3); g.globalAlpha = 1;
31
+ // neon signs along the pavement edges, and their smeared reflections on the wet road
32
+ const NEON = ['#ff3d7f', '#38e1ff', '#ffd23f', '#9b5cff', '#3dff9e'];
33
+ for (let i = 0; i < 18; i++) {
34
+ const c = NEON[i % NEON.length], side = i % 4, k = ((i * 0.6180339) % 1) * 2 - 1, edge = PAVE * 1.1;
35
+ const x = side < 2 ? k * edge : (side === 2 ? -1 : 1) * edge, y = side < 2 ? (side === 0 ? -1 : 1) * edge : k * edge;
36
+ const rg = g.createRadialGradient(toX(x), toY(y), 0, toX(x), toY(y), 9 * S); rg.addColorStop(0, c + '30'); rg.addColorStop(1, c + '00');
37
+ g.fillStyle = rg; g.fillRect(toX(x) - 9 * S, toY(y) - 9 * S, 18 * S, 18 * S);
38
+ }
39
+ }
40
+ const resize = () => {
41
+ const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1;
42
+ canvas.width = Math.max(1, (W * d) | 0); canvas.height = Math.max(1, (H * d) | 0); ctx.setTransform(d, 0, 0, d, 0, 0);
43
+ S = Math.min(W, H) / (2 * R + 13); cx = W / 2; cy = H / 2; paintGround();
44
+ };
45
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
46
+
47
+ // umbrellas from above: canopy panels with ribs, a few clear plastic ones, one sprite per colour
48
+ const COLS = ['#16181c', '#1d2230', '#26262a', '#0f1a2a', '#3b0f19', '#c8263a', '#f2b632', '#2a9d8f', '#e9e5dc', 'clear', 'clear', 'clear', '#16181c', '#3c3f46'];
49
+ const umbrella = c => {
50
+ const s = document.createElement('canvas'), n = 64; s.width = s.height = n; const g = s.getContext('2d'), r = n / 2 - 2;
51
+ const clear = c === 'clear';
52
+ for (let k = 0; k < 8; k++) { // eight panels, alternately lit
53
+ const a0 = (k / 8) * Math.PI * 2, a1 = ((k + 1) / 8) * Math.PI * 2;
54
+ g.beginPath(); g.moveTo(n / 2, n / 2); g.arc(n / 2, n / 2, r, a0, a1); g.closePath();
55
+ g.fillStyle = clear ? `rgba(205,225,240,${k % 2 ? 0.28 : 0.38})` : c; g.fill();
56
+ if (!clear) { g.fillStyle = `rgba(255,255,255,${k % 2 ? 0.04 : 0.12})`; g.fill(); }
57
+ }
58
+ g.strokeStyle = clear ? 'rgba(235,245,255,.6)' : 'rgba(0,0,0,.35)'; g.lineWidth = 1.2;
59
+ for (let k = 0; k < 8; k++) { const a = (k / 8) * Math.PI * 2; g.beginPath(); g.moveTo(n / 2, n / 2); g.lineTo(n / 2 + Math.cos(a) * r, n / 2 + Math.sin(a) * r); g.stroke(); }
60
+ g.beginPath(); g.arc(n / 2, n / 2, r, 0, 7); g.stroke();
61
+ const hl = g.createRadialGradient(n * 0.38, n * 0.36, 0, n / 2, n / 2, r); hl.addColorStop(0, 'rgba(255,255,255,.22)'); hl.addColorStop(1, 'rgba(255,255,255,0)');
62
+ g.fillStyle = hl; g.beginPath(); g.arc(n / 2, n / 2, r, 0, 7); g.fill();
63
+ g.fillStyle = clear ? '#d8e6f0' : '#c9c9c9'; g.beginPath(); g.arc(n / 2, n / 2, 2.2, 0, 7); g.fill();
64
+ return s;
65
+ };
66
+ const SPR = COLS.map(umbrella);
67
+
68
+ // the four corners where people wait (centre of each waiting area)
69
+ const CORNERS = [[-R - 3.5, -R - 3.5], [R + 3.5, -R - 3.5], [R + 3.5, R + 3.5], [-R - 3.5, R + 3.5]];
70
+ const spotIn = c => { const [x, y] = CORNERS[c]; return [x + Math.sign(x) * Math.random() * 7 - Math.sign(x) * 1, y + Math.sign(y) * Math.random() * 7 - Math.sign(y) * 1]; };
71
+ const N = Math.max(40, Math.round(PARAMS.people));
72
+ const P = Array.from({ length: N }, (_, i) => { const c = i % 4, [x, y] = spotIn(c); return { x, y, vx: 0, vy: 0, c, gx: x, gy: y, v0: 1.6 + Math.random() * 0.7, spr: (Math.random() * SPR.length) | 0, rot: Math.random() * 6.28, r: 0.36 + Math.random() * 0.1 }; });
73
+ // cars, only while the cars have the green
74
+ const cars = [];
75
+ const CYCLE = 26, CARS = 11; // seconds per cycle; the first 11 belong to the cars
76
+ let lastPhase = '';
77
+
78
+ const drops = Array.from({ length: 220 }, () => ({ x: Math.random(), y: Math.random(), s: 0.7 + Math.random() * 0.6 }));
79
+ const rings = [];
80
+ let raf, last = performance.now(), time = 0, grid = new Map();
81
+ function frame(now) {
82
+ const dt = Math.min(0.04, (now - last) / 1000) * PARAMS.pace; last = now; time += dt;
83
+ const ct = time % CYCLE, phase = ct < CARS ? 'cars' : ct < CYCLE - 3 ? 'walk' : 'flash';
84
+ if (phase === 'walk' && lastPhase === 'cars') for (const p of P) { let d = (p.c + 1 + ((Math.random() * 3) | 0)) % 4; p.c = d; [p.gx, p.gy] = spotIn(d); } // everyone picks another corner
85
+ if (phase === 'cars' && lastPhase !== 'cars') cars.length = 0;
86
+ lastPhase = phase;
87
+ // cars: north–south for the first half of their green, then east–west
88
+ if (phase === 'cars' && ct < CARS - 3 && Math.random() < dt * 2.4) { // the last cars clear the box before the walk
89
+ const ns = ct < CARS / 2, lane = (Math.random() < 0.5 ? -1 : 1), off = lane * (Math.random() < 0.5 ? 3 : 7.5);
90
+ cars.push(ns ? { x: off, y: -lane * (PAVE + 10), vx: 0, vy: lane * 15, ns } : { x: lane * (PAVE + 10), y: off, vx: -lane * 15, vy: 0, ns });
91
+ }
92
+ for (const c of cars) { c.x += c.vx * dt; c.y += c.vy * dt; }
93
+ // walkers: the social force model, neighbours found through a 2 m grid
94
+ grid.clear();
95
+ for (let i = 0; i < N; i++) { const k = Math.floor(P[i].x / 2) * 1000 + Math.floor(P[i].y / 2); let a = grid.get(k); if (!a) grid.set(k, a = []); a.push(i); }
96
+ for (let i = 0; i < N; i++) {
97
+ const p = P[i];
98
+ let dx = p.gx - p.x, dy = p.gy - p.y; const dist = Math.hypot(dx, dy) + 1e-6, hurry = phase === 'flash' ? 1.5 : 1;
99
+ const want = dist > 0.6 ? p.v0 * hurry : 0;
100
+ let ax = ((dx / dist) * want - p.vx) / 0.5, ay = ((dy / dist) * want - p.vy) / 0.5;
101
+ const gx = Math.floor(p.x / 2), gy = Math.floor(p.y / 2);
102
+ for (let ox = -1; ox <= 1; ox++) for (let oy = -1; oy <= 1; oy++) {
103
+ const a = grid.get((gx + ox) * 1000 + gy + oy); if (!a) continue;
104
+ for (const j of a) {
105
+ if (j === i) continue;
106
+ const q = P[j], ex = p.x - q.x, ey = p.y - q.y, d = Math.hypot(ex, ey) + 1e-6, rr = p.r + q.r;
107
+ const f = 2.6 * Math.exp((rr - d) / 0.35);
108
+ // people look ahead: someone in front pushes harder than someone behind
109
+ const ahead = ((-ex) * p.vx + (-ey) * p.vy) / (d * (Math.hypot(p.vx, p.vy) + 1e-6)), wgt = 0.45 + 0.55 * Math.max(0, ahead);
110
+ ax += (ex / d) * f * wgt; ay += (ey / d) * f * wgt;
111
+ if (d < rr) { const push = (rr - d) * 0.5; p.x += (ex / d) * push; p.y += (ey / d) * push; }
112
+ }
113
+ }
114
+ p.vx += ax * dt; p.vy += ay * dt;
115
+ const sp = Math.hypot(p.vx, p.vy), mx = p.v0 * 1.4; if (sp > mx) { p.vx *= mx / sp; p.vy *= mx / sp; }
116
+ p.x += p.vx * dt; p.y += p.vy * dt;
117
+ if (sp > 0.2) { let da = Math.atan2(p.vy, p.vx) - p.rot; da -= Math.PI * 2 * Math.round(da / (Math.PI * 2)); p.rot += da * Math.min(1, dt * 2); }
118
+ }
119
+ // draw: wet ground, headlight smears, then umbrellas, then the rain
120
+ ctx.globalCompositeOperation = 'source-over'; ctx.drawImage(ground, 0, 0, W, H);
121
+ for (const c of cars) {
122
+ const x = toX(c.x), y = toY(c.y), ang = Math.atan2(c.vy, c.vx);
123
+ ctx.save(); ctx.translate(x, y); ctx.rotate(ang);
124
+ const beam = ctx.createLinearGradient(2.2 * S, 0, 14 * S, 0); beam.addColorStop(0, 'rgba(255,245,220,.28)'); beam.addColorStop(1, 'rgba(255,245,220,0)');
125
+ ctx.fillStyle = beam; ctx.beginPath(); ctx.moveTo(2.2 * S, -0.8 * S); ctx.lineTo(14 * S, -3 * S); ctx.lineTo(14 * S, 3 * S); ctx.lineTo(2.2 * S, 0.8 * S); ctx.fill();
126
+ ctx.fillStyle = '#0b0c0f'; ctx.beginPath(); ctx.roundRect(-2.2 * S, -0.95 * S, 4.4 * S, 1.9 * S, 0.5 * S); ctx.fill();
127
+ ctx.fillStyle = 'rgba(160,180,200,.18)'; ctx.fillRect(-0.6 * S, -0.8 * S, 1.6 * S, 1.6 * S);
128
+ ctx.fillStyle = '#ff3b3b'; ctx.fillRect(-2.25 * S, -0.8 * S, 0.18 * S, 0.4 * S); ctx.fillRect(-2.25 * S, 0.4 * S, 0.18 * S, 0.4 * S);
129
+ ctx.restore();
130
+ }
131
+ for (const p of P) {
132
+ const r = p.r * 1.45 * S;
133
+ ctx.fillStyle = 'rgba(0,0,0,.28)'; ctx.beginPath(); ctx.arc(toX(p.x) + r * 0.18, toY(p.y) + r * 0.22, r * 0.95, 0, 7); ctx.fill(); // shadow
134
+ ctx.save(); ctx.translate(toX(p.x), toY(p.y)); ctx.rotate(p.rot + Math.sin(time * 6 + p.v0 * 9) * 0.06 * Math.min(1, Math.hypot(p.vx, p.vy)));
135
+ ctx.drawImage(SPR[p.spr], -r, -r, r * 2, r * 2); ctx.restore();
136
+ }
137
+ // the walk signal
138
+ const lamp = phase === 'walk' ? '#3dff9e' : phase === 'flash' ? (Math.floor(time * 4) % 2 ? '#3dff9e' : '#1a2a22') : '#ff4d4d';
139
+ for (const [x, y] of CORNERS) { ctx.fillStyle = lamp; ctx.shadowColor = lamp; ctx.shadowBlur = 10; ctx.beginPath(); ctx.arc(toX(x * 0.82), toY(y * 0.82), Math.max(2.5, S * 0.35), 0, 7); ctx.fill(); }
140
+ ctx.shadowBlur = 0;
141
+ // rain: streaks, and rings where drops land
142
+ const rate = PARAMS.rain;
143
+ ctx.strokeStyle = 'rgba(200,215,235,.22)'; ctx.lineWidth = 1; ctx.beginPath();
144
+ for (const d of drops) { d.y += dt * 2.2 * d.s; if (d.y > 1) { d.y -= 1; d.x = Math.random(); if (Math.random() < rate) rings.push({ x: d.x * W, y: Math.random() * H, t: 0 }); } if (Math.random() > rate) continue; const x = d.x * W + d.y * 18, y = d.y * H; ctx.moveTo(x, y); ctx.lineTo(x + 2.2, y + 11 * d.s); }
145
+ ctx.stroke();
146
+ ctx.lineWidth = 1;
147
+ for (let i = rings.length - 1; i >= 0; i--) { const r = rings[i]; r.t += dt; if (r.t > 0.5) { rings.splice(i, 1); continue; } ctx.strokeStyle = `rgba(210,225,240,${0.35 * (1 - r.t / 0.5)})`; ctx.beginPath(); ctx.ellipse(r.x, r.y, 1 + r.t * 14, (1 + r.t * 14) * 0.6, 0, 0, 7); ctx.stroke(); }
148
+ raf = requestAnimationFrame(frame);
149
+ }
150
+ raf = requestAnimationFrame(frame);
151
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
152
+ }
@@ -0,0 +1,84 @@
1
+ // Snowfall at night: three depths of flakes under a street lamp. Far flakes are tiny and slow, near ones large, soft
2
+ // and fast (parallax sells the depth). Gusts push every layer, more so the near ones, and each flake sways on its own
3
+ // phase. Flakes inside the lamp's cone light up. Near flakes that land build a drift along the ground, column by column.
4
+ const PARAMS = { flakes: 700, wind: 0.6 };
5
+
6
+ export default function (stage) {
7
+ const canvas = document.createElement('canvas');
8
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
9
+ stage.append(canvas);
10
+ const ctx = canvas.getContext('2d');
11
+ let W = 0, H = 0, ground = new Float32Array(1), COL = 4;
12
+ const resize = () => {
13
+ const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1;
14
+ canvas.width = W * d; canvas.height = H * d; ctx.setTransform(d, 0, 0, d, 0, 0);
15
+ ground = new Float32Array(Math.ceil(W / COL) + 2);
16
+ };
17
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
18
+
19
+ // one soft sprite per depth band, drawn once (a radial falloff reads as an out-of-focus flake)
20
+ const sprite = (r, soft) => {
21
+ const c = document.createElement('canvas'), s = Math.ceil(r * 2 + 4); c.width = c.height = s;
22
+ const g = c.getContext('2d'), gr = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, r);
23
+ gr.addColorStop(0, 'rgba(255,255,255,1)'); gr.addColorStop(soft, 'rgba(240,246,255,.85)'); gr.addColorStop(1, 'rgba(230,240,255,0)');
24
+ g.fillStyle = gr; g.beginPath(); g.arc(s / 2, s / 2, r, 0, 7); g.fill();
25
+ return c;
26
+ };
27
+ const sprites = [sprite(2, .5), sprite(4, .35), sprite(9, .15)];
28
+
29
+ const make = top => { const z = Math.random() ** 1.6; return { x: Math.random() * 1.2 - 0.1, y: top ? -0.05 - Math.random() * 0.1 : Math.random(), z, ph: Math.random() * 6.28, sw: 0.5 + Math.random() } };
30
+ const flakes = Array.from({ length: Math.round(PARAMS.flakes) }, () => make(false));
31
+ const lamp = { x: 0.72, top: 0.22 };
32
+ let raf, last = performance.now();
33
+ function frame(now) {
34
+ const dt = Math.min(0.05, (now - last) / 1000); last = now;
35
+ const t = now / 1000, lx = lamp.x * W, ly = lamp.top * H;
36
+ // night sky and a far ridge
37
+ const sky = ctx.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#0a1020'); sky.addColorStop(1, '#1f2b40');
38
+ ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H);
39
+ ctx.fillStyle = '#141d2e'; ctx.beginPath(); ctx.moveTo(0, H);
40
+ for (let x = 0; x <= W; x += 8) ctx.lineTo(x, H * 0.78 - Math.sin(x * 0.006) * H * 0.04 - Math.sin(x * 0.017 + 1) * H * 0.02);
41
+ ctx.lineTo(W, H); ctx.fill();
42
+ // the lamp's cone of light
43
+ const cone = ctx.createRadialGradient(lx, ly, 0, lx, ly, H * 0.85);
44
+ cone.addColorStop(0, 'rgba(255,214,150,.38)'); cone.addColorStop(1, 'rgba(255,190,120,0)');
45
+ ctx.fillStyle = cone; ctx.beginPath(); ctx.moveTo(lx - 6, ly); ctx.lineTo(lx + 6, ly); ctx.lineTo(lx + H * 0.42, H); ctx.lineTo(lx - H * 0.42, H); ctx.fill();
46
+ ctx.fillStyle = '#0b0f18'; ctx.fillRect(lx - 2.5, ly, 5, H - ly); ctx.fillRect(lx - 14, ly - 6, 28, 7);
47
+ ctx.fillStyle = '#ffe2b0'; ctx.shadowColor = '#ffcf87'; ctx.shadowBlur = 24; ctx.fillRect(lx - 10, ly, 20, 4); ctx.shadowBlur = 0;
48
+
49
+ const gust = Math.sin(t * 0.23) * 0.6 + Math.sin(t * 0.61 + 1) * 0.3 + Math.sin(t * 1.7) * 0.1; // layered swells, never a loop you can spot
50
+ const wind = PARAMS.wind * (30 + 70 * gust);
51
+ for (const f of flakes) {
52
+ const v = 18 + f.z * 70;
53
+ f.x += ((wind * (0.35 + f.z) + Math.sin(t * f.sw + f.ph) * (8 + f.z * 20)) * dt) / W;
54
+ f.y += (v * dt) / H;
55
+ const gx = Math.min(ground.length - 1, Math.max(0, Math.round((f.x * W) / COL)));
56
+ const floor = H - ground[gx];
57
+ if (f.y * H >= floor - (f.z > 0.55 ? 0 : H * 0.2 * (1 - f.z))) {
58
+ // near flakes settle into the drift; the drift's top is smoothed so it piles in soft mounds
59
+ if (f.z > 0.55 && f.y * H >= floor && ground[gx] < H * 0.13) {
60
+ ground[gx] += 0.35;
61
+ if (gx > 0) ground[gx - 1] += 0.12;
62
+ if (gx < ground.length - 1) ground[gx + 1] += 0.12;
63
+ }
64
+ Object.assign(f, make(true));
65
+ }
66
+ if (f.x > 1.12) f.x -= 1.24; else if (f.x < -0.12) f.x += 1.24;
67
+ const x = f.x * W, y = f.y * H, band = f.z < 0.35 ? 0 : f.z < 0.75 ? 1 : 2, sp = sprites[band];
68
+ const dx = (x - lx) / Math.max(1, y - ly), inCone = y > ly && Math.abs(dx) < 0.42 ? 1 - Math.abs(dx) / 0.42 : 0;
69
+ ctx.globalAlpha = Math.min(1, (0.25 + f.z * 0.75) * (0.55 + inCone * 0.9));
70
+ const s = (0.5 + f.z) * sp.width * (band === 2 ? 0.8 : 1);
71
+ ctx.drawImage(sp, x - s / 2, y - s / 2, s, s);
72
+ }
73
+ ctx.globalAlpha = 1;
74
+ // the drift, smoothed each frame so it slumps instead of stacking columns
75
+ for (let i = 1; i < ground.length - 1; i++) ground[i] += ((ground[i - 1] + ground[i + 1]) / 2 - ground[i]) * 0.04;
76
+ const gr = ctx.createLinearGradient(0, H - H * 0.14, 0, H); gr.addColorStop(0, '#dbe6f5'); gr.addColorStop(1, '#8796b3');
77
+ ctx.fillStyle = gr; ctx.beginPath(); ctx.moveTo(0, H);
78
+ for (let i = 0; i < ground.length; i++) ctx.lineTo(i * COL, H - ground[i] - 2);
79
+ ctx.lineTo(W, H); ctx.fill();
80
+ raf = requestAnimationFrame(frame);
81
+ }
82
+ raf = requestAnimationFrame(frame);
83
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
84
+ }
@@ -0,0 +1,71 @@
1
+ // Grinder sparks: hot particles thrown from a spinning disc, pulled down by gravity, bouncing off the floor with most
2
+ // of their speed lost, and now and then bursting into smaller sparks. Each is drawn as a streak from where it was a
3
+ // moment ago (motion blur), and its colour follows its temperature: white, yellow, orange, then a dull red as it cools.
4
+ const PARAMS = { rate: 320, gravity: 1 };
5
+
6
+ export default function (stage) {
7
+ const canvas = document.createElement('canvas');
8
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
9
+ stage.append(canvas);
10
+ const ctx = canvas.getContext('2d');
11
+ let W = 0, H = 0;
12
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1; canvas.width = W * d; canvas.height = H * d; ctx.setTransform(d, 0, 0, d, 0, 0); };
13
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
14
+
15
+ const MAX = 2600, p = [];
16
+ // temperature 1..0 to a blackbody-ish colour
17
+ const heat = k => k > 0.8 ? [255, 250, 235] : k > 0.55 ? [255, 214 + (k - 0.55) * 140, 120 + (k - 0.55) * 400] : k > 0.3 ? [255, 120 + (k - 0.3) * 376, 40 + (k - 0.3) * 320] : [140 + k * 380, 30 + k * 300, 10];
18
+ let raf, last = performance.now(), carry = 0;
19
+ function emit(x, y, ang, speed, temp, small) {
20
+ if (p.length >= MAX) return;
21
+ p.push({ x, y, px: x, py: y, vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed, k: temp, life: 0, small, split: !small && Math.random() < 0.06 });
22
+ }
23
+ function frame(now) {
24
+ const dt = Math.min(0.033, (now - last) / 1000); last = now;
25
+ const t = now / 1000, S = Math.min(W, H), floor = H * 0.86, g = 900 * PARAMS.gravity * (S / 500);
26
+ // the contact point sweeps slowly along the work piece
27
+ const cx = W * (0.32 + Math.sin(t * 0.5) * 0.08), cy = H * 0.42 + Math.sin(t * 1.3) * H * 0.02;
28
+ carry += PARAMS.rate * dt;
29
+ for (; carry >= 1; carry--) {
30
+ const a = -0.2 + (Math.random() - 0.5) * 0.55, sp = S * (1.1 + Math.random() * 1.4); // thrown off tangentially, in a fan
31
+ emit(cx, cy, a, sp, 0.85 + Math.random() * 0.15, false);
32
+ }
33
+ // a dark workshop: fade instead of clearing, so the brightest streaks leave a short glow
34
+ ctx.globalCompositeOperation = 'source-over';
35
+ ctx.fillStyle = 'rgba(12,10,12,.42)'; ctx.fillRect(0, 0, W, H);
36
+ const fl = ctx.createLinearGradient(0, floor, 0, H); fl.addColorStop(0, 'rgba(40,30,28,.5)'); fl.addColorStop(1, 'rgba(14,11,12,.5)');
37
+ ctx.fillStyle = fl; ctx.fillRect(0, floor, W, H - floor);
38
+ // the work piece and the grinder
39
+ ctx.fillStyle = '#2a2a2e'; ctx.fillRect(cx - W, cy + 3, W, H * 0.05);
40
+ ctx.save(); ctx.translate(cx + S * 0.05, cy - S * 0.07); ctx.rotate(t * 40);
41
+ ctx.fillStyle = '#3a3a40'; ctx.beginPath(); ctx.arc(0, 0, S * 0.085, 0, 7); ctx.fill();
42
+ ctx.strokeStyle = '#55555c'; ctx.lineWidth = 2; for (let i = 0; i < 6; i++) { ctx.rotate(1.047); ctx.beginPath(); ctx.moveTo(S * 0.02, 0); ctx.lineTo(S * 0.08, 0); ctx.stroke(); }
43
+ ctx.restore();
44
+ ctx.globalCompositeOperation = 'lighter';
45
+ // the glow where the disc bites
46
+ const gl = ctx.createRadialGradient(cx, cy, 0, cx, cy, S * 0.12); gl.addColorStop(0, 'rgba(255,220,160,.9)'); gl.addColorStop(1, 'rgba(255,120,40,0)');
47
+ ctx.fillStyle = gl; ctx.fillRect(cx - S * 0.12, cy - S * 0.12, S * 0.24, S * 0.24);
48
+ ctx.lineCap = 'round';
49
+ for (let i = p.length - 1; i >= 0; i--) {
50
+ const s = p[i];
51
+ s.px = s.x; s.py = s.y;
52
+ s.vy += g * dt; s.vx *= 1 - 0.5 * dt;
53
+ s.x += s.vx * dt; s.y += s.vy * dt;
54
+ s.life += dt; s.k -= dt * (s.small ? 1.5 : 0.75);
55
+ if (s.y > floor) { s.y = floor; s.vy *= -0.32; s.vx *= 0.7; s.k -= 0.12; } // the floor eats most of the energy
56
+ if (s.split && s.life > 0.25 + Math.random() * 0.3) { // a burst: three or four smaller sparks
57
+ s.split = false;
58
+ for (let j = 0; j < 3 + (Math.random() * 2 | 0); j++) emit(s.x, s.y, Math.random() * 6.28, S * (0.25 + Math.random() * 0.4), Math.min(1, s.k + 0.2), true);
59
+ }
60
+ if (s.k <= 0 || s.x < -50 || s.x > W + 50) { p[i] = p[p.length - 1]; p.pop(); continue; }
61
+ const [r, gg, b] = heat(s.k);
62
+ ctx.strokeStyle = `rgba(${r | 0},${gg | 0},${b | 0},${Math.min(1, s.k * 1.4)})`;
63
+ ctx.lineWidth = (s.small ? 0.9 : 1.6) * (0.6 + s.k);
64
+ ctx.beginPath(); ctx.moveTo(s.px - s.vx * dt * 0.8, s.py - s.vy * dt * 0.8); ctx.lineTo(s.x, s.y); ctx.stroke();
65
+ if (s.y >= floor - 1 && s.k > 0.3) { ctx.fillStyle = `rgba(255,140,60,${s.k * 0.08})`; ctx.fillRect(s.x - 6, floor, 12, 3); } // the glow they cast on the floor
66
+ }
67
+ raf = requestAnimationFrame(frame);
68
+ }
69
+ raf = requestAnimationFrame(frame);
70
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
71
+ }
@@ -0,0 +1,123 @@
1
+ // Spider web at dawn: an orb weaver builds its web in the order real ones do. A bridge line, a frame, radial
2
+ // spokes out from the hub one at a time, then the sticky capture spiral, laid from the outside in as a chain of
3
+ // straight steps between spokes. Then dew condenses on the spiral: the beads weigh each strand down into a little
4
+ // curve, and every bead is a lens that catches the low sun. Silk glints where it lies across the light, with a faint
5
+ // rainbow sheen. A breeze rocks the whole web; then a fresh morning and a fresh web.
6
+ const PARAMS = { spokes: 30, turns: 34, breeze: 1 };
7
+
8
+ export default function (stage) {
9
+ const canvas = document.createElement('canvas');
10
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;background:#1d2a20';
11
+ stage.append(canvas);
12
+ const ctx = canvas.getContext('2d');
13
+ let W = 1, H = 1, bg = null, web = null;
14
+ const rnd = (a, b) => a + Math.random() * (b - a);
15
+ // a garden out of focus at dawn: dark leaves, warm light coming in from the upper left, soft bokeh
16
+ function paintBg() {
17
+ const d = Math.min(devicePixelRatio, 2); bg = document.createElement('canvas'); bg.width = canvas.width; bg.height = canvas.height;
18
+ const g = bg.getContext('2d'); g.setTransform(d, 0, 0, d, 0, 0);
19
+ const lg = g.createLinearGradient(0, 0, W, H); lg.addColorStop(0, '#5c5a33'); lg.addColorStop(0.45, '#22301f'); lg.addColorStop(1, '#0e1a14');
20
+ g.fillStyle = lg; g.fillRect(0, 0, W, H);
21
+ for (let i = 0; i < 40; i++) {
22
+ const x = rnd(-0.1, 1.1) * W, y = rnd(-0.1, 1.1) * H, r = rnd(0.03, 0.12) * Math.min(W, H), warm = 1 - (x / W + y / H) / 2;
23
+ const c = warm > 0.55 ? `rgba(255,214,140,${rnd(0.08, 0.22)})` : `rgba(120,170,110,${rnd(0.04, 0.12)})`;
24
+ const rg = g.createRadialGradient(x, y, r * 0.7, x, y, r); rg.addColorStop(0, c); rg.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = rg; g.beginPath(); g.arc(x, y, r, 0, 7); g.fill();
25
+ }
26
+ // twigs at the edges holding the web
27
+ g.strokeStyle = '#120d08'; g.lineCap = 'round';
28
+ for (const [x0, y0, x1, y1, w] of [[-0.05, 0.06, 0.32, 0.12, 9], [0.62, -0.05, 1.05, 0.2, 8], [0.97, 0.3, 0.86, 1.05, 10], [-0.05, 0.85, 0.24, 1.05, 9]]) { g.lineWidth = w; g.beginPath(); g.moveTo(x0 * W, y0 * H); g.quadraticCurveTo(((x0 + x1) / 2 + 0.03) * W, ((y0 + y1) / 2 - 0.03) * H, x1 * W, y1 * H); g.stroke(); }
29
+ }
30
+ // build a new web: anchors on the twigs, a hub, spokes, and the spiral as steps between spokes
31
+ function plan() {
32
+ const hub = [W * rnd(0.47, 0.55), H * rnd(0.45, 0.53)];
33
+ const anchors = [[0.2 * W, 0.105 * H], [0.86 * W, 0.12 * H], [0.92 * W, 0.62 * H], [0.12 * W, 0.93 * H]];
34
+ const frame = anchors.map(([x, y], i) => { const [nx, ny] = anchors[(i + 1) % 4]; return [x, y, nx, ny]; });
35
+ const S = Math.max(8, Math.round(PARAMS.spokes)), spokes = [];
36
+ // where a ray from the hub meets the frame
37
+ const reach = a => { let best = 1e9; const dx = Math.cos(a), dy = Math.sin(a);
38
+ for (const [x1, y1, x2, y2] of frame) { const ex = x2 - x1, ey = y2 - y1, den = dx * ey - dy * ex; if (Math.abs(den) < 1e-9) continue; const t = ((x1 - hub[0]) * ey - (y1 - hub[1]) * ex) / den, u = ((x1 - hub[0]) * dy - (y1 - hub[1]) * dx) / den; if (t > 0 && u >= 0 && u <= 1) best = Math.min(best, t); }
39
+ return best; };
40
+ for (let i = 0; i < S; i++) { const a = (i / S) * Math.PI * 2 + rnd(-0.05, 0.05); spokes.push({ a, r: reach(a) }); }
41
+ const T = Math.max(8, Math.round(PARAMS.turns)), spiral = [];
42
+ // outside in: radius at each spoke shrinks a little every step
43
+ const steps = T * S;
44
+ // near the rim the spiral follows the frame; toward the hub it rounds into circles, as real ones do
45
+ const rmin = Math.min(...spokes.map(s => s.r));
46
+ for (let k = 0; k <= steps; k++) { const s = spokes[k % S], f = 0.9 - (k / steps) * 0.76, shape = Math.pow(f / 0.9, 1.6); spiral.push({ a: s.a, f, r: f * (rmin + (s.r - rmin) * shape) }); }
47
+ const dew = [];
48
+ for (let k = 0; k < spiral.length - 1; k++) { const n = 1 + ((Math.random() * 3) | 0); for (let j = 0; j < n; j++) dew.push({ k, t: (j + Math.random()) / n, r: rnd(0.8, 2.6) * (Math.random() < 0.08 ? 2 : 1), d: Math.random() }); }
49
+ web = { hub, anchors, frame, spokes, spiral, dew, t: 0 };
50
+ }
51
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1; canvas.width = Math.max(1, (W * d) | 0); canvas.height = Math.max(1, (H * d) | 0); ctx.setTransform(d, 0, 0, d, 0, 0); paintBg(); plan(); };
52
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
53
+
54
+ // one dew bead, drawn once: a lens showing the bright sky upside down, a dark rim, and a glint
55
+ const bead = document.createElement('canvas'); bead.width = bead.height = 32;
56
+ { const g = bead.getContext('2d'), lens = g.createRadialGradient(16, 21, 0, 16, 16, 15); lens.addColorStop(0, 'rgba(255,236,190,.9)'); lens.addColorStop(0.7, 'rgba(160,190,150,.5)'); lens.addColorStop(1, 'rgba(20,30,25,.65)');
57
+ g.fillStyle = lens; g.beginPath(); g.arc(16, 16, 15, 0, 7); g.fill(); g.fillStyle = 'rgba(255,255,255,.95)'; g.beginPath(); g.arc(10.5, 10.3, 4.2, 0, 7); g.fill(); }
58
+ const SUN = Math.atan2(1, 1.3); // direction of the light across the picture
59
+ // silk glints where a thread lies across the light, with a faint diffraction rainbow
60
+ const silk = (x1, y1, x2, y2, base) => {
61
+ const a = Math.atan2(y2 - y1, x2 - x1), g = Math.pow(Math.abs(Math.sin(a - SUN)), 6);
62
+ const hue = (a * 180 / Math.PI * 3 + 360) % 360;
63
+ return g > 0.25 ? `hsla(${hue},70%,86%,${base + g * 0.5})` : `rgba(225,232,236,${base})`;
64
+ };
65
+ let raf, last = performance.now(), time = 0;
66
+ function frame(now) {
67
+ const dt = Math.min(0.05, (now - last) / 1000); last = now; time += dt;
68
+ const w = web; w.t += dt;
69
+ // the timeline: frame 0–3 s, spokes 3–9 s, spiral 9–21 s, dew 21–26 s, rest, then a new morning
70
+ const T = w.t, tFrame = Math.min(1, T / 3), tSpoke = Math.min(1, Math.max(0, (T - 3) / 6)), tSpiral = Math.min(1, Math.max(0, (T - 9) / 12)), tDew = Math.min(1, Math.max(0, (T - 20.5) / 5));
71
+ const fade = T > 33 ? Math.max(0, 1 - (T - 33) / 1.5) : 1;
72
+ if (T > 34.5) plan();
73
+ // breeze: the web rocks a little about the top
74
+ const sway = Math.sin(time * 0.9) * 0.012 * PARAMS.breeze + Math.sin(time * 2.3) * 0.004 * PARAMS.breeze;
75
+ const P = (x, y) => [x + (y - w.hub[1] + H * 0.4) * sway, y];
76
+ ctx.globalAlpha = 1; ctx.drawImage(bg, 0, 0, W, H);
77
+ ctx.globalAlpha = fade; ctx.lineCap = 'round';
78
+ let tip = null;
79
+ // frame lines
80
+ ctx.lineWidth = 1.1;
81
+ w.frame.forEach(([x1, y1, x2, y2], i) => {
82
+ const k = Math.min(1, Math.max(0, tFrame * 4 - i)); if (k <= 0) return;
83
+ const [a, b] = P(x1, y1), [c, d] = P(x1 + (x2 - x1) * k, y1 + (y2 - y1) * k);
84
+ ctx.strokeStyle = silk(a, b, c, d, 0.42); ctx.beginPath(); ctx.moveTo(a, b); ctx.lineTo(c, d); ctx.stroke(); if (k < 1) tip = [c, d];
85
+ });
86
+ // spokes, one after another (not in order round the hub: a spider alternates sides to keep the tension even)
87
+ const S = w.spokes.length, order = Array.from({ length: S }, (_, i) => (i % 2 ? Math.floor(i / 2) + Math.ceil(S / 2) : i / 2) | 0);
88
+ ctx.lineWidth = 0.9;
89
+ for (let n = 0; n < S; n++) {
90
+ const k = Math.min(1, Math.max(0, tSpoke * S - n)); if (k <= 0) break;
91
+ const s = w.spokes[order[n] % S], [a, b] = P(w.hub[0], w.hub[1]), [c, d] = P(w.hub[0] + Math.cos(s.a) * s.r * k, w.hub[1] + Math.sin(s.a) * s.r * k);
92
+ ctx.strokeStyle = silk(a, b, c, d, 0.36); ctx.beginPath(); ctx.moveTo(a, b); ctx.lineTo(c, d); ctx.stroke(); if (k < 1) tip = [c, d];
93
+ }
94
+ // the capture spiral, sagging under its dew
95
+ const sp = w.spiral, upto = tSpiral * (sp.length - 1);
96
+ ctx.lineWidth = 0.75;
97
+ const pos = q => P(w.hub[0] + Math.cos(q.a) * q.r, w.hub[1] + Math.sin(q.a) * q.r);
98
+ for (let k = 0; k < Math.min(sp.length - 1, Math.ceil(upto)); k++) {
99
+ const part = Math.min(1, upto - k), [a, b] = pos(sp[k]), [c0, d0] = pos(sp[k + 1]), c = a + (c0 - a) * part, d = b + (d0 - b) * part;
100
+ const sag = tDew * Math.hypot(c - a, d - b) * 0.09;
101
+ ctx.strokeStyle = silk(a, b, c, d, 0.3); ctx.beginPath(); ctx.moveTo(a, b); ctx.quadraticCurveTo((a + c) / 2, (b + d) / 2 + sag, c, d); ctx.stroke();
102
+ if (part < 1) tip = [c, d];
103
+ }
104
+ // dew: every bead a tiny lens, dark rim, bright inverted sky, and a glint
105
+ if (tDew > 0) for (const dw of w.dew) {
106
+ const grow = Math.min(1, Math.max(0, tDew * 1.6 - dw.d * 0.6)); if (grow <= 0) continue;
107
+ const [a, b] = pos(sp[dw.k]), [c, d] = pos(sp[dw.k + 1]), t = dw.t, sag = tDew * Math.hypot(c - a, d - b) * 0.09 * 4 * t * (1 - t);
108
+ const x = a + (c - a) * t, y = b + (d - b) * t + sag, r = dw.r * grow * Math.min(W, H) / 560;
109
+ ctx.drawImage(bead, x - r, y - r, r * 2, r * 2);
110
+ }
111
+ // the spider, at whatever it is spinning (or resting at the hub)
112
+ const [sx, sy] = tip ?? P(w.hub[0], w.hub[1]), s = Math.min(W, H) / 120;
113
+ ctx.fillStyle = '#100c0a'; ctx.strokeStyle = '#100c0a'; ctx.lineWidth = Math.max(0.8, s * 0.22);
114
+ for (let l = 0; l < 8; l++) { const side = l < 4 ? -1 : 1, k = (l % 4) - 1.5, a = side * (0.5 + k * 0.35) + Math.PI / 2 * (side > 0 ? 0 : 0); const bend = Math.sin(time * 14 + l) * (tip ? 0.25 : 0);
115
+ ctx.beginPath(); ctx.moveTo(sx, sy); ctx.quadraticCurveTo(sx + side * s * 1.6, sy + k * s * 1.1 - s, sx + side * s * 2.6, sy + k * s * 1.5 + bend * s); ctx.stroke(); }
116
+ ctx.beginPath(); ctx.ellipse(sx, sy - s * 0.4, s * 0.55, s * 0.6, 0, 0, 7); ctx.fill();
117
+ ctx.beginPath(); ctx.ellipse(sx, sy + s * 0.8, s * 0.95, s * 1.15, 0, 0, 7); ctx.fill();
118
+ ctx.globalAlpha = 1;
119
+ raf = requestAnimationFrame(frame);
120
+ }
121
+ raf = requestAnimationFrame(frame);
122
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
123
+ }
@@ -0,0 +1,121 @@
1
+ // Starry night in brushstrokes: a painting made only of strokes, laid down thousands per second, after Van Gogh.
2
+ // The sky is one stream function: a long wave across it, two swirls locked together in the middle, and a bump around
3
+ // every star and the moon. Its contour lines are the flow, so every stroke follows the swirl it sits in, and the
4
+ // colour bands come from the same function, so stripes of colour run along the strokes as his do. Each stroke is a
5
+ // few bristles side by side with a lighter edge for the ridge of paint. The sky keeps turning; new strokes keep
6
+ // painting over old ones. Move the pointer to stir it.
7
+ const PARAMS = { strokes: 320, size: 1, swirl: 1 };
8
+
9
+ export default function (stage) {
10
+ const canvas = document.createElement('canvas');
11
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;background:#e9dfc8';
12
+ stage.append(canvas);
13
+ const ctx = canvas.getContext('2d');
14
+ let W = 1, H = 1, A = 1, D = 1, painted = 0;
15
+ const resize = () => {
16
+ D = Math.min(devicePixelRatio, 2); const w = stage.clientWidth || 1, h = stage.clientHeight || 1;
17
+ canvas.width = Math.max(1, (w * D) | 0); canvas.height = Math.max(1, (h * D) | 0); W = w; H = h; A = w / h;
18
+ ctx.setTransform(D, 0, 0, D, 0, 0); ctx.fillStyle = '#e9dfc8'; ctx.fillRect(0, 0, W, H); painted = 0; // a fresh canvas
19
+ };
20
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
21
+
22
+ // the scene, in units of the stage height: x from 0 to A, y from 0 (top) to 1
23
+ const STARS = [[0.07, 0.12, 0.035], [0.25, 0.08, 0.03], [0.37, 0.2, 0.028], [0.55, 0.1, 0.03], [0.66, 0.24, 0.026], [0.78, 0.36, 0.03],
24
+ [0.47, 0.48, 0.022], [0.92, 0.46, 0.024], [0.2, 0.33, 0.026], [0.98, 0.3, 0.02]];
25
+ const MOON = [0.88, 0.15, 0.05];
26
+ const hill = u => 0.66 + 0.04 * Math.sin(u * 7 + 1) + 0.025 * Math.sin(u * 15 + 2);
27
+ const cypress = (u, y, t) => { // > 0 inside the flame-shaped tree
28
+ if (y < 0.1) return -1;
29
+ const k = (y - 0.1) / 0.9, cx = 0.15 + 0.012 * Math.sin(y * 22 + t * 0.8) * (1 - k), w = 0.065 * Math.pow(Math.sin(Math.PI * Math.min(1, k * 0.62 + 0.04)), 0.8);
30
+ return w - Math.abs(u - cx);
31
+ };
32
+ const h1 = n => { const s = Math.sin(n * 127.1) * 43758.5453; return s - Math.floor(s); };
33
+ const houseAt = (u, y) => { // the village: returns 0 none, 1 wall, 2 roof, 3 window
34
+ if (u < 0.32 || u > 0.9) return 0;
35
+ const cell = Math.floor(u / 0.045), r = h1(cell), base = hill(cell * 0.045 + 0.022) + 0.05 + r * 0.07;
36
+ const fx = u / 0.045 - cell, top = base - 0.035 - r * 0.025;
37
+ if (Math.abs(u - 0.6) < 0.0055 * Math.min(1, (y - 0.5) / 0.1) && y > 0.5 && y < base) return 2; // the church spire
38
+ if (fx < 0.08 || fx > 0.92 || y < top - 0.018 || y > base) return 0;
39
+ if (y < top) return Math.abs(fx - 0.5) < (y - top + 0.018) / 0.018 * 0.45 ? 2 : 0;
40
+ return h1(cell * 3.1 + Math.floor((y - top) / 0.014)) > 0.5 && Math.abs(fx - 0.5) < 0.2 ? 3 : 1;
41
+ };
42
+ const VORT = [[0.44, 0.37, 0.13, 0.085], [0.6, 0.31, 0.1, -0.075], [0.75, 0.47, 0.065, 0.035], [0.27, 0.5, 0.075, -0.03]];
43
+ const ptr = { x: 0, y: 0, s: 0, on: false };
44
+ // the sky's stream function: flow runs along its contours
45
+ const psi = (x, y, t) => {
46
+ const u = x / A, sw = PARAMS.swirl;
47
+ let s = y + 0.03 * Math.sin(u * 9 + t * 0.25) + 0.018 * Math.sin(u * 17 - y * 6 - t * 0.2);
48
+ for (const [vu, vy, r, g] of VORT) { const dx = (u - vu) * A, dy = y - vy; s += g * sw * Math.exp(-(dx * dx + dy * dy) / (r * r)) * (1 + 0.15 * Math.sin(t * 0.4 + vu * 9)); }
49
+ for (const [su, sy, r] of [...STARS, MOON]) { const dx = (u - su) * A, dy = y - sy; s += 0.02 * Math.exp(-(dx * dx + dy * dy) / (r * r * 4)); }
50
+ if (ptr.s > 0.01) { const dx = x - ptr.x, dy = y - ptr.y; s += 0.06 * ptr.s * Math.exp(-(dx * dx + dy * dy) / 0.012); }
51
+ return s;
52
+ };
53
+ const mix = (a, b, k) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
54
+ const C = { deep: [23, 50, 128], mid: [43, 92, 182], light: [116, 172, 214], pale: [214, 230, 200], core: [255, 247, 207], gold: [246, 214, 74],
55
+ lime: [214, 226, 136], teal: [140, 196, 196], moon: [244, 182, 58], cy1: [14, 22, 20], cy2: [30, 48, 34], cy3: [58, 76, 46],
56
+ h1: [22, 36, 66], h2: [38, 64, 104], h3: [64, 96, 118], wall: [98, 116, 150], roof: [30, 42, 78], win: [246, 210, 80], ground: [28, 48, 52] };
57
+ // where a stroke at (x, y) points, and what colour it carries; also the stroke's scale for that region
58
+ function sample(x, y, t) {
59
+ const u = x / A, hy = hill(u), cyp = cypress(u, y, t), house = houseAt(u, y);
60
+ if (cyp > 0) { // the cypress: upward, flickering flames
61
+ const a = -Math.PI / 2 + 0.75 * Math.sin(y * 26 + u * 60 + t * 0.6) + 0.3 * Math.sin(y * 71); // curling upward like flames
62
+ const k = 0.5 + 0.5 * Math.sin(y * 40 + u * 120 + Math.sin(y * 13) * 3);
63
+ return { a, c: k < 0.33 ? C.cy1 : k < 0.75 ? C.cy2 : C.cy3, s: 0.85 };
64
+ }
65
+ if (house) return { a: house === 2 ? -Math.PI / 2 : (Math.floor(y * 200) % 2 ? 0 : Math.PI / 2), c: house === 3 ? C.win : house === 2 ? C.roof : C.wall, s: 0.38 };
66
+ if (y > hy) { // the hills: strokes follow their curves
67
+ const e = 0.002, dy = (hill(u + e) - hill(u - e)) / (2 * e * A), band = 0.5 + 0.5 * Math.sin((y - hy) * 70 + u * 3);
68
+ const deep = Math.min(1, (y - hy) / 0.3);
69
+ return { a: Math.atan(dy) + 0.15 * Math.sin(u * 40 + y * 30), c: mix(mix(C.h3, C.h2, band), mix(C.h1, C.ground, band), deep), s: 0.8 };
70
+ }
71
+ // the sky
72
+ const e = 0.004, gx = (psi(x + e, y, t) - psi(x - e, y, t)) / (2 * e), gy = (psi(x, y + e, t) - psi(x, y - e, t)) / (2 * e);
73
+ const a = Math.atan2(-gx, gy); // along the contour
74
+ const v = psi(x, y, t), band = 0.5 + 0.5 * Math.sin(v * 75);
75
+ let c = mix(mix(C.deep, C.mid, band), C.light, Math.max(0, Math.sin(v * 31 + 1)) ** 3 * 0.8);
76
+ for (const [vu, vy, r] of VORT) { const d = Math.hypot((u - vu) * A, y - vy) / r; if (d < 1.3) c = mix(c, Math.sin(v * 140) > 0 ? C.pale : C.light, (1 - d / 1.3) * 0.8); } // the swirls are lighter, ringed
77
+ for (const [su, sy, r] of STARS) { // stars: a hot core inside rings of gold and green
78
+ const d = Math.hypot((u - su) * A, y - sy) / r;
79
+ if (d < 0.38) c = C.core; else if (d < 1) c = mix(Math.sin(d * 18) > 0 ? C.gold : C.core, c, (d - 0.38) * 0.4);
80
+ else if (d < 1.7) c = mix(Math.sin(d * 14) > 0 ? C.lime : C.teal, c, (d - 1) / 0.7);
81
+ }
82
+ const md = Math.hypot((u - MOON[0]) * A, y - MOON[1]) / MOON[2], inner = Math.hypot((u - MOON[0] - 0.02) * A, y - MOON[1] + 0.012) / MOON[2];
83
+ if (md < 1 && inner > 0.82) c = C.moon; else if (md < 1) c = C.core; else if (md < 2.4) c = mix(Math.sin(md * 10) > 0 ? C.gold : C.lime, c, (md - 1) / 1.4);
84
+ return { a, c, s: 1 };
85
+ }
86
+
87
+ function stroke(t, big) {
88
+ const x = Math.random() * A, y = Math.random();
89
+ const f = sample(x, y, t), L = (0.03 + Math.random() * 0.04) * f.s * PARAMS.size * (big ? 1.6 : 1), w = (0.006 + Math.random() * 0.005) * f.s * PARAMS.size * (big ? 2.4 : 1);
90
+ // walk the field to bend the stroke along the flow
91
+ const pts = [[x, y]]; let px = x, py = y, a = f.a;
92
+ for (let i = 0; i < 5; i++) { px += Math.cos(a) * L / 5; py += Math.sin(a) * L / 5; pts.push([px, py]); a = sample(px, py, t).a; if (Math.abs(a - f.a) > 1.2) break; }
93
+ const j = (Math.random() - 0.5) * 26, base = f.c.map(v => Math.max(0, Math.min(255, v + j)));
94
+ const nx = -Math.sin(f.a), ny = Math.cos(f.a);
95
+ ctx.lineCap = 'round'; ctx.lineJoin = 'round';
96
+ for (let b = 0; b < 4; b++) { // bristles, side by side
97
+ const off = (b / 3 - 0.5) * w, k = b === 0 ? 1.18 : b === 3 ? 0.84 : 1 + (Math.random() - 0.5) * 0.12;
98
+ ctx.strokeStyle = `rgb(${Math.min(255, base[0] * k) | 0},${Math.min(255, base[1] * k) | 0},${Math.min(255, base[2] * k) | 0})`;
99
+ ctx.lineWidth = Math.max(0.6, (w / 3.6) * H * (0.85 + Math.random() * 0.35));
100
+ ctx.beginPath();
101
+ for (let i = 0; i < pts.length; i++) { const taper = i === 0 || i === pts.length - 1 ? 0.6 : 1; const X = (pts[i][0] + nx * off * taper) * H, Y = (pts[i][1] + ny * off * taper) * H; i ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y); }
102
+ ctx.stroke();
103
+ }
104
+ }
105
+
106
+ const move = e => { const r = canvas.getBoundingClientRect(); ptr.x = (e.clientX - r.left) / r.height; ptr.y = (e.clientY - r.top) / r.height; ptr.on = true; };
107
+ const leave = () => { ptr.on = false; };
108
+ canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerleave', leave);
109
+ let raf, last = performance.now(), t = 0;
110
+ function frame(now) {
111
+ const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
112
+ ptr.s += ((ptr.on ? 1 : 0) - ptr.s) * Math.min(1, dt * 3);
113
+ // the first second lays a broad underpainting fast, then the fine strokes take over
114
+ const n = painted < 9000 ? 700 : Math.round(PARAMS.strokes);
115
+ for (let i = 0; i < n; i++) stroke(t, painted + i < 4500);
116
+ painted += n;
117
+ raf = requestAnimationFrame(frame);
118
+ }
119
+ raf = requestAnimationFrame(frame);
120
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerleave', leave); canvas.remove(); };
121
+ }