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.
- package/LICENSE +16 -0
- package/README.md +37 -2
- package/anims/basics/anticipation.js +56 -0
- package/anims/basics/card-flip.js +39 -0
- package/anims/basics/easing-race.js +57 -0
- package/anims/basics/fade-scale.js +44 -0
- package/anims/basics/stagger-grid.js +43 -0
- package/anims/chrome/black-hole.js +81 -0
- package/anims/chrome/cloth-reveal.js +156 -0
- package/anims/chrome/dna-helix.js +52 -0
- package/anims/chrome/dust-bust.js +109 -0
- package/anims/chrome/extruded-type.js +64 -0
- package/anims/chrome/fractal-tree.js +75 -0
- package/anims/chrome/gem.js +111 -0
- package/anims/chrome/geode.js +102 -0
- package/anims/chrome/glass-lens.js +73 -0
- package/anims/chrome/glass-prism.js +95 -0
- package/anims/chrome/gyroid.js +69 -0
- package/anims/chrome/holo-card.js +52 -0
- package/anims/chrome/infinite-tunnel.js +81 -0
- package/anims/chrome/inflated-type.js +141 -0
- package/anims/chrome/isometric-city.js +97 -0
- package/anims/chrome/jelly-cube.js +329 -0
- package/anims/chrome/klein-bottle.js +80 -0
- package/anims/chrome/liquid-metal.js +94 -0
- package/anims/chrome/low-poly-planet.js +163 -0
- package/anims/chrome/mandelbulb.js +79 -0
- package/anims/chrome/melting-logo.js +126 -0
- package/anims/chrome/menger-sponge.js +79 -0
- package/anims/chrome/neon-geodesic.js +149 -0
- package/anims/chrome/paper-fold.js +135 -0
- package/anims/chrome/path-spheres.js +112 -0
- package/anims/chrome/point-globe.js +81 -0
- package/anims/chrome/portal.js +105 -0
- package/anims/chrome/rubiks-cube.js +74 -0
- package/anims/chrome/sdf-morph.js +129 -0
- package/anims/chrome/shatter.js +120 -0
- package/anims/chrome/soap-bubble.js +86 -0
- package/anims/chrome/terrain-flyover.js +78 -0
- package/anims/chrome/tesseract.js +148 -0
- package/anims/chrome/torus-knot.js +89 -0
- package/anims/chrome/volumetric-clouds.js +70 -0
- package/anims/flow/bio-shore.js +140 -0
- package/anims/flow/calligraphy.js +116 -0
- package/anims/flow/chladni.js +152 -0
- package/anims/flow/cloud-chamber.js +101 -0
- package/anims/flow/confetti.js +46 -0
- package/anims/flow/curl-flow.js +52 -0
- package/anims/flow/dandelion.js +80 -0
- package/anims/flow/dla-crystal.js +57 -0
- package/anims/flow/drone-show.js +357 -0
- package/anims/flow/embers.js +63 -0
- package/anims/flow/firefly-sync.js +107 -0
- package/anims/flow/galaxy.js +69 -0
- package/anims/flow/galton-board.js +155 -0
- package/anims/flow/gpu-particles.js +73 -0
- package/anims/flow/harmonograph.js +61 -0
- package/anims/flow/holi.js +153 -0
- package/anims/flow/iron-filings.js +112 -0
- package/anims/flow/lightning.js +72 -0
- package/anims/flow/murmuration.js +105 -0
- package/anims/flow/orbitals.js +195 -0
- package/anims/flow/particle-life.js +87 -0
- package/anims/flow/physarum.js +171 -0
- package/anims/flow/pin-art.js +181 -0
- package/anims/flow/rainbow.js +127 -0
- package/anims/flow/roche-limit.js +161 -0
- package/anims/flow/sand-mandala.js +121 -0
- package/anims/flow/scramble-crossing.js +152 -0
- package/anims/flow/snowfall.js +84 -0
- package/anims/flow/sparks.js +71 -0
- package/anims/flow/spider-web.js +123 -0
- package/anims/flow/starry-night.js +121 -0
- package/anims/flow/states-of-matter.js +113 -0
- package/anims/flow/strange-attractor.js +54 -0
- package/anims/flow/wheat-field.js +186 -0
- package/anims/flow/wind-tunnel.js +227 -0
- package/anims/fluid/bubbles.js +152 -0
- package/anims/fluid/caustics.js +45 -0
- package/anims/fluid/dripping-text.js +53 -0
- package/anims/fluid/ferrofluid.js +56 -0
- package/anims/fluid/fire-smoke.js +83 -0
- package/anims/fluid/honey-drip.js +167 -0
- package/anims/fluid/ink-drop.js +50 -0
- package/anims/fluid/ink-fluid.js +125 -0
- package/anims/fluid/lava-lamp.js +56 -0
- package/anims/fluid/liquid-distort.js +64 -0
- package/anims/fluid/liquid-glass-fill.js +53 -0
- package/anims/fluid/marbling.js +53 -0
- package/anims/fluid/mercury.js +177 -0
- package/anims/fluid/ocean-waves.js +50 -0
- package/anims/fluid/paint-swirl.js +153 -0
- package/anims/fluid/raindrops-glass.js +60 -0
- package/anims/fluid/ripples.js +57 -0
- package/anims/fluid/sph-water.js +75 -0
- package/anims/fluid/underwater.js +55 -0
- package/anims/fluid/water-surface-3d.js +98 -0
- package/anims/hover/cursor-trail.js +37 -0
- package/anims/hover/magnetic.js +41 -0
- package/anims/hover/ripple.js +31 -0
- package/anims/hover/spotlight.js +31 -0
- package/anims/hover/tilt.js +36 -0
- package/anims/micro/arc-spinner.js +22 -0
- package/anims/micro/like-burst.js +42 -0
- package/anims/micro/skeleton.js +36 -0
- package/anims/micro/submit-success.js +45 -0
- package/anims/micro/toggle.js +35 -0
- package/anims/morph/gooey.js +36 -0
- package/anims/morph/icon-morph.js +45 -0
- package/anims/morph/line-draw.js +34 -0
- package/anims/morph/liquid-button.js +53 -0
- package/anims/morph/shape-morph.js +61 -0
- package/anims/registry-more.js +327 -0
- package/anims/registry.js +294 -0
- package/anims/scroll/parallax.js +50 -0
- package/anims/scroll/pinned-track.js +40 -0
- package/anims/scroll/scrub-reveal.js +34 -0
- package/anims/scroll/velocity-skew.js +38 -0
- package/anims/scroll/zoom-through.js +41 -0
- package/anims/shaders/aurora.js +47 -0
- package/anims/shaders/dither.js +69 -0
- package/anims/shaders/domain-warp.js +52 -0
- package/anims/shaders/guilloche.js +42 -0
- package/anims/shaders/kaleidoscope.js +64 -0
- package/anims/shaders/reaction-diffusion.js +85 -0
- package/anims/shaders/rgb-glitch.js +56 -0
- package/anims/shaders/truchet.js +57 -0
- package/anims/shaders/voronoi.js +50 -0
- package/anims/springs/ball-collisions.js +47 -0
- package/anims/springs/pendulum-wave.js +35 -0
- package/anims/springs/rubber-band.js +50 -0
- package/anims/springs/squash-bounce.js +50 -0
- package/anims/springs/verlet-rope.js +46 -0
- package/anims/text/letter-rise.js +40 -0
- package/anims/text/line-mask.js +26 -0
- package/anims/text/scramble.js +32 -0
- package/anims/text/typewriter.js +35 -0
- package/anims/text/vf-wave.js +26 -0
- package/anims/transitions/circle-reveal.js +42 -0
- package/anims/transitions/push-slide.js +40 -0
- package/anims/transitions/shared-expand.js +43 -0
- package/anims/transitions/staircase-wipe.js +41 -0
- package/anims/transitions/view-transition.js +43 -0
- package/bin.mjs +155 -0
- package/js/code.js +122 -0
- 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
|
+
}
|