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 parametric dome: an arc of points from the hub up over the crown to the rim, closed back to the hub, so it reads as a ribbed umbrella seen from the side. Wind enters as a scalar speed that drives a quadratic drag force; that force sets a bulge value which deforms the arc — the downwind edge is pushed out and down, the upwind edge compresses — and simultaneously drives the person's lean, the handle's tilt, and a screen shake. Inversion is a state change, not a continuous deformation: once wind crosses a threshold the canopy flips to a concave, drooping, semi-transparent shape (the same arc with the deflection mirrored and a droop added), with hysteresis so it doesn't flicker at the boundary. What decides when it happens is accumulated load: the drag force scaled by an "inverted" penalty factor, compared against a grip budget that drains over time — so the umbrella can be wrenched sideways (a wrench value that tilts the handle and shakes the arm) and eventually get away entirely (grip hits zero, the umbrella flies off on a scripted arc and returns).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Umbrella in a Gale</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#0a0e1a}
body{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;color:#cfe3ff;-webkit-font-smoothing:antialiased}
canvas{display:block;position:fixed;inset:0}
#hud{position:fixed;top:22px;left:22px;pointer-events:none;user-select:none}
.windBox{pointer-events:auto}
.label{font-size:11px;letter-spacing:4px;color:#6f8fb0}
.value{font-size:68px;line-height:1;color:#fff;text-shadow:0 0 26px rgba(120,200,255,.55);font-variant-numeric:tabular-nums}
.unit{font-size:17px;color:#6f8fb0;margin-left:8px}
.controls{margin-top:20px;pointer-events:auto}
.row{display:flex;align-items:center;gap:8px;font-size:12px;color:#8aa6c8;margin-bottom:10px;cursor:pointer}
input[type=range]{width:200px;accent-color:#4da3ff;cursor:pointer}
input[type=checkbox]{accent-color:#4da3ff;cursor:pointer}
.hint{position:fixed;bottom:18px;left:22px;font-size:12px;color:#55708f;letter-spacing:.5px}
.hint b{color:#9fc0e0;font-weight:600}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
<div class="windBox">
<div class="label">WIND</div>
<div class="value" id="windVal">0</div><span class="unit">m/s</span>
</div>
<div class="controls">
<label class="row"><input type="checkbox" id="autoChk" checked> AUTO — wind rises</label>
<label class="row">manual <input type="range" id="windSlider" min="0" max="25" step="0.5" value="0"></label>
</div>
</div>
<div class="hint"><b>↑</b>/<b>↓</b> wind · <b>R</b> reset · <b>SPACE</b> auto/manual</div>
<script>
(() => {
'use strict';
const cv = document.getElementById('c');
const ctx = cv.getContext('2d');
let W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
cv.width = Math.floor(W * DPR);
cv.height = Math.floor(H * DPR);
cv.style.width = W + 'px';
cv.style.height = H + 'px';
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
window.addEventListener('resize', resize);
resize();
const MAX_WIND = 25;
const AUTO_START = 1.5;
const AUTO_RATE = 0.42;
const st = {
t: 0, wind: 0, windTarget: 0, auto: true,
inverted: false, lost: false, lostTimer: 0,
wrench: 0, grip: 1, lean: 0, bulge: 0, w: 0,
pop: 0, shake: 0
};
const scale = () => Math.min(W / 1300, H / 900);
const groundY = () => H * 0.82;
const baseX = () => W * 0.58;
function personPoints() {
const S = scale();
const gy = groundY();
const bx = baseX();
const a = -st.lean;
const c = Math.cos(a), s = Math.sin(a);
const rot = (x, y) => [bx + x * c - y * s, gy + x * s + y * c];
const legLen = 200 * S, torsoLen = 125 * S, headR = 30 * S, armLen = 145 * S;
return {
S, gy, bx, legLen, torsoLen, headR, armLen,
hip: rot(0, -legLen),
shoulder: rot(0, -legLen - torsoLen),
head: rot(0, -legLen - torsoLen - headR),
hand: rot(-armLen * 0.8, -legLen - torsoLen * 0.5)
};
}
// ---------- canopy geometry ----------
function canopyArc(bulge, inverted, Rc) {
const N = 20;
const arc = [];
for (let i = 0; i <= N; i++) {
const phi = Math.PI - Math.PI * i / N;
const ox = Rc * Math.cos(phi);
const oy = -Rc - Rc * Math.sin(phi);
let x = ox, y = oy;
if (inverted) {
const df = (Rc - ox) / (2 * Rc);
x += -bulge * df * Rc * 1.0;
y += -bulge * df * Rc * 0.6;
const df2 = (ox + Rc) / (2 * Rc);
x += bulge * df2 * Rc * (-0.7);
y += bulge * df2 * Rc * (-0.35);
y += bulge * Rc * 0.3;
} else {
const df = (ox + Rc) / (2 * Rc);
x += bulge * df * Rc * 0.9;
y += bulge * df * Rc * 0.5;
}
arc.push([x, y]);
}
return arc;
}
// ---------- rain ----------
let rain = [];
function initRain() {
rain = [];
for (let i = 0; i < 320; i++) {
rain.push({
x: Math.random() * W, y: Math.random() * H,
len: 8 + Math.random() * 20,
sp: 0.5 + Math.random() * 1.6,
a: 0.10 + Math.random() * 0.20,
front: Math.random() < 0.28
});
}
}
initRain();
// ---------- update ----------
function update(dt) {
st.t += dt;
if (st.auto) st.windTarget = Math.min(MAX_WIND, AUTO_START + AUTO_RATE * st.t);
st.wind += (st.windTarget - st.wind) * Math.min(1, dt * 1.6);
const gust = 1 + 0.05 * Math.sin(st.t * 1.1) + 0.035 * Math.sin(st.t * 2.3) + 0.02 * Math.sin(st.t * 4.1);
st.w = Math.max(0, st.wind * gust);
st.bulge = Math.min(1, Math.pow(st.w / 16, 1.25));
if (!st.inverted && st.w > 13.5) { st.inverted = true; st.pop = 1; st.shake = 8; }
if (st.inverted && st.w < 10.5) st.inverted = false;
const wt = Math.max(0, Math.min(1, (st.w - 15) / 7));
st.wrench += (wt - st.wrench) * Math.min(1, dt * 1.5);
const load = st.w * st.w * (st.inverted ? 1.8 : 1.0);
const gt = Math.max(0, 1 - load / 620);
st.grip += (gt - st.grip) * Math.min(1, dt * 0.7);
if (!st.lost && st.grip < 0.04) { st.lost = true; st.lostTimer = 0; }
if (st.lost) {
st.lostTimer += dt;
if (st.lostTimer > 6) { st.lost = false; st.grip = 1; }
}
const lt = Math.min(0.5, st.w * 0.022);
st.lean += (lt - st.lean) * Math.min(1, dt * 3);
st.pop = Math.max(0, st.pop - dt * 3.5);
st.shake = Math.max(0, st.shake - dt * 2.2);
if (st.pop > 0) st.shake = Math.max(st.shake, st.pop * 7);
const vx = st.w * 7;
for (const r of rain) {
r.x += vx * dt;
r.y += (430 + r.sp * 230) * dt;
if (r.y > H + 80) { r.y = -80; r.x = Math.random() * W; }
if (r.x > W + 80) r.x = -80;
if (r.x < -80) r.x = W + 80;
}
}
// ---------- background ----------
function drawBackground() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#070a14');
g.addColorStop(0.55, '#16213c');
g.addColorStop(0.82, '#3a2a4a');
g.addColorStop(1, '#120d18');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const gy = groundY();
ctx.fillStyle = '#080b12';
ctx.fillRect(0, gy, W, H - gy);
ctx.strokeStyle = 'rgba(255,180,140,0.10)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(W, gy);
ctx.stroke();
// distant glow near horizon
const rg = ctx.createRadialGradient(W * 0.5, gy, 0, W * 0.5, gy, W * 0.7);
rg.addColorStop(0, 'rgba(255,150,90,0.10)');
rg.addColorStop(1, 'rgba(255,150,90,0)');
ctx.fillStyle = rg;
ctx.fillRect(0, gy - H * 0.3, W, H * 0.3);
}
function drawRainLayer(front) {
const vx = st.w * 7;
ctx.save();
ctx.lineCap = 'butt';
for (const r of rain) {
if (r.front !== front) continue;
const vy = 430 + r.sp * 230;
const sp = Math.hypot(vx, vy) || 1;
const k = r.len / sp;
ctx.strokeStyle = 'rgba(190,215,255,' + r.a + ')';
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(r.x - vx * k, r.y - vy * k);
ctx.lineTo(r.x, r.y);
ctx.stroke();
}
ctx.restore();
}
// ---------- person ----------
function drawPerson() {
const p = personPoints();
const S = p.S;
ctx.save();
ctx.translate(p.bx, p.gy);
ctx.rotate(-st.lean);
// legs
ctx.strokeStyle = '#2b3240';
ctx.lineWidth = Math.max(8, 16 * S);
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(0, -p.legLen); ctx.lineTo(-12 * S, 0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, -p.legLen); ctx.lineTo(12 * S, 0); ctx.stroke();
// feet planted
ctx.strokeStyle = '#161b23';
ctx.lineWidth = Math.max(8, 14 * S);
ctx.beginPath(); ctx.moveTo(-12 * S, 0); ctx.lineTo(-24 * S, 9 * S); ctx.stroke();
ctx.beginPath(); ctx.moveTo(12 * S, 0); ctx.lineTo(24 * S, 9 * S); ctx.stroke();
// torso
ctx.strokeStyle = '#ffd166';
ctx.lineWidth = Math.max(16, 34 * S);
ctx.beginPath(); ctx.moveTo(0, -p.legLen); ctx.lineTo(0, -p.legLen - p.torsoLen); ctx.stroke();
// head
ctx.beginPath();
ctx.arc(0, -p.legLen - p.torsoLen - p.headR, p.headR, 0, Math.PI * 2);
ctx.fillStyle = '#e8b48c';
ctx.fill();
ctx.strokeStyle = '#b8865a';
ctx.lineWidth = Math.max(2, 2.5 * S);
ctx.stroke();
// face (facing left, into the wind)
ctx.beginPath();
ctx.moveTo(-p.headR * 0.3, -p.legLen - p.torsoLen - p.headR * 0.2);
ctx.lineTo(-p.headR * 0.85, -p.legLen - p.torsoLen - p.headR * 0.55);
ctx.strokeStyle = '#5a3a22';
ctx.lineWidth = Math.max(2, 3 * S);
ctx.stroke();
// umbrella arm
ctx.strokeStyle = '#ffd166';
ctx.lineWidth = Math.max(8, 14 * S);
ctx.beginPath();
ctx.moveTo(0, -p.legLen - p.torsoLen);
ctx.lineTo(-p.armLen * 0.8, -p.legLen - p.torsoLen * 0.5);
ctx.stroke();
// bracing arm
ctx.beginPath();
ctx.moveTo(0, -p.legLen - p.torsoLen * 0.7);
ctx.lineTo(p.armLen * 0.55, -p.legLen - p.torsoLen * 0.25);
ctx.stroke();
ctx.restore();
}
// ---------- umbrella ----------
function drawUmbrella() {
const p = personPoints();
const S = p.S;
const hand = p.hand;
const hubDist = 78 * S;
const ha = Math.min(0.55, st.w * 0.013);
const hubX = hand[0] + hubDist * Math.sin(ha);
const hubY = hand[1] - hubDist * Math.cos(ha);
const Rc = 138 * S;
const arc = canopyArc(st.bulge, st.inverted, Rc);
const world = arc.map(pt => [hubX + pt[0], hubY + pt[1]]);
// canopy body
ctx.save();
ctx.beginPath();
ctx.moveTo(world[0][0], world[0][1]);
for (let i = 1; i < world.length; i++) ctx.lineTo(world[i][0], world[i][1]);
ctx.lineTo(hubX, hubY);
ctx.closePath();
const grad = ctx.createRadialGradient(hubX, hubY - Rc * 0.4, Rc * 0.1, hubX, hubY - Rc, Rc * 1.7);
if (st.inverted) {
grad.addColorStop(0, 'rgba(96,22,44,0.80)');
grad.addColorStop(1, 'rgba(38,8,18,0.22)');
} else {
grad.addColorStop(0, 'rgba(232,62,62,0.96)');
grad.addColorStop(1, 'rgba(150,20,26,0.50)');
}
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = st.inverted ? 'rgba(255,120,120,0.45)' : 'rgba(255,150,150,0.85)';
ctx.lineWidth = Math.max(2, 2.5 * S);
ctx.stroke();
ctx.restore();
// ribs
ctx.save();
ctx.strokeStyle = 'rgba(18,6,10,0.8)';
ctx.lineWidth = Math.max(2, 2.5 * S);
ctx.beginPath(); ctx.moveTo(hubX, hubY); ctx.lineTo(world[0][0], world[0][1]); ctx.stroke();
ctx.beginPath(); ctx.moveTo(hubX, hubY); ctx.lineTo(world[world.length - 1][0], world[world.length - 1][1]); ctx.stroke();
ctx.restore();
// handle
ctx.save();
ctx.lineCap = 'round';
ctx.strokeStyle = '#3a2a1a';
ctx.lineWidth = Math.max(4, 7 * S);
ctx.beginPath(); ctx.moveTo(hand[0], hand[1]); ctx.lineTo(hubX, hubY); ctx.stroke();
ctx.strokeStyle = '#1a1208';
ctx.beginPath();
ctx.moveTo(hand[0], hand[1]);
ctx.lineTo(hand[0] + (hubX - hand[0]) * 0.28, hand[1] + (hubY - hand[1]) * 0.28);
ctx.stroke();
ctx.restore();
// hub cap
ctx.beginPath();
ctx.arc(hubX, hubY, Math.max(4, 6 * S), 0, Math.PI * 2);
ctx.fillStyle = '#2a1a10';
ctx.fill();
}
// ---------- lost umbrella flight ----------
function drawLostUmbrella() {
const p = personPoints();
const S = p.S;
const hand = p.hand;
const t = st.lostTimer;
let x, y, rot;
if (t < 3) {
const f = t / 3;
x = hand[0] + 520 * f * f;
y = hand[1] - 360 * f;
rot = f * 2.6;
} else {
const f = (t - 3) / 3;
x = hand[0] + 520 - 520 * f;
y = hand[1] - 360 + 360 * f;
rot = 2.6 * (1 - f);
}
const Rc = 138 * S;
const arc = canopyArc(0.85, false, Rc);
const world = arc.map(pt => [x + pt[0], y + pt[1]]);
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.beginPath();
ctx.moveTo(world[0][0] - x, world[0][1] - y);
for (let i = 1; i < world.length; i++) ctx.lineTo(world[i][0] - x, world[i][1] - y);
ctx.lineTo(0, 0);
ctx.closePath();
const g = ctx.createRadialGradient(-Rc * 0.2, -Rc * 0.4, Rc * 0.1, 0, -Rc, Rc * 1.7);
g.addColorStop(0, 'rgba(232,62,62,0.95)');
g.addColorStop(1, 'rgba(150,20,26,0.45)');
ctx.fillStyle = g;
ctx.fill();
ctx.strokeStyle = 'rgba(255,150,150,0.8)';
ctx.lineWidth = Math.max(2, 2.5 * S);
ctx.stroke();
ctx.strokeStyle = '#3a2a1a';
ctx.lineWidth = Math.max(4, 7 * S);
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, 78 * S); ctx.stroke();
ctx.restore();
}
// ---------- render ----------
function render() {
ctx.save();
// screen shake
if (st.shake > 0.2) {
const m = st.shake * 0.6;
ctx.translate((Math.random() - 0.5) * m, (Math.random() - 0.5) * m);
}
drawBackground();
drawRainLayer(false);
drawPerson();
if (!st.lost) drawUmbrella();
else drawLostUmbrella();
drawRainLayer(true);
// vignette
const vg = ctx.createRadialGradient(W * 0.5, H * 0.5, Math.min(W, H) * 0.35, W * 0.5, H * 0.5, Math.max(W, H) * 0.75);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
ctx.restore();
}
// ---------- HUD ----------
const slider = document.getElementById('windSlider');
const autoChk = document.getElementById('autoChk');
const windVal = document.getElementById('windVal');
function updateHUD() {
windVal.textContent = Math.round(st.w).toString();
slider.value = Math.round(st.windTarget * 2) / 2;
}
slider.addEventListener('input', () => {
st.auto = false;
autoChk.checked = false;
st.windTarget = parseFloat(slider.value);
});
autoChk.addEventListener('change', () => {
st.auto = autoChk.checked;
if (st.auto) st.windTarget = Math.min(MAX_WIND, AUTO_START + AUTO_RATE * st.t);
});
window.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
st.auto = false; autoChk.checked = false;
st.windTarget = Math.min(MAX_WIND, st.windTarget + 1);
slider.value = st.windTarget;
} else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
st.auto = false; autoChk.checked = false;
st.windTarget = Math.max(0, st.windTarget - 1);
slider.value = st.windTarget;
} else if (e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
st.auto = !st.auto;
autoChk.checked = st.auto;
} else if (e.key === 'r' || e.key === 'R') {
st.t = 0; st.wind = 0; st.windTarget = 0; st.auto = true; autoChk.checked = true;
st.inverted = false; st.lost = false; st.lostTimer = 0;
st.wrench = 0; st.grip = 1; st.lean = 0; st.bulge = 0; st.w = 0;
st.pop = 0; st.shake = 0;
initRain();
}
});
// ---------- loop ----------
let last = performance.now();
function loop(now) {
let dt = (now - last) / 1000;
last = now;
dt = Math.min(dt, 0.05);
update(dt);
render();
updateHUD();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();
</script>
</body>
</html>
Where it looks right: the canopy's silhouette is a proper dome with the hub at the bottom and the rim up top, so the ribs read as ribs rather than a fan; the deflection is asymmetric in the correct direction (downwind edge pushed out and down, upwind edge compressed); the person leans into the wind, plants both feet, and braces with the free arm; rain streaks shear sideways at a rate that tracks the wind; and the inversion is a genuine state change with hysteresis, a pop, and a screen shake rather than a slider that wobbles back and forth.
Where it looks wrong: the canopy is a flat 2D silhouette with no foreshortening, so the ribs on the far side don't converge the way they would in a real perspective view — it reads as a diagram more than a photo. The inversion is a mirrored deformation plus a droop and a transparency change; a real umbrella collapses along its ribs and the fabric goes slack and inside-out in a way that's hard to fake with a single parametric arc. The person is a stick figure with a fixed limb layout — no weight shift, no foot adjustment, no arm tremor beyond the lean value, and no sense of the handle twisting in the grip. The rain is a flat particle field with no depth sorting against the canopy, so it doesn't visibly "stop" at the fabric; it just gets covered by the draw order. And the loss-and-return cycle is a scripted arc, not a consequence of the wind — the umbrella flies off and comes back on a timer regardless of what the wind is doing at that moment. The wind itself is a smoothed scalar with sinusoidal gusts, so it has no spatial structure: nothing on screen is affected differently by wind at different heights.