27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
18:40ERNIE 5.5 Flash Preview vs GLM 5.3 Flash: Baidu's new model is very impressive!
Nov 11, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 2026 · YouTube
14:03Gemini 4 Carbon: Can it handle my challenges? (First look with one-shot coding tests)
Nov 10, 2026 · YouTube
47:51Ranking major U.S. Open-Weights Models. (Where does Ripple 65B fit in?)
Nov 6, 2026 · YouTube
10:57"Crouton Alpha": the latest super-efficient coding model by Meta?
Nov 3, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
22:14GPT-6.1 Terra Pro vs Terra: How Much Better Is Pro?
Oct 27, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
31:28Is this company reselling GPT Sol 6.1 at a loss (for non-agentic/text-only)?🤔 Is this sustainable?
Oct 20, 2026 · YouTube
17:58Nemotron 3.5 Super VL vs Opus 5.5: Testing NVIDIA’s new coding juggernaut
Oct 15, 2026 · YouTube
16:04Testing a *massive* new open-weights model 🤯: Muse Spark Open (vs. Nemotron, Opus 5.5, and more)
Oct 15, 2026 · YouTube
16:43Gemini 4 Argon: Great 🐈 Animation, but a broken escalator. (Coding challenge vs Opus 5.5, Sol 6.1)
Oct 13, 2026 · YouTube
16:37Meta's new "Muse Wonder" AI is impressive... but can it compete with Anthropic's models?
Oct 12, 2026 · YouTube
11:12WOW... Haiku 5.5 is very impressive. RIP OpenAI?
Oct 7, 2026 · YouTube
22:47Mistral Large 4 Is Cheap. But Is It Good Enough to Compete?
Oct 6, 2026 · YouTube
22:02Is Pareto 26.10 Preview Just Routing to GPT Sol? I Tested It Against Sol 6.1 Pro
Oct 1, 2026 · YouTube
11:22I Tested Sonnet 5.5 Against Opus 5.5. Is It Actually Cheaper?
Sep 28, 2026 · YouTube
19:21I Tested the Stealth "Space Bunny Alpha" Model Before It Went Offline
Sep 23, 2026 · YouTube
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 five spring-driven rib endpoints joined by curved fabric panels, carried on a slightly bending shaft with its own angular spring. Wind pressure grows with speed squared; it bends the ribs, twists the umbrella, and pulls against the person’s grip. Inversion switches the ribs’ preferred shape from a dome to a bowl once sustained loading exceeds a threshold—not just when the slider crosses a number. There’s hysteresis before it springs back, and accumulated grip overload can detach the whole umbrella.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A losing argument — an umbrella in wind</title>
<style>
:root {
--ink: #253e3c;
--muted: #657873;
--paper: #f3f4ec;
--accent: #be4d34;
--line: #bec9be;
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; }
body {
background: #dce3da;
color: var(--ink);
font-family: Georgia, "Times New Roman", serif;
}
canvas { display: block; position: fixed; inset: 0; width: 100%; height: 100%; }
header {
position: absolute; top: 29px; left: 34px;
pointer-events: none;
}
.eyebrow, .small, button, .readout, .controls, .hint {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.eyebrow {
font-size: 10px; letter-spacing: .19em; font-weight: bold;
display: flex; align-items: center; gap: 9px;
}
.eyebrow::before {
content: ""; width: 7px; height: 7px; background: var(--accent);
border-radius: 50%;
}
h1 {
font-size: clamp(34px, 4.1vw, 59px); line-height: .99;
font-weight: normal; letter-spacing: -.055em; margin: 17px 0 13px;
}
header p { font-size: 13px; line-height: 1.5; margin: 0; color: var(--muted); }
.readout {
position: absolute; right: 34px; top: 30px; text-align: right;
font-variant-numeric: tabular-nums; pointer-events: none;
}
.readout .caption { font-size: 10px; letter-spacing: .16em; }
.speed { font-size: clamp(48px, 6vw, 78px); line-height: 1.1; letter-spacing: -.08em; }
.unit { font-size: 13px; letter-spacing: 0; margin-left: 9px; }
.wind-arrow { margin-top: 8px; font-size: 11px; color: var(--muted); }
.wind-arrow span { display: inline-block; margin-left: 12px; letter-spacing: -.18em; font-size: 18px; }
.loadbox {
margin-top: 23px; width: 156px; margin-left: auto;
font-size: 9px; letter-spacing: .08em; text-align: left;
}
.load-label { display: flex; justify-content: space-between; margin-bottom: 7px; }
.track { height: 3px; background: #b8c5bb; overflow: hidden; margin-bottom: 13px; }
.track i { display: block; height: 100%; width: 0; background: var(--ink); transition: width .15s; }
#gripBar { background: var(--accent); }
.controls {
position: absolute; bottom: 35px; left: 50%; transform: translateX(-50%);
width: min(860px, calc(100% - 48px));
padding: 17px 22px 18px;
background: rgba(245, 246, 237, .94);
border: 1px solid rgba(74, 99, 82, .26);
box-shadow: 0 7px 25px rgba(43, 62, 47, .035);
border-radius: 3px;
}
.status-row {
display: flex; align-items: center; gap: 11px;
border-bottom: 1px solid #d6dccf; padding-bottom: 13px; margin-bottom: 15px;
font-size: 10px;
}
.status-dot { width: 7px; height: 7px; flex-shrink: 0; border-radius: 50%; background: var(--ink); }
#state { font-weight: bold; letter-spacing: .09em; white-space: nowrap; }
#explanation { color: var(--muted); margin-left: auto; text-align: right; line-height: 1.4; }
.control-row { display: flex; align-items: center; gap: 24px; }
.slider-group { flex: 1; min-width: 0; }
.slider-label { display: flex; justify-content: space-between; font-size: 10px; margin-bottom: 9px; }
.slider-label label { letter-spacing: .09em; }
#setSpeed { color: var(--muted); font-variant-numeric: tabular-nums; }
input[type=range] {
display: block; width: 100%; height: 17px; margin: 0; padding: 0;
background: transparent; cursor: pointer; accent-color: var(--ink);
-webkit-appearance: none; appearance: none;
}
input[type=range]::-webkit-slider-runnable-track { height: 3px; background: #b9c6b9; }
input[type=range]::-moz-range-track { height: 3px; background: #b9c6b9; }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px;
background: var(--ink); border: 3px solid var(--paper); border-radius: 50%; margin-top: -6px;
box-shadow: 0 0 0 1px var(--ink);
}
input[type=range]::-moz-range-thumb {
width: 10px; height: 10px; background: var(--ink);
border: 3px solid var(--paper); border-radius: 50%; box-shadow: 0 0 0 1px var(--ink);
}
.buttons { display: flex; gap: 9px; }
button {
border: 1px solid #acbaac; background: transparent; color: var(--ink);
height: 39px; padding: 0 14px; border-radius: 2px;
font-size: 10px; cursor: pointer; white-space: nowrap;
}
button:hover { background: #e2e8dc; }
button[aria-pressed=true] { background: var(--ink); border-color: var(--ink); color: #f4f5eb; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; }
.hint {
position: absolute; bottom: 12px; width: 100%; text-align: center;
font-size: 9px; color: #607369; letter-spacing: .025em;
}
.hint b { font-weight: normal; color: var(--ink); }
@media (max-width: 600px) {
header { top: 23px; left: 22px; }
h1 { margin-top: 14px; font-size: 37px; }
header p { width: 165px; font-size: 11px; }
.eyebrow { font-size: 8px; letter-spacing: .12em; }
.readout { top: 25px; right: 22px; }
.readout .caption { font-size: 8px; }
.speed { font-size: 49px; }
.unit { font-size: 10px; margin-left: 5px; }
.wind-arrow { font-size: 9px; margin-top: 1px; }
.loadbox { margin-top: 16px; width: 114px; font-size: 8px; }
.controls { width: calc(100% - 28px); padding: 13px 15px; bottom: 32px; }
.control-row { flex-wrap: wrap; gap: 13px; }
.slider-group { flex-basis: 100%; }
.buttons { width: 100%; }
button { flex: 1; height: 35px; }
.status-row { margin-bottom: 12px; padding-bottom: 10px; gap: 7px; font-size: 9px; }
#explanation { max-width: 155px; font-size: 8px; }
.hint { font-size: 8px; bottom: 11px; }
}
@media (max-height: 520px) and (min-width: 601px) {
header { top: 16px; left: 22px; }
h1 { font-size: 32px; margin: 10px 0; }
header p, .loadbox { display: none; }
.readout { top: 16px; right: 22px; }
.speed { font-size: 44px; }
.controls { bottom: 28px; padding: 11px 16px; }
.status-row { padding-bottom: 8px; margin-bottom: 9px; }
.hint { bottom: 8px; }
}
</style>
</head>
<body>
<canvas id="scene" role="img" aria-label="An animated person braces against rising wind. Their umbrella bends, inverts, and can fly away."></canvas>
<header>
<div class="eyebrow">FIELD NOTES / WIND STUDY 001</div>
<h1>A losing<br>argument.</h1>
<p>One person. One umbrella.<br>Increasingly bad weather.</p>
</header>
<aside class="readout" aria-label="Wind and umbrella instruments">
<div class="caption">CURRENT WIND</div>
<div class="speed"><output id="speed">16</output><span class="unit">km/h</span></div>
<div class="wind-arrow">gusts included <span aria-hidden="true">────→</span></div>
<div class="loadbox">
<div class="load-label"><span>FRAME LOAD</span><output id="force">0 N</output></div>
<div class="track"><i id="loadBar"></i></div>
<div class="load-label"><span>GRIP SLIP</span><output id="slip">0%</output></div>
<div class="track"><i id="gripBar"></i></div>
</div>
</aside>
<section class="controls" aria-label="Simulation controls">
<div class="status-row">
<span class="status-dot" id="dot" aria-hidden="true"></span>
<span id="state" role="status" aria-live="polite">01 / HOLDING</span>
<span id="explanation">For now, this is a reasonable idea.</span>
</div>
<div class="control-row">
<div class="slider-group">
<div class="slider-label">
<label for="wind">SET WIND</label>
<output id="setSpeed" for="wind">16 / 130 km/h</output>
</div>
<input id="wind" type="range" min="0" max="130" step="1" value="16" aria-label="Base wind speed in kilometres per hour">
</div>
<div class="buttons">
<button id="auto" aria-pressed="true" title="Toggle automatically rising wind (Space)">↗ Auto rise</button>
<button id="replace" title="Give the person another umbrella (R)">↺ New umbrella</button>
</div>
</div>
</section>
<div class="hint"><b>← →</b> change wind · <b>Space</b> auto rise · <b>R</b> new umbrella</div>
<script>
(() => {
'use strict';
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const ui = Object.fromEntries(
['wind','speed','setSpeed','force','slip','loadBar','gripBar',
'auto','replace','state','explanation','dot'].map(id => [id, document.getElementById(id)])
);
const controls = document.querySelector('.controls');
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const mix = (a, b, t) => a + (b - a) * t;
const approach = (a, b, rate, dt) => mix(a, b, 1 - Math.exp(-rate * dt));
const ink = '#253e3c';
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let W = 0, H = 0, scale = 1, originX = 0, groundY = 0, dpr = 1;
let time = 0, baseWind = 16, actualWind = 16, automatic = true;
let load = 0, gripSlip = 0, inversionCharge = 0, recovery = 0;
let inverted = false, attached = true, goneTime = 0;
let angle = .06, angularVelocity = 0, lean = .05, crouch = 0;
let flight = null, lastStatus = '', lastUI = -1;
let pose = null;
const shaftLength = 134;
const ribs = [-1, -.52, 0, .52, 1].map(u => ({
u, x: u * 95, y: 46 + (1 - u*u) * 10, vx: 0, vy: 0
}));
const rain = [], splashes = [], scraps = [];
// A seeded generator is used only for the static scenery.
let seed = 7391;
function random() {
seed = (1664525 * seed + 1013904223) >>> 0;
return seed / 4294967296;
}
const grain = Array.from({length: 1400}, () => [random(), random(), random()]);
const reeds = Array.from({length: 30}, () => ({
x: random(), h: 5 + random() * 17, n: random()
}));
function resize() {
W = window.innerWidth;
H = window.innerHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
groundY = Math.min(H * .77, controls.getBoundingClientRect().top - 29);
scale = Math.max(.35, Math.min(1.4, W / 490, (groundY - 145) / 310));
originX = W * (W > 800 ? .49 : .47);
rain.length = 0;
const count = Math.round(clamp(W * H / 3300, 75, reduced ? 140 : 380));
for (let i = 0; i < count; i++) {
rain.push({
x: Math.random() * W, y: Math.random() * groundY,
z: .5 + Math.random() * .7, length: 9 + Math.random() * 15
});
}
}
window.addEventListener('resize', resize);
resize();
function setAuto(value) {
automatic = value;
ui.auto.setAttribute('aria-pressed', String(value));
ui.auto.textContent = value ? '↗ Auto rise' : '↗ Rise paused';
}
function manualWind(value) {
baseWind = clamp(value, 0, 130);
setAuto(false);
syncUI(true);
}
ui.wind.addEventListener('input', () => manualWind(Number(ui.wind.value)));
ui.auto.addEventListener('click', () => setAuto(!automatic));
function newUmbrella() {
attached = true;
inverted = false;
flight = null;
gripSlip = inversionCharge = recovery = goneTime = 0;
angle = .05;
angularVelocity = 0;
for (const r of ribs) {
r.x = r.u * 95;
r.y = 46 + (1 - r.u*r.u) * 10;
r.vx = r.vy = 0;
}
syncUI(true);
}
ui.replace.addEventListener('click', newUmbrella);
window.addEventListener('keydown', e => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLButtonElement) {
if (e.key.toLowerCase() === 'r') newUmbrella();
return;
}
if (['ArrowRight','ArrowUp','ArrowLeft','ArrowDown'].includes(e.key)) {
e.preventDefault();
manualWind(baseWind + (['ArrowRight','ArrowUp'].includes(e.key) ? 4 : -4));
} else if (e.code === 'Space') {
e.preventDefault();
if (!e.repeat) setAuto(!automatic);
} else if (e.key.toLowerCase() === 'r') {
newUmbrella();
}
});
function getPose() {
const bob = Math.sin(time * 3) * .45 * lean;
const hip = { x: -lean * 22, y: -85 + crouch * 13 };
const shoulder = { x: -lean * 52, y: -145 + crouch * 15 + bob };
const releaseRelax = attached ? 0 : clamp((goneTime - .65) / 1.1, 0, 1);
const grip = {
x: shoulder.x + 39 + crouch * 4 - releaseRelax * 17,
y: shoulder.y + 18 + crouch * 2 + releaseRelax * 22
};
const lowerGrip = {
x: grip.x - Math.sin(angle) * 15,
y: grip.y + Math.cos(angle) * 15
};
return { hip, shoulder, grip, lowerGrip };
}
function detach() {
attached = false;
goneTime = 0;
const p = getPose().grip;
flight = {
x: p.x, y: p.y, vx: 85 + actualWind * 1.4,
vy: -145 - load * .25, a: angle,
av: .7 + angularVelocity, age: 0
};
gripSlip = 1;
}
function physics(dt) {
time += dt;
if (automatic) baseWind = Math.min(130, baseWind + dt * 2.05);
// Smooth, deterministic gusts. Manual zero really is calm.
const gust = 1 + .085 * Math.sin(time * 1.37)
+ .052 * Math.sin(time * 3.19 + 1)
+ .075 * Math.pow(Math.max(0, Math.sin(time * .61 - 1)), 5);
actualWind = approach(actualWind, baseWind * gust, 2.8, dt);
const v = actualWind / 3.6;
const orientation = .5 + .5 * Math.max(0, Math.cos(angle));
load = .5 * 1.225 * v * v * .62 * orientation * (inverted ? .68 : 1);
if (attached) {
if (!inverted) {
inversionCharge = clamp(inversionCharge +
(load > 98 ? (load - 98) / 30 : -.85) * dt, 0, 1);
if (inversionCharge >= .7) {
inverted = true;
recovery = 0;
angularVelocity += .6;
for (const r of ribs) r.vy -= 80 * (0.5 + Math.abs(r.u));
}
} else {
recovery = actualWind < 27 ? recovery + dt : 0;
if (recovery > 1.25) {
inverted = false;
inversionCharge = 0;
recovery = 0;
angularVelocity -= .2;
}
}
// Rotational spring about the upper hand.
const desired = Math.min(1.38, load * (inverted ? .0065 : .0052));
const buffeting = Math.sin(time * 9.2) * Math.min(.11, load / 1800);
angularVelocity += ((desired + buffeting - angle) * 34
- angularVelocity * 8.3) * dt;
angle += angularVelocity * dt;
const tension = load * (1 + Math.abs(angularVelocity) * .08);
gripSlip = clamp(gripSlip +
(tension > 150 ? (tension - 150) / 145 : -.3) * dt, 0, 1);
if (gripSlip >= 1) detach();
} else {
goneTime += dt;
if (flight) {
flight.age += dt;
// Translation and tumble are independent after the constraint breaks.
const airX = v * 21;
flight.vx += (airX - flight.vx) * .48 * dt;
flight.vy += (120 - load * .38 * Math.exp(-flight.age * .65)) * dt;
flight.x += flight.vx * dt;
flight.y += flight.vy * dt;
flight.av += (.75 * Math.sin(flight.a + .6) - flight.av * .18) * dt;
flight.a += flight.av * dt;
if (flight.y > 50 && flight.vy > 0) {
flight.y = 50;
flight.vy *= -.25;
flight.av += 1.8;
}
if (originX + flight.x * scale > W + 330 * scale || flight.age > 16) flight = null;
}
}
const normalizedLoad = clamp(load / 160, 0, 1.8);
for (const r of ribs) {
const u = r.u;
const flutter = Math.sin(time * (9.7 + u) + u * 4) * normalizedLoad;
const targetX = u * 95 * (1 - .045 * normalizedLoad)
+ normalizedLoad * 7 * (1 - Math.abs(u) * .35);
const targetY = (inverted ? -50 : 46) + (1 - u*u) * 10
- Math.max(0, -u) * normalizedLoad * 13
+ flutter * (inverted ? 4.1 : 1.8);
r.vx += ((targetX - r.x) * 135 - r.vx * 15) * dt;
r.vy += ((targetY - r.y) * 170 - r.vy * 13) * dt;
r.x += r.vx * dt;
r.y += r.vy * dt;
}
const bodyLoad = clamp(actualWind / 125, 0, 1);
const heldPull = attached ? clamp(load / 210, 0, 1) : 0;
lean = approach(lean, clamp(bodyLoad * .68 + heldPull * .4, 0, 1.05), 3, dt);
crouch = approach(crouch, clamp(bodyLoad * .52 + heldPull * .5, 0, 1), 3, dt);
pose = getPose();
weather(dt);
}
function weather(dt) {
const vx = actualWind / 3.6 * 26 * scale;
const vy = 310 * scale;
for (const r of rain) {
r.x += vx * r.z * dt;
r.y += vy * r.z * dt;
if (r.y > groundY + 4) {
if (splashes.length < 45 && Math.random() < .19) {
splashes.push({x:r.x, y:groundY + Math.random()*18, age:0, size:2+Math.random()*3});
}
r.y = -Math.random() * 50;
r.x = Math.random() * (W + 250) - 220;
}
if (r.x > W + 40) {
r.x = -40;
r.y = Math.random() * groundY;
}
}
for (let i = splashes.length - 1; i >= 0; i--) {
splashes[i].age += dt;
if (splashes[i].age > .45) splashes.splice(i, 1);
}
if (!reduced && actualWind > 32 && scraps.length < 7 && Math.random() < dt * .7) {
scraps.push({x:-50,y:groundY-5-Math.random()*55,v:0,spin:Math.random()*6});
}
for (let i = scraps.length-1; i >= 0; i--) {
const p = scraps[i];
p.v += dt;
p.x += (actualWind * 3 + 20) * dt;
p.y += Math.sin(p.v * 5 + p.spin) * 24 * dt;
if (p.x > W + 50) scraps.splice(i,1);
}
}
function line(points, color, width) {
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.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
// A two-bone geometric solve; the endpoints, especially the feet, are constraints.
function joint(a, b, l1, l2, side) {
const dx = b.x-a.x, dy = b.y-a.y;
const d = Math.max(.01, Math.hypot(dx,dy));
const reach = Math.min(d, l1+l2-.001);
const along = (l1*l1 - l2*l2 + reach*reach) / (2*reach);
const height = Math.sqrt(Math.max(0,l1*l1-along*along));
return {
x:a.x + dx/d*along - dy/d*height*side,
y:a.y + dy/d*along + dx/d*height*side
};
}
function background() {
const sky = ctx.createLinearGradient(0,0,0,H);
sky.addColorStop(0, '#e8ebe1');
sky.addColorStop(.72, '#d8e1d6');
sky.addColorStop(1, '#cbd5c8');
ctx.fillStyle = sky;
ctx.fillRect(0,0,W,H);
// Far bank: a low, quiet horizon rather than a competing backdrop.
ctx.beginPath();
ctx.moveTo(0, groundY-30);
ctx.bezierCurveTo(W*.18,groundY-51,W*.25,groundY-20,W*.43,groundY-31);
ctx.bezierCurveTo(W*.65,groundY-52,W*.83,groundY-29,W,groundY-47);
ctx.lineTo(W,H); ctx.lineTo(0,H); ctx.closePath();
ctx.fillStyle = '#c9d3c6'; ctx.fill();
ctx.fillStyle = '#bfcbbd';
ctx.fillRect(0,groundY+2,W,H-groundY);
line([{x:0,y:groundY+2},{x:W,y:groundY+2}], '#8da28f', 1);
// Water worn into the path.
ctx.fillStyle = '#d6dfd2';
for (const p of [[.19,22,65,2],[.7,28,104,2.5],[.85,11,42,1.5],[.36,44,70,2]]) {
ctx.beginPath();
ctx.ellipse(W*p[0],groundY+p[1]*scale,p[2]*scale,p[3]*scale,0,0,Math.PI*2);
ctx.fill();
}
ctx.strokeStyle = '#8ba18b';
ctx.lineWidth = .85;
for (const r of reeds) {
if (Math.abs(r.x*W-originX) < 110*scale) continue;
const x = r.x * W;
const bend = (actualWind/130)*r.h*.9;
ctx.beginPath();
ctx.moveTo(x,groundY+1);
ctx.quadraticCurveTo(x+bend*.35,groundY-r.h*.75,x+bend,groundY-r.h);
ctx.stroke();
}
ctx.fillStyle = 'rgba(42,66,45,.045)';
for (const g of grain) ctx.fillRect(g[0]*W,g[1]*H,g[2]>.9?1.6:1,1);
// Sparse, slowly travelling eddies.
ctx.strokeStyle = 'rgba(248,250,239,.32)';
ctx.lineWidth = 1;
for (let i=0;i<5;i++) {
const x = ((time*(20+actualWind*.55)+i*W*.29)%(W+360))-270;
const y = groundY-65-i*43*scale;
ctx.beginPath();
ctx.moveTo(x,y);
ctx.bezierCurveTo(x+55,y-5,x+95,y+7,x+175,y-1);
ctx.stroke();
}
}
function drawRain() {
const vx = actualWind / 3.6 * 26;
const vy = 310;
const mag = Math.hypot(vx,vy);
ctx.lineWidth = .8;
ctx.strokeStyle = 'rgba(67,101,94,.24)';
ctx.beginPath();
for (const r of rain) {
const length = r.length * (.65 + mag/850) * scale * r.z;
ctx.moveTo(r.x,r.y);
ctx.lineTo(r.x-vx/mag*length,r.y-vy/mag*length);
}
ctx.stroke();
for (const p of splashes) {
ctx.globalAlpha = (1-p.age/.45)*.36;
ctx.strokeStyle = '#eef3e5';
ctx.beginPath();
ctx.ellipse(p.x,p.y,p.size+p.age*13,1+p.age*2,0,0,Math.PI*2);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.fillStyle = '#879c82';
for (const p of scraps) {
ctx.save();
ctx.translate(p.x,p.y);
ctx.rotate(p.v*8+p.spin);
ctx.beginPath(); ctx.moveTo(-4,0); ctx.lineTo(1,-2); ctx.lineTo(5,1); ctx.lineTo(-1,2);
ctx.closePath(); ctx.fill(); ctx.restore();
}
}
function drawPersonBase() {
const p = pose;
const hip = p.hip, shoulder = p.shoulder;
// The feet never inherit motion from the torso.
const backFoot = {x:-29,y:-7};
const frontFoot = {x:34,y:-7};
const backHip = {x:hip.x-8,y:hip.y};
const frontHip = {x:hip.x+8,y:hip.y};
const backKnee = joint(backHip,backFoot,47,47,-1);
const frontKnee = joint(frontHip,frontFoot,46,48,-1);
ctx.fillStyle = 'rgba(48,72,50,.13)';
ctx.beginPath(); ctx.ellipse(2,3,75,5,0,0,Math.PI*2); ctx.fill();
line([backHip,backKnee,backFoot], '#455650', 12);
line([frontHip,frontKnee,frontFoot], '#2b423e', 13);
line([{x:-36,y:-4},{x:-18,y:-4}], '#243833', 10);
line([{x:27,y:-4},{x:47,y:-4}], '#243833', 10);
line([{x:-39,y:0},{x:-13,y:0}], '#718278', 2);
line([{x:24,y:0},{x:52,y:0}], '#718278', 2);
// Far sleeve.
const farShoulder = {x:shoulder.x-8,y:shoulder.y+6};
const elbow = joint(farShoulder,p.lowerGrip,33,32,1);
line([farShoulder,elbow,p.lowerGrip], '#6a7a62', 15);
// A heavy, flared raincoat.
ctx.beginPath();
ctx.moveTo(shoulder.x-12,shoulder.y-5);
ctx.quadraticCurveTo(shoulder.x+3,shoulder.y-13,shoulder.x+15,shoulder.y+1);
ctx.lineTo(hip.x+18,hip.y+12);
ctx.quadraticCurveTo(hip.x+1,hip.y+18,hip.x-23,hip.y+9);
ctx.quadraticCurveTo(shoulder.x-20,shoulder.y+21,shoulder.x-12,shoulder.y-5);
ctx.closePath();
ctx.fillStyle = '#89916a'; ctx.fill();
ctx.strokeStyle = '#536950'; ctx.lineWidth = 1.2; ctx.stroke();
line([
{x:shoulder.x+10,y:shoulder.y+5},
{x:hip.x+10,y:hip.y+9}
], '#bdba8a', 1.4);
line([
{x:hip.x-10,y:hip.y-11},
{x:hip.x+1,y:hip.y-13}
], '#536950', 1.5);
// Neck and a small side-profile head.
const hx = shoulder.x-1, hy = shoulder.y-24;
line([{x:hx,y:hy+13},{x:shoulder.x+2,y:shoulder.y}], '#b58766', 10);
ctx.save();
ctx.translate(hx,hy);
ctx.rotate(-lean*.13);
ctx.beginPath();
ctx.moveTo(-12,-9);
ctx.quadraticCurveTo(1,-21,11,-8);
ctx.lineTo(12,-1);
ctx.lineTo(17,3);
ctx.lineTo(12,6);
ctx.quadraticCurveTo(12,17,3,17);
ctx.quadraticCurveTo(-11,14,-12,-9);
ctx.fillStyle = '#c69877'; ctx.fill();
ctx.beginPath();
ctx.moveTo(-14,-2);
ctx.quadraticCurveTo(-16,-20,1,-20);
ctx.quadraticCurveTo(13,-19,14,-7);
ctx.lineTo(18,-5); ctx.lineTo(18,-2);
ctx.lineTo(-14,-2);
ctx.fillStyle = '#354c46'; ctx.fill();
line([{x:-11,y:-2},{x:-9,y:9}], '#354c46', 5);
line([{x:8,y:1},{x:11,y:2}], '#35433b', 1.5);
ctx.restore();
// Scarf tail advects downstream.
const sx = shoulder.x-6, sy = shoulder.y-7;
ctx.beginPath();
ctx.moveTo(sx,sy);
ctx.quadraticCurveTo(sx+21,sy+3,sx+29+lean*19,sy-7+Math.sin(time*7)*lean*3);
ctx.lineTo(sx+28+lean*19,sy-1+Math.sin(time*7+.5)*lean*3);
ctx.quadraticCurveTo(sx+18,sy+11,sx,sy+5);
ctx.closePath(); ctx.fillStyle = '#ad563b'; ctx.fill();
}
function drawUmbrella(x,y,a,free) {
ctx.save();
ctx.translate(x,y);
ctx.rotate(a);
const bend = clamp(load*.017,0,5);
// Shaft and crook, with the upper hand at its local origin.
ctx.beginPath();
ctx.moveTo(-12,17);
ctx.bezierCurveTo(-12,30,1,29,1,16);
ctx.lineTo(0,0);
ctx.quadraticCurveTo(bend*.25,-shaftLength*.52,bend,-shaftLength);
ctx.strokeStyle = '#2e4847'; ctx.lineWidth = 3.2; ctx.lineCap = 'round'; ctx.stroke();
ctx.beginPath();
ctx.moveTo(-12,17);
ctx.bezierCurveTo(-12,29,1,29,1,16);
ctx.strokeStyle = '#7b533e'; ctx.lineWidth = 5; ctx.stroke();
ctx.translate(bend,-shaftLength);
const colors = inverted
? ['#a34e3d','#c26048','#b4533d','#d17254']
: ['#c85239','#e46a49','#d85c3f','#ed7954'];
function control(r) {
return {x:r.x*.48 + clamp(load/100,0,2)*1.5, y:r.y*.095};
}
// Each panel shares its two bounding spring endpoints with its neighbours.
for (let i=0;i<ribs.length-1;i++) {
const a=ribs[i], b=ribs[i+1], ca=control(a), cb=control(b);
const sag=inverted ? -5 : 7;
ctx.beginPath();
ctx.moveTo(0,0);
ctx.quadraticCurveTo(ca.x,ca.y,a.x,a.y);
ctx.quadraticCurveTo((a.x+b.x)*.5,(a.y+b.y)*.5+sag,b.x,b.y);
ctx.quadraticCurveTo(cb.x,cb.y,0,0);
ctx.closePath();
ctx.fillStyle=colors[i]; ctx.fill();
ctx.strokeStyle='#8a4837'; ctx.lineWidth=.8; ctx.stroke();
}
for (const r of ribs) {
const c=control(r);
ctx.beginPath(); ctx.moveTo(0,0);
ctx.quadraticCurveTo(c.x,c.y,r.x,r.y);
ctx.strokeStyle=inverted?'#503f34':'rgba(102,54,40,.67)';
ctx.lineWidth=inverted?1.5:1.1; ctx.stroke();
ctx.beginPath(); ctx.arc(r.x,r.y,1.6,0,Math.PI*2);
ctx.fillStyle='#4c4a39'; ctx.fill();
}
// Runner and exposed stretchers are easiest to see in the inverted state.
if (inverted) {
ctx.strokeStyle='rgba(50,64,57,.65)';
ctx.lineWidth=1;
for (const r of [ribs[0],ribs[1],ribs[3],ribs[4]]) {
ctx.beginPath();
ctx.moveTo(0,20);
ctx.lineTo(r.x*.56,r.y*.30);
ctx.stroke();
}
line([{x:0,y:2},{x:0,y:24}], '#354c46', 2.5);
}
line([{x:0,y:2},{x:0,y:-7}], '#294440', 3);
ctx.restore();
}
function drawHands() {
const p=pose;
const s={x:p.shoulder.x+8,y:p.shoulder.y+6};
const elbow=joint(s,p.grip,31,32,1);
line([s,elbow,p.grip], '#9a9d70', 14);
line([{x:elbow.x+1,y:elbow.y-3},{x:p.grip.x-3,y:p.grip.y-3}], '#b5b68a', 1.5);
// Fingers are rendered after the shaft, visibly enclosing it.
line([
{x:p.lowerGrip.x-3,y:p.lowerGrip.y},
{x:p.lowerGrip.x+4,y:p.lowerGrip.y-1}
], '#bf8f6b', 8);
line([
{x:p.grip.x-3,y:p.grip.y+1},
{x:p.grip.x+4,y:p.grip.y-1}
], '#d0a27f', 9);
if (attached) {
line([
{x:p.grip.x+3,y:p.grip.y-3},
{x:p.grip.x+4,y:p.grip.y+2}
], '#9c7256', 1);
}
}
function draw() {
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,W,H);
background();
drawRain();
ctx.save();
ctx.translate(originX,groundY);
ctx.scale(scale,scale);
drawPersonBase();
if (attached) drawUmbrella(pose.grip.x,pose.grip.y,angle,false);
else if (flight) drawUmbrella(flight.x,flight.y,flight.a,true);
drawHands();
ctx.restore();
}
function syncUI(force=false) {
if (!force && time-lastUI < .1) return;
lastUI=time;
ui.speed.value=String(Math.round(actualWind));
ui.force.value=attached?`${Math.round(load)} N`:'—';
ui.slip.value=attached?`${Math.round(gripSlip*100)}%`:'LOST';
ui.loadBar.style.width=`${attached?clamp(load/250,0,1)*100:0}%`;
ui.gripBar.style.width=`${attached?gripSlip*100:100}%`;
ui.wind.value=String(Math.round(baseWind));
ui.setSpeed.value=`${Math.round(baseWind)} / 130 km/h`;
let state, description, color;
if (!attached) {
state='04 / LET GO';
description='The wind has custody now. Try a new umbrella.';
color='#be4d34';
} else if (gripSlip>.32) {
state='03 / LOSING GRIP';
description='Too much pull. Ease the wind to save it.';
color='#be4d34';
} else if (inverted) {
state='03 / INSIDE OUT';
description='The ribs have snapped through. Ease below 27 to reseat.';
color='#be4d34';
} else if (load>65) {
state='02 / BRACING';
description='Ribs bending. Both hands. Feet planted.';
color='#977744';
} else {
state='01 / HOLDING';
description=actualWind<8?'A rare moment of peace.':'For now, this is a reasonable idea.';
color=ink;
}
if (state!==lastStatus) {
ui.state.textContent=state;
lastStatus=state;
}
ui.explanation.textContent=description;
ui.dot.style.background=color;
}
pose=getPose();
syncUI(true);
// Fixed timesteps keep springs stable, even after a slow render frame.
const step=1/120;
let previous=0, accumulator=0;
function frame(now) {
const delta=previous?Math.min((now-previous)/1000,.05):step;
previous=now;
accumulator+=delta;
while (accumulator>=step) {
physics(step);
accumulator-=step;
}
draw();
syncUI();
requestAnimationFrame(frame);
}
document.addEventListener('visibilitychange', () => {
previous=0;
accumulator=0;
});
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
The convincing parts are the accumulating pull, springy snap-through, sideways wrenching, two-handed grip, planted boots, and the sudden separation when the umbrella escapes. Rain gets faster and more horizontal with the gusts. The slider overrides automatic rising wind; lowering it lets the person recover.
The cheats: the fabric doesn’t actually stretch or tear, ribs can’t break individually, and aerodynamic forces are approximations—not airflow simulation. Rain doesn’t collide with the canopy. Feet stay planted even in winds that could topple someone. An inverted umbrella also politely reseats itself in low wind; a real one may demand an awkward wrestling match.