You build physics toys, and you have held an umbrella in a gale.
Build me a single HTML page: a person holding an umbrella in wind that keeps getting stronger.
data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file: what the canopy is made of in your code, what inversion is as a state change, and what decides when it happens.
The single fenced html block.
Where it looks right and where it looks wrong. Everyone who has held an umbrella knows what one does in wind, so say what yours gets wrong before I find it.
The canopy is a side-view fan of 27 spring-damped fabric nodes coupled to their neighbours, drawn as alternating panels and bent ribs from the shaft hub; wind pressure, projected angle and stored frame damage drive a load value that flexes those nodes, bows the shaft and colors the ribs. Inversion is a latched Boolean state change that reverses the sign of the canopy’s rest curvature, with a springy visual blend from domed-rim-below-hub to cupped-rim-above-hub; it triggers when sustained dynamic-pressure load plus sideways shaft angle exceeds a fatigue threshold, and only pops back after a much calmer hysteresis window.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>Umbrella in a Rising Gale</title>
<style>
* { box-sizing: border-box; }
:root { color-scheme: dark; }
html, body { margin: 0; height: 100%; overflow: hidden; background: #08101c; }
body { font: 14px/1.35 system-ui, -apple-system, Segoe UI, sans-serif; color: #eaf3ff; }
canvas { display: block; width: 100vw; height: 100vh; touch-action: none; }
#hud {
position: fixed; z-index: 5; top: 14px; left: 14px;
width: min(430px, calc(100vw - 28px));
padding: 12px 14px 11px;
background: rgba(8, 15, 28, .72);
border: 1px solid rgba(170, 215, 255, .18);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(0,0,0,.35);
backdrop-filter: blur(7px);
user-select: none;
}
.display { display: flex; align-items: baseline; gap: 8px; white-space: nowrap; }
.cap { font-size: 11px; letter-spacing: .22em; color: #9fb5ce; font-weight: 800; }
#windLabel { font-size: 34px; line-height: 1; font-weight: 800; letter-spacing: -.05em; font-variant-numeric: tabular-nums; }
.unit { color: #bcd0e6; font-size: 14px; }
#dirLabel { margin-left: auto; font-size: 27px; line-height: 1; color: #8fd0ff; text-shadow: 0 0 16px rgba(110,200,255,.45); }
input[type="range"] { width: 100%; margin: 10px 0 7px; accent-color: #69c6ff; }
.controls { display: flex; align-items: center; gap: 10px; justify-content: space-between; }
label { display: inline-flex; align-items: center; gap: 6px; color: #c7d9ec; font-size: 12px; }
label input { accent-color: #69c6ff; }
button {
border: 1px solid rgba(170,215,255,.25);
background: rgba(80,130,180,.18);
color: #eaf3ff; border-radius: 9px; padding: 6px 9px;
font: inherit; font-size: 12px; cursor: pointer;
}
button:hover { background: rgba(100,165,225,.28); }
.meter { height: 7px; background: rgba(255,255,255,.09); border-radius: 99px; overflow: hidden; margin-top: 9px; }
#loadBar { display: block; height: 100%; width: 3%; border-radius: 99px; background: linear-gradient(90deg, #66d1ff, #ffd05a); transition: width .07s linear, background .18s; }
.status { display: flex; justify-content: space-between; gap: 10px; margin-top: 7px; font-size: 11px; }
#stateLabel { font-weight: 800; letter-spacing: .08em; color: #d7e8ff; text-transform: uppercase; }
#tip { color: #8fa7c2; text-align: right; }
.keys { font-size: 11px; color: #91a8c3; margin-top: 5px; opacity: .9; }
#footer { position: fixed; z-index: 4; right: 14px; bottom: 10px; font-size: 11px; color: rgba(220,235,255,.52); text-shadow: 0 1px 2px #000; }
</style>
</head>
<body>
<canvas id="stage" aria-label="A person holding an umbrella in rising wind"></canvas>
<div id="hud">
<div class="display">
<span class="cap">WIND</span>
<span id="windLabel">0.0</span>
<span class="unit">m/s</span>
<span id="dirLabel">→</span>
</div>
<input id="windSlider" type="range" min="-45" max="50" step="0.1" value="4" aria-label="Wind speed; negative reverses direction">
<div class="controls">
<label><input id="auto" type="checkbox" checked> rising gust</label>
<button id="calm" type="button">calm / re-grip</button>
</div>
<div class="meter"><i id="loadBar"></i></div>
<div class="status"><span id="stateLabel">CANOPY STABLE</span><span id="tip">automatic gust climbing</span></div>
<div class="keys">←/↓ ease off · →/↑ pile on · Space shoves · minus reverses</div>
</div>
<div id="footer">hand-written springs, ribs, rain and bad decisions</div>
<script>
(() => {
"use strict";
const canvas = document.getElementById("stage");
const ctx = canvas.getContext("2d");
const $ = (id) => document.getElementById(id);
const slider = $("windSlider");
const auto = $("auto");
const calm = $("calm");
const windLabel = $("windLabel");
const dirLabel = $("dirLabel");
const stateLabel = $("stateLabel");
const tip = $("tip");
const loadBar = $("loadBar");
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const rnd = (a, b) => a + Math.random() * (b - a);
const rgba = (r, g, b, a = 1) =>
"rgba(" + clamp(Math.round(r), 0, 255) + "," +
clamp(Math.round(g), 0, 255) + "," +
clamp(Math.round(b), 0, 255) + "," + a + ")";
const TAU = Math.PI * 2;
const rot = (x, y, a) => ({ x: x * Math.cos(a) - y * Math.sin(a), y: x * Math.sin(a) + y * Math.cos(a) });
let W = 0, H = 0, DPR = 1, S = 1, groundY = 0, px = 0;
let time = 0;
const MIN_W = -45, MAX_W = 50;
let demand = 4, wind = 0, userPause = 0;
let dragging = false, space = false;
const keys = { low: false, high: false };
let drops = [], splashes = [], windLines = [];
const NODE_COUNT = 27;
const nodes = Array.from({ length: NODE_COUNT }, (_, i) => ({
u: -1 + 2 * i / (NODE_COUNT - 1),
dx: 0, vx: 0, dy: 0, vy: 0,
ph: rnd(0, TAU)
}));
const person = {
lean: 0, leanV: 0,
brace: 0, braceV: 0,
wobble: 0, grip: 1
};
const um = {
attached: true,
handleX: 0, handleY: 0,
freeX: 0, freeY: 0, freeVX: 0, freeVY: 0,
angle: 0.06, angleV: 0,
inverted: false, invBlend: 0, invV: 0,
load: 0, flex: 0, flexV: 0,
frameStress: 0, frameDamage: 0,
fatigue: 0, calmTimer: 0, gripStrain: 0,
flipFlash: 0, lostTimer: 0,
snapThreshold: rnd(.45, .75)
};
function resetNodes() {
for (const n of nodes) {
n.dx = n.vx = n.dy = n.vy = 0;
n.ph = rnd(0, TAU);
}
}
function resetToy() {
demand = 0; wind = 0; userPause = 3.2;
slider.value = "0";
um.attached = true;
um.inverted = false;
um.invBlend = 0; um.invV = 0;
um.angle = .03; um.angleV = 0;
um.load = 0; um.flex = 0; um.flexV = 0;
um.frameStress = 0; um.frameDamage = 0;
um.fatigue = 0; um.calmTimer = 0; um.gripStrain = 0;
um.flipFlash = 0; um.lostTimer = 0;
um.snapThreshold = rnd(.45, .75);
person.lean = person.leanV = person.brace = person.braceV = person.wobble = 0;
person.grip = 1;
splashes.length = 0;
resetNodes();
}
function wrapX(x, margin) {
const span = W + margin * 2;
return ((x % span) + span) % span - margin;
}
function makeDrop(initial) {
const d = { x: 0, y: 0, z: 1, seed: 0 };
resetDrop(d, initial);
return d;
}
function resetDrop(d, initial) {
d.z = rnd(.45, 1.35);
d.seed = rnd(0, 1000);
if (initial) {
d.x = rnd(-W * .2, W * 1.2);
d.y = rnd(-H * .2, groundY + 10 * S);
} else {
d.y = rnd(-90 * S, -8 * S);
d.x = wind < 0 ? rnd(W * .05, W * 1.3) : rnd(-W * .3, W * .95);
}
}
function makeWindLine() {
return {
x: rnd(-W * .2, W * 1.2),
y: rnd(25 * S, Math.max(40 * S, groundY - 45 * S)),
z: rnd(.45, 1.55),
len: rnd(50, 190),
phase: rnd(0, TAU),
a: rnd(.045, .16)
};
}
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
S = clamp(Math.min(W / 950, H / 620), .42, 1.35);
groundY = H * .80;
px = W * .47;
const count = Math.floor(clamp(W * H / 5500, 90, 420));
drops = Array.from({ length: count }, () => makeDrop(true));
windLines = Array.from({ length: 62 }, makeWindLine);
splashes.length = 0;
resetNodes();
}
function updateWind(dt) {
if (keys.low) { demand -= 24 * dt; userPause = .35; }
if (keys.high) { demand += 24 * dt; userPause = .35; }
if (space) {
demand += (wind >= 0 ? 1 : -1) * 31 * dt;
userPause = .35;
}
demand = clamp(demand, MIN_W, MAX_W);
if (auto.checked && !dragging && userPause <= 0) {
demand = clamp(demand + 1.08 * dt, MIN_W, MAX_W);
}
userPause = Math.max(0, userPause - dt);
const sign = demand < -.5 ? -1 : 1;
const amp = .18 + clamp(Math.abs(demand) / 36, 0, 1) * 2.65;
const wiggle = Math.sin(time * 1.71) * .65 +
Math.sin(time * .47 + 2.1) * 1.05 +
Math.sin(time * 5.3 + .8) * .22;
const spike = Math.max(0, Math.sin(time * .83 + .4) - .70) *
(5.5 + Math.abs(demand) * .09);
const ageBoost = auto.checked ? sign * clamp(time / 110, 0, 1) * 2.6 : 0;
const target = demand + sign * wiggle * amp + sign * spike + ageBoost;
wind += (target - wind) * (1 - Math.exp(-dt * 2.25));
if (!dragging) slider.value = demand.toFixed(1);
}
function updatePerson(dt, dir, av) {
const load = clamp(um.load, 0, 2.8);
const targetLean = -dir * clamp(av / 50 * .43 + load * .045 + (um.attached ? 0 : .035), 0, .58);
person.leanV += ((targetLean - person.lean) * 30 - person.leanV * 8) * dt;
person.lean += person.leanV * dt;
const targetBrace = clamp(av / 48 + load * .1 + um.gripStrain * .08, 0, 1.15);
person.braceV += ((targetBrace - person.brace) * 17 - person.braceV * 8) * dt;
person.brace += person.braceV * dt;
person.wobble += (Math.sin(time * 6.2) * clamp(av / 50, 0, 1) - person.wobble) *
(1 - Math.exp(-dt * 5.5));
}
function personPose(dir) {
const hip = {
x: px + dir * (5 * S * person.brace) + person.wobble * 2.2 * S,
y: groundY - 102 * S
};
const ca = Math.cos(person.lean), sa = Math.sin(person.lean);
const rotatePoint = (x, y) => ({
x: hip.x + ca * x - sa * y,
y: hip.y + sa * x + ca * y
});
const shoulder = rotatePoint(0, -69 * S);
const shoulderGrip = rotatePoint(-dir * 12 * S, -67 * S);
const shoulderBrace = rotatePoint(dir * 12 * S, -66 * S);
const head = rotatePoint(0, -98 * S);
const handRelX = -dir * (18 * S + 5 * S * person.brace) +
dir * Math.min(um.load, 2.8) * 3 * S;
const handRelY = -48 * S + Math.sin(time * 8.1) * clamp(um.load, 0, 2.8) * 1.5 * S;
const hand = rotatePoint(handRelX, handRelY);
return { hip, shoulder, shoulderGrip, shoulderBrace, head, hand, rotatePoint };
}
function snapInvert(dir) {
um.inverted = true;
um.frameDamage = clamp(um.frameDamage + .36 + Math.abs(wind) / 180, 0, 1.2);
um.fatigue = 0;
um.calmTimer = 0;
um.flipFlash = .72;
um.angleV += dir * rnd(1.5, 2.9);
for (const n of nodes) {
n.vy += rnd(-140, 140) * S;
n.vx += dir * rnd(-70, 130) * S;
}
um.snapThreshold = rnd(.42, .72);
}
function popBack(dir) {
um.inverted = false;
um.fatigue = 0;
um.calmTimer = 0;
um.flipFlash = .42;
um.angleV -= dir * rnd(.7, 1.4);
for (const n of nodes) {
n.vy += rnd(-60, 60) * S;
n.vx += dir * rnd(-30, 50) * S;
}
}
function releaseUmbrella(dir, av) {
um.attached = false;
um.freeX = um.handleX;
um.freeY = um.handleY;
um.freeVX = dir * (120 + av * 16) * S + rnd(-25, 25) * S;
um.freeVY = -(65 + av * 2.2) * S + rnd(-25, 20) * S;
um.angleV = dir * rnd(1.8, 3.4);
um.lostTimer = 0;
person.grip = 0;
}
function updateCanopyNodes(dt, dir, av) {
const oldX = nodes.map(n => n.dx);
const oldY = nodes.map(n => n.dy);
const q0 = clamp(av / 50, 0, 1.35);
const q = q0 * q0;
const capSign = 1 - 2 * clamp(um.invBlend, 0, 1);
let maxDef = 0;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const u = n.u;
const panel = Math.max(0, 1 - u * u);
const down = Math.max(0, dir * u);
const up = Math.max(0, -dir * u);
const flutter = Math.sin(time * 11.4 + n.ph + u * 5.2) * q * 4.4 * S;
const targetX = dir * q * S *
(11 * panel + 22 * down * down - 7 * up * up) +
dir * flutter * .25;
const peel = q * S * (8 * panel + 18 * down * down + 5 * up);
const targetY = -capSign * peel + flutter +
Math.sin(time * 8.3 + n.ph + u * 3.1) * um.invBlend * q * 6 * S;
const lx = i > 0 ? oldX[i - 1] : oldX[0];
const rx = i < nodes.length - 1 ? oldX[i + 1] : oldX[nodes.length - 1];
const ly = i > 0 ? oldY[i - 1] : oldY[0];
const ry = i < nodes.length - 1 ? oldY[i + 1] : oldY[nodes.length - 1];
const lapX = lx + rx - 2 * oldX[i];
const lapY = ly + ry - 2 * oldY[i];
n.vx += ((targetX - n.dx) * 46 + lapX * 78 - n.vx * 8.5) * dt;
n.vy += ((targetY - n.dy) * 52 + lapY * 92 - n.vy * 9.5) * dt;
n.dx += n.vx * dt;
n.dy += n.vy * dt;
n.dx = clamp(n.dx, -46 * S, 46 * S);
n.dy = clamp(n.dy, -46 * S, 46 * S);
maxDef = Math.max(maxDef, Math.abs(n.dx) + Math.abs(n.dy));
}
um.frameStress = clamp(um.load * .66 + maxDef / (38 * S) * .55, 0, 1.6);
}
function updateUmbrella(dt, dir, av) {
const gustSpike = Math.max(0, Math.sin(time * 3.1 + .6) - .72) * av / 50;
const side = Math.abs(Math.sin(um.angle - dir * .10));
const q = av * av / (30 * 30);
um.load = clamp(
q * (1 + .65 * side) +
Math.abs(um.angle) * .11 +
(um.inverted ? .12 : 0) +
gustSpike * .35,
0, 2.8
);
const targetAngle = clamp(
dir * (.045 + av / 50 * .94 + (um.inverted ? .20 : 0)) +
dir * gustSpike * .10,
-1.28, 1.28
);
const shake = Math.sin(time * 7.7) * (av / 50) * .14;
um.angleV += ((targetAngle - um.angle) * (30 + um.load * 7) -
um.angleV * (6.2 + 1 / (1 + um.load)) + shake) * dt;
um.angle += um.angleV * dt;
um.angle = clamp(um.angle, -1.45, 1.45);
const flexTarget = clamp(um.load * .58 + av / 50 * .15 + (um.inverted ? .22 : 0), 0, 1.42);
um.flexV += ((flexTarget - um.flex) * 50 - um.flexV * 10) * dt;
um.flex += um.flexV * dt;
if (!um.inverted) {
const trigger = um.load > (1.04 + .14 * um.frameDamage) &&
av > 26 &&
Math.abs(um.angle) > .34;
if (trigger) um.fatigue += dt * (.65 + Math.max(0, um.load - 1.0) * 1.35 + gustSpike);
else um.fatigue = Math.max(0, um.fatigue - dt * .85);
if (um.fatigue > um.snapThreshold) snapInvert(dir);
} else {
if (um.attached && av < 7 && Math.abs(um.angle) < .26 && um.frameDamage < .75)
um.calmTimer += dt;
else um.calmTimer = 0;
if (um.calmTimer > 1.45) popBack(dir);
}
const targetInv = um.inverted ? 1 : 0;
const spring = um.flipFlash > 0 ? 88 : 35;
um.invV += ((targetInv - um.invBlend) * spring - um.invV * 12) * dt;
um.invBlend = clamp(um.invBlend + um.invV * dt, -.09, 1.09);
um.flipFlash = Math.max(0, um.flipFlash - dt);
updateCanopyNodes(dt, dir, av);
if (um.attached) {
const yank = um.load +
Math.abs(um.angle) * .55 +
(um.inverted ? .35 : 0) +
Math.abs(um.angleV) * .035 +
gustSpike * 1.2;
const releaseLimit = Math.max(1.25, 2.25 - um.frameDamage * .55);
if (yank > 1.62 && av > 32)
um.gripStrain += (yank - 1.62) * (.55 + av / 90) * dt;
else
um.gripStrain = Math.max(0, um.gripStrain - dt * .42);
person.grip = clamp(1 - um.gripStrain / releaseLimit, 0, 1);
if (um.gripStrain > releaseLimit ||
(yank > 3.15 && av > 45 && Math.random() < dt * .45)) {
releaseUmbrella(dir, av);
}
} else {
person.grip = 0;
}
}
function updateFree(dt, dir, av) {
um.lostTimer += dt;
const targetVX = wind * 17 * S;
um.freeVX += (targetVX - um.freeVX) * (1 - Math.exp(-dt * 1.25));
const targetVY = 60 * S - av * 4.3 * S;
um.freeVY += ((targetVY - um.freeVY) * .85 + 90 * S) * dt;
um.freeX += um.freeVX * dt;
um.freeY += um.freeVY * dt;
um.angle += (um.angleV + dir * (.35 + av / 90)) * dt;
um.angleV *= Math.exp(-dt * .12);
}
function updateRain(dt) {
const vx = wind * 15 * S;
const vy = (250 + Math.abs(wind) * 3) * S;
for (const d of drops) {
d.x += vx * d.z * dt;
d.y += vy * d.z * dt;
if (d.y > groundY || d.x < -120 * S || d.x > W + 120 * S || d.y > H + 30 * S) {
if (d.y > groundY - 4 * S && d.x > 0 && d.x < W && splashes.length < 90) {
const life = rnd(.26, .48);
splashes.push({ x: d.x, y: groundY + rnd(-1, 2) * S, t: 0, life });
}
resetDrop(d, false);
}
}
for (const s of splashes) s.t += dt;
splashes = splashes.filter(s => s.t < s.life);
for (const l of windLines) {
l.x += wind * 24 * S * l.z * dt;
if (l.x > W + 300 * S) l.x = -rnd(20, 280) * S;
if (l.x < -300 * S) l.x = W + rnd(20, 280) * S;
}
}
function updateHUD() {
const av = Math.abs(wind);
windLabel.textContent = av.toFixed(1);
dirLabel.textContent = wind >= 0 ? "→" : "←";
dirLabel.style.opacity = av > .25 ? "1" : ".45";
let state = "CANOPY STABLE";
if (!um.attached) state = "UMBRELLA GONE";
else if (um.inverted) state = "INVERTED · RIBS OUTSIDE IN";
else if (um.fatigue > .14) state = "FRAME LOADED · SNAPPING";
else if (um.frameStress > .82) state = "CANOPY FLEXING";
else if (av < 1) state = "CALM";
stateLabel.textContent = state;
if (!um.attached) tip.textContent = "press calm / re-grip";
else if (auto.checked) tip.textContent = userPause > 0 ? "ramp paused while you steer" : "automatic gust climbing";
else tip.textContent = "manual wind";
loadBar.style.width = clamp(um.frameStress / 1.5 * 100, 3, 100) + "%";
if (!um.attached) loadBar.style.background = "linear-gradient(90deg,#64748b,#ff6b57)";
else if (um.inverted || um.frameStress > 1.18) loadBar.style.background = "linear-gradient(90deg,#ffd05a,#ff5c4d)";
else loadBar.style.background = "linear-gradient(90deg,#66d1ff,#ffd05a)";
}
function drawCloud(cx, cy, sc, alpha) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = "#25344e";
ctx.beginPath();
ctx.arc(cx - 62 * sc, cy + 10 * sc, 42 * sc, 0, TAU);
ctx.arc(cx - 25 * sc, cy - 18 * sc, 58 * sc, 0, TAU);
ctx.arc(cx + 22 * sc, cy - 9 * sc, 50 * sc, 0, TAU);
ctx.arc(cx + 70 * sc, cy + 14 * sc, 38 * sc, 0, TAU);
ctx.arc(cx + 8 * sc, cy + 22 * sc, 70 * sc, 0, TAU);
ctx.fill();
ctx.restore();
}
function drawBackground() {
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, "#101b2e");
sky.addColorStop(.55, "#17243a");
sky.addColorStop(1, "#0b1422");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
const glow = ctx.createRadialGradient(W * .67, H * .2, 0, W * .67, H * .2, W * .65);
glow.addColorStop(0, "rgba(110,150,195,.16)");
glow.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
const drift = time * wind * .55 * S;
for (let i = 0; i < 6; i++) {
const cx = wrapX(i * W * .28 + 90 * S + drift, 340 * S);
const cy = (0.10 + .035 * (i % 3)) * H;
drawCloud(cx, cy, (.82 + (i % 2) * .30) * S, .22);
}
const bw = Math.max(36 * S, W / 14);
ctx.fillStyle = "rgba(7,12,22,.82)";
for (let x = -bw; x < W + bw; x += bw) {
const h = (24 + 58 * Math.abs(Math.sin((x + bw) * .013 + 2.1))) * S;
ctx.fillRect(x, groundY - h, bw + 1, h);
}
const g = ctx.createLinearGradient(0, groundY, 0, H);
g.addColorStop(0, "#142235");
g.addColorStop(1, "#080e18");
ctx.fillStyle = g;
ctx.fillRect(0, groundY, W, H - groundY);
ctx.strokeStyle = "rgba(145,190,235,.18)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, groundY + .5);
ctx.lineTo(W, groundY + .5);
ctx.stroke();
for (let i = 0; i < 7; i++) {
const x = W * (.06 + .14 * i) + Math.sin(i * 7.1) * 20 * S;
const y = groundY + (16 + (i % 3) * 16) * S;
ctx.strokeStyle = "rgba(120,180,225,.12)";
ctx.lineWidth = Math.max(1, 1.2 * S);
ctx.beginPath();
ctx.ellipse(x, y, (30 + i % 3 * 15) * S, 3.5 * S, 0, 0, TAU);
ctx.stroke();
}
}
function drawWindStreaks() {
const av = Math.abs(wind);
if (av < 1) return;
const dir = wind >= 0 ? 1 : -1;
const strength = clamp(av / 50, .04, 1);
ctx.save();
ctx.lineCap = "round";
for (const l of windLines) {
const y = l.y + Math.sin(time * 1.6 + l.phase) * 9 * S;
const len = l.len * S * (.35 + strength);
ctx.strokeStyle = rgba(160, 205, 255, l.a * strength);
ctx.lineWidth = Math.max(.7, 1.15 * S * l.z);
ctx.beginPath();
ctx.moveTo(l.x, y);
ctx.quadraticCurveTo(
l.x - dir * len * .52,
y + Math.sin(time * 2 + l.phase) * 8 * S,
l.x - dir * len,
y + Math.sin(time * 1.3 + l.phase) * 5 * S
);
ctx.stroke();
}
ctx.restore();
}
function drawRain(foreground) {
const vx = wind * 15 * S;
const vy = (250 + Math.abs(wind) * 3) * S;
const mag = Math.max(1, Math.hypot(vx, vy));
const ux = vx / mag, uy = vy / mag;
ctx.save();
ctx.lineCap = "round";
for (const d of drops) {
const wantsFront = d.z > 1.04;
if (wantsFront !== foreground) continue;
const len = (8 + Math.abs(wind) * .20 + d.z * 5) * S;
const a = (.11 + d.z * .10 + Math.min(Math.abs(wind) / 90, .16)) * (foreground ? 1 : .78);
ctx.strokeStyle = rgba(185, 220, 255, a);
ctx.lineWidth = Math.max(.6, (foreground ? 1.15 : .8) * d.z * S);
ctx.beginPath();
ctx.moveTo(d.x, d.y);
ctx.lineTo(d.x - ux * len, d.y - uy * len);
ctx.stroke();
}
ctx.restore();
}
function drawSplashes() {
ctx.save();
for (const s of splashes) {
const k = s.t / s.life;
const a = (1 - k) * .32;
ctx.strokeStyle = rgba(180, 220, 255, a);
ctx.lineWidth = Math.max(.7, 1.1 * S);
ctx.beginPath();
ctx.ellipse(s.x, s.y, (5 + 16 * k) * S, (1.5 + 2.5 * k) * S, 0, 0, TAU);
ctx.stroke();
}
ctx.restore();
}
function fillPoly(points, fill, stroke, lineWidth) {
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = lineWidth || 1;
ctx.stroke();
}
}
function drawPolyline(points, width, color) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.stroke();
}
function drawLimb(a, b, bend, width, color) {
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.max(1, Math.hypot(dx, dy));
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
const nx = -dy / len, ny = dx / len;
const elbow = { x: mx + nx * bend, y: my + ny * bend };
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.quadraticCurveTo(elbow.x, elbow.y, b.x, b.y);
ctx.stroke();
}
function drawBoot(x, y, dir) {
ctx.fillStyle = "#090d15";
ctx.beginPath();
ctx.moveTo(x - dir * 11 * S, y - 7 * S);
ctx.lineTo(x + dir * 24 * S, y - 7 * S);
ctx.lineTo(x + dir * 29 * S, y + 3 * S);
ctx.lineTo(x - dir * 14 * S, y + 3 * S);
ctx.closePath();
ctx.fill();
}
function drawPerson(pose, dir, origin) {
const av = Math.abs(wind);
const footY = groundY - 1 * S;
const frontFootX = px - dir * (30 * S + 4 * S * person.brace);
const backFootX = px + dir * (42 * S + 34 * S * person.brace);
const hip = pose.hip;
const frontKnee = {
x: lerp(hip.x, frontFootX, .52) - dir * (5 + 10 * person.brace) * S,
y: groundY - 49 * S
};
const backKnee = {
x: lerp(hip.x, backFootX, .50) + dir * (6 + 16 * person.brace) * S,
y: groundY - (48 + 8 * person.brace) * S
};
drawPolyline([hip, frontKnee, { x: frontFootX, y: footY }], 14 * S, "#172338");
drawPolyline([hip, backKnee, { x: backFootX, y: footY }], 15 * S, "#101b2d");
drawBoot(frontFootX, footY + 3 * S, dir);
drawBoot(backFootX, footY + 3 * S, dir);
if (av > 18) {
ctx.strokeStyle = rgba(180, 215, 250, clamp((av - 18) / 45, 0, .25));
ctx.lineWidth = Math.max(1, 1.1 * S);
for (let i = 0; i < 4; i++) {
const yy = groundY + (2 + i * 4) * S;
ctx.beginPath();
ctx.moveTo(backFootX - dir * (28 + i * 12) * S, yy);
ctx.lineTo(backFootX - dir * (8 + i * 7) * S, yy);
ctx.stroke();
}
}
const rp = pose.rotatePoint;
const coat = [
rp(-20 * S, -69 * S),
rp(20 * S, -69 * S),
rp(27 * S, 4 * S),
rp(-27 * S, 1 * S)
];
fillPoly(coat, "#d29a3b", "rgba(60,38,20,.65)", 1.5 * S);
ctx.strokeStyle = "rgba(255,225,150,.22)";
ctx.lineWidth = 2 * S;
ctx.beginPath();
ctx.moveTo(rp(0, -64 * S).x, rp(0, -64 * S).y);
ctx.lineTo(rp(-4 * S, 0).x, rp(-4 * S, 0).y);
ctx.stroke();
const head = pose.head;
ctx.fillStyle = "#6b4a24";
ctx.beginPath();
ctx.arc(head.x, head.y, 20 * S, 0, TAU);
ctx.fill();
ctx.fillStyle = "#e6c09a";
ctx.beginPath();
ctx.arc(head.x - dir * 3 * S, head.y + 1 * S, 15 * S, 0, TAU);
ctx.fill();
const noseDir = -dir;
ctx.fillStyle = "#e6c09a";
ctx.beginPath();
ctx.moveTo(head.x + noseDir * 12 * S, head.y - 2 * S);
ctx.lineTo(head.x + noseDir * 20 * S, head.y + 1 * S);
ctx.lineTo(head.x + noseDir * 12 * S, head.y + 4 * S);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "#182033";
ctx.beginPath();
ctx.arc(head.x + noseDir * 7 * S, head.y - 5 * S, 1.8 * S, 0, TAU);
ctx.fill();
drawLimb(pose.shoulderGrip, pose.hand, -dir * (16 + 10 * person.brace) * S, 10 * S, "#c28c32");
const L = 170 * S;
const shaftVec = { x: Math.sin(um.angle) * L, y: -Math.cos(um.angle) * L };
const bracePoint = {
x: origin.x + shaftVec.x * .31,
y: origin.y + shaftVec.y * .31
};
const emptyPoint = { x: pose.hand.x + dir * 28 * S, y: pose.hand.y + 12 * S };
drawLimb(
pose.shoulderBrace,
um.attached ? bracePoint : emptyPoint,
dir * (18 + 14 * person.brace) * S,
9 * S,
"#b9822d"
);
const gripColor = rgba(45 + (1 - person.grip) * 185, 56 - (1 - person.grip) * 22, 48 - (1 - person.grip) * 20, 1);
ctx.fillStyle = gripColor;
for (const p of [pose.hand, um.attached ? bracePoint : emptyPoint]) {
ctx.beginPath();
ctx.arc(p.x, p.y, 6.5 * S, 0, TAU);
ctx.fill();
ctx.strokeStyle = "rgba(25,20,15,.65)";
ctx.lineWidth = 1.2 * S;
ctx.stroke();
}
}
function drawUmbrella(origin, dir, av) {
const L = 170 * S;
const angle = um.angle;
const hub = {
x: origin.x + Math.sin(angle) * L,
y: origin.y - Math.cos(angle) * L
};
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
const bend = clamp(um.load, 0, 2.8) * 10 * S * dir +
Math.sin(time * 5.1) * clamp(av / 50, 0, 1) * 2 * S;
const mid = { x: (origin.x + hub.x) / 2 + bend, y: (origin.y + hub.y) / 2 };
ctx.strokeStyle = "rgba(20,24,32,.95)";
ctx.lineWidth = 7 * S;
ctx.beginPath();
ctx.moveTo(origin.x, origin.y);
ctx.quadraticCurveTo(mid.x, mid.y, hub.x, hub.y);
ctx.stroke();
ctx.strokeStyle = rgba(195 + um.frameStress * 20, 205, 220, .45);
ctx.lineWidth = 1.4 * S;
ctx.stroke();
const hookDir = -dir;
ctx.strokeStyle = "#3d2a1c";
ctx.lineWidth = 6 * S;
ctx.beginPath();
ctx.moveTo(origin.x, origin.y);
ctx.bezierCurveTo(
origin.x, origin.y + 20 * S,
origin.x + hookDir * 18 * S, origin.y + 25 * S,
origin.x + hookDir * 20 * S, origin.y + 8 * S
);
ctx.stroke();
const ca = Math.cos(angle), sa = Math.sin(angle);
const xf = (lx, ly) => ({
x: hub.x + ca * lx - sa * ly,
y: hub.y + sa * lx + ca * ly
});
const inv = clamp(um.invBlend, 0, 1);
const curv = 1 - 2 * um.invBlend;
const R = 145 * S * (1 - .075 * inv);
const dome = 50 * S;
const pts = [], local = [], scallops = [];
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const u = n.u;
const lx = u * R + n.dx + dir * um.flex * 8 * S * (1 - u * u);
const ly = dome * curv * u * u + n.dy;
local.push({ lx, ly, u });
pts.push(xf(lx, ly));
}
for (let i = 0; i < nodes.length - 1; i++) {
const a = local[i], b = local[i + 1];
const bulge = (curv >= 0 ? 1 : -1) * (5 + 2.5 * Math.sin(i * 1.7)) * S;
scallops.push(xf((a.lx + b.lx) / 2, (a.ly + b.ly) / 2 + bulge));
}
const hot = clamp((um.frameStress - .9) / .7, 0, 1);
const cA = rgba(lerp(214, 94, inv) + hot * 30, lerp(58, 125, inv) + hot * 18, lerp(58, 155, inv), .95);
const cB = rgba(lerp(165, 70, inv) + hot * 42, lerp(43, 98, inv), lerp(72, 132, inv), .93);
for (let i = 0; i < nodes.length - 1; i++) {
ctx.fillStyle = i % 2 ? cA : cB;
ctx.beginPath();
ctx.moveTo(hub.x, hub.y);
ctx.lineTo(pts[i].x, pts[i].y);
ctx.quadraticCurveTo(scallops[i].x, scallops[i].y, pts[i + 1].x, pts[i + 1].y);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(20,24,34,.28)";
ctx.lineWidth = .8 * S;
ctx.stroke();
}
const stress = clamp((um.frameStress - .45) / 1.0, 0, 1);
const ribIndices = [];
for (let j = 0; j < nodes.length; j += 3) ribIndices.push(j);
if (ribIndices[ribIndices.length - 1] !== nodes.length - 1) ribIndices.push(nodes.length - 1);
for (const j of ribIndices) {
const a = local[j], p = pts[j];
const ribBulge = (curv >= 0 ? 1 : -1) *
(6 + um.frameStress * 5) * S *
(1 - .25 * a.u * a.u);
const c = xf(a.lx * .50, a.ly * .46 + ribBulge);
ctx.strokeStyle = rgba(
lerp(225, 255, stress),
lerp(225, 105, stress),
lerp(210, 70, stress),
.53 + .34 * stress
);
ctx.lineWidth = (1.2 + stress * 1.1) * S;
ctx.beginPath();
ctx.moveTo(hub.x, hub.y);
ctx.quadraticCurveTo(c.x, c.y, p.x, p.y);
ctx.stroke();
}
ctx.strokeStyle = inv > .5 ? "rgba(190,210,235,.72)" : "rgba(40,28,32,.75)";
ctx.lineWidth = (1.5 + stress * .7) * S;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 0; i < nodes.length - 1; i++) {
ctx.quadraticCurveTo(scallops[i].x, scallops[i].y, pts[i + 1].x, pts[i + 1].y);
}
ctx.stroke();
if (hot > 0) {
ctx.strokeStyle = rgba(255, 100, 70, hot * .35);
ctx.lineWidth = (2.2 + hot * 3) * S;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 0; i < nodes.length - 1; i++) {
ctx.quadraticCurveTo(scallops[i].x, scallops[i].y, pts[i + 1].x, pts[i + 1].y);
}
ctx.stroke();
}
const spike = xf(0, -18 * S);
ctx.strokeStyle = "rgba(220,230,240,.9)";
ctx.lineWidth = 3 * S;
ctx.beginPath();
ctx.moveTo(hub.x, hub.y);
ctx.lineTo(spike.x, spike.y);
ctx.stroke();
ctx.fillStyle = "#d6dee8";
ctx.beginPath();
ctx.arc(hub.x, hub.y, 4.2 * S, 0, TAU);
ctx.fill();
for (const j of [0, nodes.length - 1]) {
ctx.fillStyle = "rgba(30,36,48,.8)";
ctx.beginPath();
ctx.arc(pts[j].x, pts[j].y, 2.6 * S, 0, TAU);
ctx.fill();
}
ctx.restore();
}
function umbrellaVisible() {
if (um.attached) return true;
const m = 280 * S;
return um.freeX > -m && um.freeX < W + m &&
um.freeY > -m && um.freeY < H + m;
}
function update(dt) {
time += dt;
updateWind(dt);
const av = Math.abs(wind);
const dir = wind >= 0 ? 1 : -1;
updatePerson(dt, dir, av);
let pose = personPose(dir);
if (um.attached) {
um.handleX = pose.hand.x;
um.handleY = pose.hand.y;
}
updateUmbrella(dt, dir, av);
if (!um.attached) updateFree(dt, dir, av);
else {
pose = personPose(dir);
um.handleX = pose.hand.x;
um.handleY = pose.hand.y;
}
updateRain(dt);
updateHUD();
}
function render() {
const av = Math.abs(wind);
const dir = wind >= 0 ? 1 : -1;
const pose = personPose(dir);
const origin = um.attached
? { x: pose.hand.x, y: pose.hand.y }
: { x: um.freeX, y: um.freeY };
ctx.clearRect(0, 0, W, H);
drawBackground();
drawWindStreaks();
drawRain(false);
if (umbrellaVisible()) drawUmbrella(origin, dir, av);
drawPerson(pose, dir, origin);
drawRain(true);
drawSplashes();
}
slider.addEventListener("pointerdown", () => { dragging = true; userPause = 2.2; });
slider.addEventListener("input", () => {
demand = clamp(parseFloat(slider.value), MIN_W, MAX_W);
userPause = 2.2;
});
window.addEventListener("pointerup", () => {
if (dragging) { dragging = false; userPause = 2.2; }
});
window.addEventListener("pointercancel", () => {
if (dragging) { dragging = false; userPause = 2.2; }
});
auto.addEventListener("change", () => { userPause = .6; });
calm.addEventListener("click", resetToy);
window.addEventListener("keydown", (e) => {
const c = e.code;
if (c === "ArrowLeft" || c === "ArrowDown") {
keys.low = true; userPause = .35; e.preventDefault();
} else if (c === "ArrowRight" || c === "ArrowUp") {
keys.high = true; userPause = .35; e.preventDefault();
} else if (c === "Space") {
space = true; e.preventDefault();
}
});
window.addEventListener("keyup", (e) => {
const c = e.code;
if (c === "ArrowLeft" || c === "ArrowDown") keys.low = false;
if (c === "ArrowRight" || c === "ArrowUp") keys.high = false;
if (c === "Space") space = false;
});
window.addEventListener("blur", () => {
keys.low = keys.high = space = dragging = false;
});
window.addEventListener("resize", resize);
resize();
let last = performance.now();
function loop(now) {
const dt = clamp((now - last) / 1000, .001, .033);
last = now;
update(dt);
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();
</script>
</body>
</html>
The wrongness first: a real umbrella inverts from a local gust getting under one lip, twisting the ribs, buckling joints and peeling panels asymmetrically; this one is a flattened 2-D fan whose whole rest curvature flips on a tidy load/fatigue threshold, so the snap is more “cartoon state machine” than wet fabric. The person’s arms are not true length-constrained linkages, the feet never slip or lift, rain does not hit or run off the canopy, fabric mass and porosity are fake, and “calm / re-grip” magically fetches another umbrella from nowhere. It looks right in the larger beats: the default gust ramps upward, the signed slider and keys can lower or reverse it, the rain’s slant and speed follow the live wind, the canopy billows and ribs redden under load, the shaft gets dragged sideways, inversion visibly turns the bowl upward, grip strain eventually loses the handle, and the loose umbrella tumbles downwind while the braced figure remains planted.