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 eight triangular fabric gores stretched over compliant, tip-loaded ribs. Its signed fold is a damped state variable—positive for the usual dome, negative for an inside-out canopy—with rib bending, fabric sag, and flutter driven by the wind load. Inversion is a latching state change: the distributed pressure load, including the umbrella’s tilt and sideways shear, must exceed the remaining rib-and-seam tension continuously for 0.14 seconds; lowering the wind can release it, but only a sustained lull lets it stand back up.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leeward — an umbrella load test</title>
<style>
:root {
color-scheme: dark;
--paper: #e6e6d7;
--muted: #91a5a5;
--line: #2c4548;
--panel: #102327;
--orange: #ed9866;
--green: #a4c8b1;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: #091619;
color: var(--paper);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 14px;
}
button, input { font: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 5px;
}
.page {
width: min(1240px, calc(100% - 48px));
margin: 0 auto;
padding: 34px 0 26px;
}
header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 28px;
margin-bottom: 25px;
}
.eyebrow, .label, .tiny, .field-label, .canvas-note {
font-size: 10px;
letter-spacing: .15em;
text-transform: uppercase;
}
.eyebrow { color: var(--orange); margin-bottom: 9px; }
h1 {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(32px, 4.4vw, 58px);
font-weight: 400;
line-height: .98;
letter-spacing: -.045em;
}
h1 em {
color: var(--muted);
font-size: .57em;
font-weight: 400;
letter-spacing: -.025em;
}
header p {
max-width: 280px;
margin: 0 0 3px;
color: var(--muted);
font-size: 12px;
line-height: 1.7;
}
main {
display: grid;
grid-template-columns: minmax(0, 1fr) 294px;
gap: 20px;
align-items: start;
}
.stage {
position: relative;
min-width: 0;
aspect-ratio: 900 / 510;
overflow: hidden;
border: 1px solid #355055;
border-radius: 13px;
background: #10252c;
box-shadow: 0 24px 70px #0004;
}
canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.hud {
position: absolute;
top: 24px;
left: 25px;
pointer-events: none;
}
.hud .label { color: #b3c6c3; }
.wind-line { display: flex; align-items: baseline; gap: 10px; }
#windValue {
font-size: clamp(30px, 4.4vw, 50px);
line-height: 1.15;
font-weight: 300;
letter-spacing: -.07em;
font-variant-numeric: tabular-nums;
}
.wind-unit { font-size: 12px; color: var(--muted); }
#windKmh { margin-top: 4px; color: #92aba9; font-size: 10px; }
.canvas-note {
position: absolute;
right: 22px;
top: 25px;
color: #a8bdb8;
pointer-events: none;
text-align: right;
line-height: 1.9;
}
.contact {
display: inline-block;
padding: 6px 9px;
margin-top: 8px;
border: 1px solid #76948a66;
border-radius: 4px;
background: #0c1d21bb;
color: var(--green);
}
.contact.lost {
color: #f2ad81;
border-color: #d8836266;
}
.bottom-note {
position: absolute;
bottom: 17px;
left: 23px;
color: #a3b7b4;
pointer-events: none;
font-size: 9px;
letter-spacing: .12em;
}
.bottom-note b { color: #e3ba8c; font-weight: 400; }
aside {
padding: 23px;
border: 1px solid var(--line);
border-radius: 13px;
background: var(--panel);
}
.aside-title {
margin: 0 0 24px;
font-size: 10px;
font-weight: 400;
color: var(--muted);
letter-spacing: .15em;
text-transform: uppercase;
}
.field-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #b9c9c4;
}
output {
color: var(--paper);
letter-spacing: 0;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
input[type="range"] {
width: 100%;
height: 30px;
margin: 11px 0 1px;
accent-color: var(--orange);
cursor: ew-resize;
}
.range-ends {
display: flex;
justify-content: space-between;
color: #6f8a8b;
font-size: 9px;
}
.toggle {
display: flex;
align-items: center;
gap: 9px;
margin: 19px 0 22px;
color: #b7c9c3;
font-size: 11px;
cursor: pointer;
}
input[type="checkbox"] {
margin: 0;
width: 14px;
height: 14px;
accent-color: var(--orange);
}
.divider {
height: 1px;
margin: 0 0 19px;
background: var(--line);
}
.readout-row {
display: flex;
justify-content: space-between;
align-items: center;
margin: 13px 0 8px;
color: #91aaa5;
font-size: 10px;
letter-spacing: .06em;
}
.readout-row strong {
color: var(--paper);
font-size: 11px;
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.track {
position: relative;
height: 5px;
overflow: hidden;
background: #263d40;
border-radius: 4px;
}
.track::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 66.666%;
width: 1px;
background: #f0d6aa;
opacity: .8;
}
.track.no-threshold::after { display: none; }
.fill {
position: absolute;
inset: 0;
transform-origin: left;
transform: scaleX(0);
background: #8ebcab;
}
#loadFill { background: var(--orange); }
.state {
min-height: 19px;
margin: 24px 0 6px;
color: var(--orange);
font-size: 11px;
letter-spacing: .12em;
}
#detail {
min-height: 46px;
margin: 0;
color: #8fa8a5;
font-size: 11px;
line-height: 1.65;
}
.buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 23px 0 12px;
}
button {
min-height: 40px;
padding: 10px 7px;
border: 1px solid #456164;
border-radius: 5px;
color: #d6e0d8;
background: #183035;
font-size: 10px;
letter-spacing: .06em;
}
button:hover { background: #244047; }
button:active { transform: translateY(1px); }
#gust { border-color: #c28761; background: #d78d5f; color: #17262a; }
#gust[aria-pressed="true"] { background: #f2bd8d; }
kbd {
font: inherit;
opacity: .8;
}
.keys {
margin: 16px 0 0;
color: #789392;
font-size: 10px;
line-height: 2.1;
}
.keys kbd {
display: inline-block;
min-width: 17px;
padding: 0 4px;
margin-right: 2px;
border: 1px solid #3b5558;
border-radius: 3px;
color: #b8c9c3;
text-align: center;
}
.aside-foot {
margin: 20px 0 0;
padding-top: 16px;
border-top: 1px solid var(--line);
color: #6f8988;
font-size: 10px;
line-height: 1.7;
}
@media (max-width: 850px) {
.page { width: min(700px, calc(100% - 28px)); padding-top: 24px; }
header p { display: none; }
main { grid-template-columns: 1fr; }
.stage { min-height: 370px; }
aside { padding: 21px; }
.aside-title { margin-bottom: 18px; }
}
@media (max-width: 460px) {
.page { width: calc(100% - 20px); }
h1 { font-size: 38px; }
.stage { min-height: 350px; }
.hud { top: 17px; left: 17px; }
.canvas-note { right: 15px; top: 18px; font-size: 8px; }
.bottom-note { left: 15px; bottom: 12px; font-size: 8px; }
#windValue { font-size: 34px; }
}
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto; }
button:active { transform: none; }
}
</style>
</head>
<body>
<div class="page">
<header>
<div>
<div class="eyebrow">Field equipment / test 07</div>
<h1>Leeward.<br><em>An umbrella, versus the sky.</em></h1>
</div>
<p>You are the anchor. Drive the wind, watch the fabric answer, and try to keep one useful thing attached to the ground.</p>
</header>
<main>
<section class="stage" aria-label="Animated side-view umbrella wind test">
<canvas id="scene" role="img" aria-describedby="sceneDescription">
A person braces against the wind while an umbrella flexes, inverts,
slips from their hands, and is carried away. Rain follows the wind.
</canvas>
<p id="sceneDescription" hidden>
Wind rises automatically. The range slider or up and down arrow keys
control it. Hold G or the Gust button for a strong gust. P pauses.
R starts a new umbrella. The person plants both feet, leans into the
wind, and eventually loses the umbrella.
</p>
<div class="hud">
<div class="label">Wind at the canopy</div>
<div class="wind-line">
<output id="windValue" for="windDriver">8.0</output>
<span class="wind-unit">m/s</span>
</div>
<div id="windKmh">29 km/h · crosswind</div>
</div>
<div class="canvas-note">
Side elevation / live<br>
<span id="contact" class="contact">BOTH HANDS ON</span>
</div>
<div class="bottom-note">
<b>01</b> RAIN GOES WHERE THE WIND GOES.
<b>02</b> THE FEET STAY PUT.
</div>
</section>
<aside aria-label="Wind and load controls">
<p class="aside-title">The wind is the driver. You are the anchor.</p>
<label class="field-label" for="windDriver">
<span>Wind driver</span>
<output id="driverValue" for="windDriver">8.0 m/s</output>
</label>
<input id="windDriver" type="range" min="0" max="42" step="0.1" value="8"
aria-label="Target wind speed in metres per second">
<div class="range-ends"><span>0 / STILL</span><span>42 / SEVERE</span></div>
<label class="toggle">
<input id="autoWind" type="checkbox" checked>
<span id="autoLabel">Automatic ramp · rising</span>
</label>
<div class="divider"></div>
<div class="readout-row"><span>CANOPY LOAD</span><strong id="loadValue">0%</strong></div>
<div class="track" role="img" aria-label="Canopy load; marked threshold is inversion">
<div class="fill" id="loadFill"></div>
</div>
<div class="readout-row"><span>GRIP / CONTACT</span><strong id="gripValue">100%</strong></div>
<div class="track no-threshold" role="img" aria-label="Remaining hand grip">
<div class="fill" id="gripFill"></div>
</div>
<p id="state" class="state" aria-live="polite">HOLDING</p>
<p id="detail">A steady hand. A full canopy. The rain has not won yet.</p>
<div class="buttons">
<button id="gust" type="button" aria-pressed="false" aria-label="Hold for a strong gust">GUST <kbd>G</kbd></button>
<button id="reset" type="button">START OVER <kbd>R</kbd></button>
</div>
<div class="keys">
<kbd>↑</kbd><kbd>↓</kbd> trim wind by 1 m/s<br>
<kbd>G</kbd> hold a gust <kbd>P</kbd> pause
</div>
<p class="aside-foot">
Moving the slider takes manual control. Turn the ramp back on to
surrender it. After an inversion, a lull below 7 m/s for a few seconds
lets the canopy stand up again.
</p>
</aside>
</main>
</div>
<script>
(() => {
"use strict";
const $ = id => document.getElementById(id);
const canvas = $("scene");
const ctx = canvas.getContext("2d");
const ui = {
slider: $("windDriver"),
auto: $("autoWind"),
driver: $("driverValue"),
autoLabel: $("autoLabel"),
wind: $("windValue"),
kmh: $("windKmh"),
load: $("loadFill"),
loadValue: $("loadValue"),
grip: $("gripFill"),
gripValue: $("gripValue"),
state: $("state"),
detail: $("detail"),
contact: $("contact"),
gust: $("gust"),
reset: $("reset")
};
const TAU = Math.PI * 2;
const RADIUS = 72;
const GRIP_LENGTH = 104;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const lerp = (a, b, t) => a + (b - a) * t;
const damp = (a, b, rate, dt) => lerp(a, b, 1 - Math.exp(-rate * dt));
const view = { w: 900, h: 510, scale: 1, ground: 466, origin: 405 };
const S = {};
const person = {};
const lost = {};
const rig = {};
let rain = [];
let droplets = [];
let gustHeld = false;
let gustTap = 0;
let dpr = 1;
let accumulator = 0;
let lastTime = performance.now();
let hudClock = 0;
function resize() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (!w || !h) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
view.scale = w / (w < 560 ? 560 : 900);
view.w = w / view.scale;
view.h = h / view.scale;
view.ground = view.h - 44;
view.origin = view.w * .45;
}
function makeRain() {
rain = Array.from({ length: 165 }, () => ({
x: Math.random() * view.w,
y: Math.random() * view.h,
near: Math.random() > .67,
seed: Math.random() * 100
}));
droplets = Array.from({ length: 15 }, () => ({
x: Math.random() * 12,
age: Math.random()
}));
}
function reset() {
Object.assign(S, {
time: 0,
wind: 8,
target: 8,
auto: true,
autoDirection: 1,
paused: false,
held: true,
fold: 1,
foldVelocity: 0,
inverted: false,
calm: 0,
quality: 1,
shear: 0,
load: .1,
stress: 0,
drive: 0,
inversionTime: 0,
buckle: 0,
gripFailure: 0,
wetness: .55,
lossReason: "",
theta: .04,
angularVelocity: 0,
shaftBend: 0,
shaftVelocity: 0,
sideShift: 0,
sideVelocity: 0
});
Object.assign(person, {
hipX: 0,
hipY: -92,
shoulderX: 0,
shoulderY: -157,
headX: 0,
headY: -188,
lean: -.04,
gripX: 20,
gripY: -165
});
Object.assign(lost, {
x: 0, y: 0, vx: 0, vy: 0,
angle: 0, spin: 0, fold: 1, damage: 0
});
gustHeld = false;
gustTap = 0;
ui.slider.value = 8;
ui.auto.checked = true;
makeRain();
updateUI();
}
function manualWind(value) {
S.auto = false;
ui.auto.checked = false;
S.target = clamp(value, 0, 42);
S.autoDirection = S.target >= 41 ? -1 : 1;
}
ui.slider.addEventListener("input", () => manualWind(Number(ui.slider.value)));
ui.auto.addEventListener("change", () => {
S.auto = ui.auto.checked;
if (S.auto && S.target >= 41) S.autoDirection = -1;
});
ui.reset.addEventListener("click", reset);
function pause() {
S.paused = !S.paused;
updateUI();
}
window.addEventListener("keydown", e => {
if (e.target instanceof HTMLElement &&
e.target.matches("input, textarea, select")) return;
const k = e.key.toLowerCase();
if (k === "arrowup" || k === "arrowdown") {
e.preventDefault();
manualWind(S.target + (k === "arrowup" ? 1 : -1) * (e.shiftKey ? 3 : 1));
}
if (k === "g" && !e.repeat) {
e.preventDefault();
gustHeld = true;
}
if (k === "p" && !e.repeat) {
e.preventDefault();
pause();
}
if (k === "r" && !e.repeat) {
e.preventDefault();
reset();
}
});
window.addEventListener("keyup", e => {
if (e.key.toLowerCase() === "g") {
gustHeld = false;
gustTap = .25;
}
});
window.addEventListener("blur", () => { gustHeld = false; });
ui.gust.addEventListener("pointerdown", e => {
e.preventDefault();
gustHeld = true;
ui.gust.setPointerCapture(e.pointerId);
});
["pointerup", "pointercancel", "lostpointercapture"].forEach(name => {
ui.gust.addEventListener(name, () => { gustHeld = false; });
});
ui.gust.addEventListener("keydown", e => {
if (e.key === " " || e.key.toLowerCase() === "g") {
e.preventDefault();
gustHeld = true;
}
});
ui.gust.addEventListener("keyup", () => { gustHeld = false; });
ui.gust.addEventListener("blur", () => { gustHeld = false; });
// Two equal-length links, solved as a circle intersection.
function solveJoint(a, b, l1, l2, bendSign) {
let dx = b.x - a.x;
let dy = b.y - a.y;
const actual = Math.max(.001, Math.hypot(dx, dy));
const d = clamp(actual, Math.abs(l1 - l2) + .001, l1 + l2 - .001);
const ux = dx / actual;
const uy = dy / actual;
const along = (l1 * l1 - l2 * l2 + d * d) / (2 * d);
const height = Math.sqrt(Math.max(0, l1 * l1 - along * along));
return {
x: a.x + ux * along - uy * height * bendSign,
y: a.y + uy * along + ux * height * bendSign
};
}
// A rib tip: a signed dome height, radial shortening, and wind flutter.
function ribPoint(phi, fold = S.fold, damage = S.stress, time = S.time) {
const radial = RADIUS * (1 - .025 * damage);
const dome = .67 * (.9 + .1 * Math.abs(fold));
const flutter = Math.sin(phi * 1.7 + time * 9) *
Math.abs(fold) * S.load * .75;
return {
x: Math.sin(phi) * radial + flutter * .28,
y: -Math.sin(phi) * radial * dome * fold +
Math.cos(phi) * 10 * (.82 + .18 * Math.abs(fold))
};
}
function canopyNormal(phi) {
const a = ribPoint(phi - .01);
const b = ribPoint(phi + .01);
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.max(.001, Math.hypot(dx, dy));
const nx = dy / length * S.fold;
const ny = -dx / length * S.fold;
const c = Math.cos(S.theta);
const s = Math.sin(S.theta);
return { x: nx * c - ny * s, y: nx * s + ny * c };
}
function updateRain(dt) {
for (const r of rain) {
const fall = r.near ? 515 : 350;
const flow = r.near ? 11 : 7;
r.x += S.wind * flow * dt;
r.y += fall * dt;
if (r.y > view.ground || r.x > view.w + 180) {
r.x = -180 + Math.random() * view.w;
r.y = -30 - Math.random() * 100;
}
if (r.x < -240) r.x = view.w + 30;
}
S.wetness = clamp(S.wetness + dt * .003, 0, 1);
droplets.forEach((d, i) => {
d.age += dt * (.5 + (i % 4) * .17);
if (d.age > 1) {
d.age = 0;
d.x = Math.random();
}
});
}
function updatePerson(dt) {
person.load = S.load;
const breath = Math.sin(S.time * 1.8) * (.65 - S.load * .35);
person.lean = -.035 - S.load * .235 * (.45 + S.quality * .55) + breath * .005;
person.hipX = -S.load * 5;
person.hipY = -92 - S.load * 4 + breath;
person.shoulderX = person.hipX + Math.sin(person.lean) * 65;
person.shoulderY = person.hipY - Math.cos(person.lean) * 65;
person.headX = person.hipX + Math.sin(person.lean - .035) * 95;
person.headY = person.hipY - Math.cos(person.lean - .035) * 95;
const leftFoot = { x: -29 - S.load * 7, y: view.ground - 2 };
const rightFoot = { x: 29 + S.load * 9, y: view.ground - 2 };
rig.hip = { x: person.hipX, y: person.hipY };
rig.kneeA = solveJoint(rig.hip, leftFoot, 58, 58, -1);
rig.kneeB = solveJoint(rig.hip, rightFoot, 58, 58, -1);
rig.footA = leftFoot;
rig.footB = rightFoot;
const targetX = person.shoulderX + 44;
const targetY = person.shoulderY - 8 + S.load * 7 + breath;
person.gripX = damp(person.gripX, targetX, 14, dt);
person.gripY = damp(person.gripY, targetY, 14, dt);
const front = { x: person.gripX + 3, y: person.gripY - 9 };
const back = { x: person.gripX - 4, y: person.gripY + 10 };
const shoulder = { x: person.shoulderX, y: person.shoulderY };
rig.elbowA = solveJoint(shoulder, back, 41, 42, 1);
rig.elbowB = solveJoint(shoulder, front, 41, 42, 1);
rig.handA = back;
rig.handB = front;
}
function releaseUmbrella(reason) {
if (!S.held) return;
S.held = false;
S.lossReason = reason;
const c = Math.cos(S.theta);
const s = Math.sin(S.theta);
const bend = S.shaftBend;
lost.x = person.gripX + (-s * GRIP_LENGTH + c * bend);
lost.y = person.gripY + (c * GRIP_LENGTH + s * bend);
lost.vx = 65 + S.wind * 10;
lost.vy = -125 - Math.min(80, Math.abs(S.shear) * 25);
lost.angle = S.theta;
lost.spin = 1.8 + S.shear * 1.1;
lost.fold = S.fold;
lost.damage = S.stress;
}
function updateUmbrella(dt) {
if (!S.held) {
// Free rigid umbrella: drag, gravity, ground impact, and tumbling.
lost.vx += (S.wind * 7 - lost.vx * .085) * dt;
lost.vy += 650 * dt;
lost.x += lost.vx * dt;
lost.y += lost.vy * dt;
lost.angle += lost.spin * dt;
lost.spin = damp(lost.spin, -.2, .6, dt);
const floor = view.ground - 45;
if (lost.y > floor) {
lost.y = floor;
lost.vy *= -.27;
lost.vx *= .77;
lost.spin *= .8;
if (Math.abs(lost.vx) < 8) lost.vx = S.wind * 4;
}
return;
}
// Each visible gore contributes a distributed normal load.
let fx = 0, fy = 0, torque = 0;
const pressurePerGore = S.wind * S.wind * .0032;
for (let i = 0; i < 5; i++) {
const phi = lerp(-1.22, 1.22, i / 4);
const p = ribPoint(phi);
const n = canopyNormal(phi);
const f = {
x: n.x * pressurePerGore,
y: n.y * pressurePerGore
};
fx += f.x;
fy += f.y;
torque += p.x * f.y - (p.y - GRIP_LENGTH) * f.x;
}
const sideTorque = S.shear * 170;
torque += sideTorque;
S.stress = damp(S.stress, clamp(
Math.abs(torque) / 3800 + Math.abs(S.shear) / 3.4, 0, 1.8
), 4, dt);
// Compliance falls as the grip is lost. The remaining shaft is a spring.
const stiffness = 2750 * (.45 + .55 * S.quality);
const angularAcceleration =
(torque - stiffness * S.theta - 2300 * S.angularVelocity) / 6800;
S.angularVelocity += angularAcceleration * dt;
S.theta = clamp(S.theta + S.angularVelocity * dt, -1.12, 1.35);
S.angularVelocity += S.shear * .11 * dt;
const totalNormalForce = Math.hypot(fx, fy);
const efficiency = clamp(
totalNormalForce / Math.max(.01, S.wind * S.wind * .016), .15, 1
);
const remainingTension =
10.5 * (1 - .20 * S.stress) * (.75 + .25 * S.quality);
S.drive = (
S.wind * S.wind * .018 * efficiency +
Math.abs(S.shear) * .20
) / remainingTension;
if (S.drive > 1 && S.wind > 16) S.inversionTime += dt;
else S.inversionTime = Math.max(0, S.inversionTime - dt * 2);
if (!S.inverted && S.inversionTime > .14) {
S.inverted = true;
S.fold = .18;
S.foldVelocity = -2.8;
S.buckle = Math.max(S.buckle, .12);
}
if (S.inverted && S.wind < 7) S.calm += dt;
else S.calm = Math.max(0, S.calm - dt * 2);
if (S.inverted && S.calm > 3) {
S.inverted = false;
S.fold = -.25;
S.buckle = 0;
}
// A signed, underdamped fold: the canopy passes through flat.
const foldTarget = S.inverted ? -1 : 1;
S.foldVelocity +=
((foldTarget - S.fold) * 95 - S.foldVelocity * 13) * dt;
S.fold = clamp(S.fold + S.foldVelocity * dt, -1.08, 1.08);
if (S.fold > .25 && foldTarget === 1) S.inverted = false;
const fatigue = (
Math.abs(S.shear) * .06 +
Math.max(0, S.load - .45) * .12
) * dt;
const recovery = S.wind < 19 ? .13 * dt : 0;
S.quality = clamp(S.quality + recovery - fatigue, 0, 1);
const buckleRate = S.inverted
? .16 + Math.max(0, S.wind - 23) * .075
: -.13;
S.buckle = clamp(S.buckle + buckleRate * dt, 0, 1.3);
if (S.quality < .24 && S.wind > 24) S.gripFailure += dt;
else S.gripFailure = Math.max(0, S.gripFailure - dt * .7);
if (S.wind > 39) S.gripFailure += dt * 1.5;
const sideTarget = S.shear * 3.8 + (1 - S.quality) * 9;
S.sideVelocity += ((sideTarget - S.sideShift) * 55 - S.sideVelocity * 11) * dt;
S.sideShift += S.sideVelocity * dt;
const bendTarget =
Math.sin(S.time * 7) * S.load * .7 +
S.shear * 3.7 + S.theta * 10 - 1.5;
S.shaftVelocity += ((bendTarget - S.shaftBend) * 80 - S.shaftVelocity * 11) * dt;
S.shaftBend = clamp(S.shaftBend + S.shaftVelocity * dt, -24, 24);
if (S.buckle >= 1.2) {
releaseUmbrella("The ribs bent past the point of an argument.");
} else if (S.gripFailure >= .75) {
releaseUmbrella("The handle slid through both hands.");
} else if (S.wind > 39) {
releaseUmbrella("The canopy simply became too much sail.");
}
}
function step(dt) {
if (S.paused) return;
S.time += dt;
if (S.auto) {
S.target += S.autoDirection * .64 * dt;
if (S.target > 42) { S.target = 42; S.autoDirection = -1; }
if (S.target < 0) { S.target = 0; S.autoDirection = 1; }
}
const turbulence = clamp(S.wind / 22, 0, 1.6) * (
.8 * Math.sin(S.time * 1.13) +
.4 * Math.sin(S.time * 3.9) +
.3 * Math.sin(S.time * .37)
);
const boost = gustHeld ? 6 + Math.min(2, S.wind / 20) :
gustTap > 0 ? 4 : 0;
const commandedWind = Math.max(0, S.target + turbulence + boost);
S.wind = damp(S.wind, commandedWind, 1.9, dt);
S.load = damp(S.load, clamp((S.wind - 5) / 28, 0, 1.15), 3, dt);
S.shear = S.wind * S.wind * .00045 * (
.7 * Math.sin(S.time * 1.7 + .4) +
.3 * Math.sin(S.time * 5.1)
);
gustTap = Math.max(0, gustTap - dt);
updateRain(dt);
updatePerson(dt);
updateUmbrella(dt);
}
function circle(x, y, r, fill) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = fill;
ctx.fill();
}
function line(a, b, color, width) {
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.stroke();
}
function drawBackdrop() {
const { w, h, ground } = view;
const sky = ctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#0a1a22");
sky.addColorStop(.6, "#1a353d");
sky.addColorStop(1, "#29464b");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, w, h);
const glow = ctx.createRadialGradient(w * .67, h * .42, 0, w * .67, h * .42, w * .7);
glow.addColorStop(0, "#7b9a9020");
glow.addColorStop(1, "#7b9a9000");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, h);
// Low, moving cloud banks: deterministic rather than flickering noise.
for (let layer = 0; layer < 4; layer++) {
const drift = S.time * (3 + S.wind * 1.1 + layer * 2);
const base = 65 + layer * 35;
ctx.fillStyle = ["#142831", "#193039", "#203941", "#29454a"][layer];
ctx.globalAlpha = .8;
ctx.beginPath();
ctx.moveTo(-180, base + 55);
ctx.bezierCurveTo(
-50, base - 28,
w * .25 + Math.sin(drift * .001) * 35, base + 28,
w * .5, base
);
ctx.bezierCurveTo(
w * .77, base - 36,
w + 120, base + 38,
w + 220, base + 4
);
ctx.lineTo(w + 220, base + 75);
ctx.lineTo(-180, base + 75);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
// A wet, almost-hidden street.
const roof = ground - 92;
for (let i = 0; i < 12; i++) {
const x = i * 89 - 15;
const bh = 38 + ((i * 37) % 53);
ctx.fillStyle = i % 2 ? "#132b31" : "#10262d";
ctx.fillRect(x, roof - bh, 77, bh + 93);
ctx.fillStyle = "#304b4c55";
ctx.fillRect(x, roof - bh, 77, 2);
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 2; c++) {
ctx.fillStyle = (i + r + c) % 5 === 0 ? "#c3a87838" : "#56716c18";
ctx.fillRect(x + 13 + c * 31, roof - bh + 18 + r * 29, 10, 6);
}
}
}
// A few high, wind-stretched wires.
ctx.strokeStyle = "#819d9620";
ctx.lineWidth = 1;
for (let j = 0; j < 3; j++) {
const y = 95 + j * 26;
const sag = 12 + j * 5;
ctx.beginPath();
ctx.moveTo(-20, y);
ctx.quadraticCurveTo(w * .5, y + sag, w + 20, y - 12);
ctx.stroke();
}
ctx.fillStyle = "#10272c";
ctx.fillRect(0, ground, w, h - ground + 10);
ctx.fillStyle = "#5c79715a";
ctx.fillRect(0, ground, w, 1);
// Wind streaks in the air, with density and length tied to speed.
ctx.strokeStyle = "#c7dfd5";
ctx.lineWidth = 1;
for (let i = 0; i < 17; i++) {
if (S.wind < 3) break;
const span = w + 160;
const x = ((i * 113.7 + S.time * (S.wind * 13 + 8)) % span) - 80;
const y = 110 + ((i * 71) % Math.max(160, ground - 145));
const len = 12 + S.wind * (1.1 + (i % 3) * .45);
ctx.globalAlpha = .025 + S.wind / 42 * .045;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + len, y + len * .06);
ctx.stroke();
}
ctx.globalAlpha = 1;
drawWindsock();
}
function drawWindsock() {
const x = view.w - 43;
const y = view.ground;
const poleTop = y - 172;
const baseY = y - 155;
line({ x, y }, { x, y: poleTop }, "#718781", 2.5);
line({ x: x - 9, y }, { x: x + 9, y }, "#718781", 2);
circle(x, poleTop, 2, "#d2b98c");
const fullness = clamp(S.wind / 32, .08, 1);
const length = 28 + fullness * 37;
const droop = (1 - fullness) * 34;
ctx.beginPath();
ctx.moveTo(x, baseY);
ctx.bezierCurveTo(
x + length * .45, baseY - 8 * fullness + Math.sin(S.time * 6) * 3,
x + length * .8, baseY + droop,
x + length, baseY + droop * .7 + Math.sin(S.time * 8) * 3
);
ctx.lineTo(x + length, baseY + 9 + droop * .4 + Math.sin(S.time * 7) * 2);
ctx.bezierCurveTo(
x + length * .65, baseY + 10 + droop,
x + length * .35, baseY + 9 - fullness * 4,
x, baseY + 6
);
ctx.closePath();
ctx.fillStyle = "#b46d4d";
ctx.fill();
ctx.strokeStyle = "#e1a377";
ctx.lineWidth = 3;
ctx.stroke();
ctx.fillStyle = "#c9d6c44d";
ctx.font = "8px ui-monospace, monospace";
ctx.textAlign = "center";
ctx.fillText("W → E", x, y + 19);
}
function drawGround() {
const { w, ground, origin } = view;
const stretch = 1 + S.wind * .035;
ctx.fillStyle = "#07191b88";
ctx.beginPath();
ctx.ellipse(origin + 15, ground + 8, 77 * stretch, 8, 0, 0, TAU);
ctx.fill();
ctx.save();
ctx.translate(origin, ground);
ctx.scale(1, .18);
ctx.fillStyle = "#9bc3b514";
ctx.beginPath();
ctx.ellipse(0, 28, 54, 50, 0, 0, TAU);
ctx.fill();
ctx.restore();
// Test markings and reflected lamp light.
ctx.strokeStyle = "#cfbf8450";
ctx.lineWidth = 1;
ctx.setLineDash([5, 8]);
ctx.beginPath();
ctx.moveTo(origin - 105, ground + 13);
ctx.lineTo(origin + 117, ground + 13);
ctx.stroke();
ctx.setLineDash([]);
for (let i = 0; i < 12; i++) {
const x = i * 47 + 6;
ctx.fillStyle = i % 3 ? "#70888024" : "#93aea129";
ctx.fillRect(x, ground + 25 + (i % 3) * 6, 22 + (i % 4) * 8, 1);
}
const lampX = origin - 153;
line({ x: lampX, y: ground }, { x: lampX, y: ground - 129 }, "#4e6a65", 2);
line({ x: lampX, y: ground - 129 }, { x: lampX + 19, y: ground - 129 }, "#4e6a65", 2);
ctx.fillStyle = "#f0cc8e";
ctx.fillRect(lampX + 13, ground - 128, 9, 4);
const lamp = ctx.createRadialGradient(lampX + 18, ground - 118, 0, lampX + 18, ground - 118, 52);
lamp.addColorStop(0, "#edc28d22");
lamp.addColorStop(1, "#edc28d00");
ctx.fillStyle = lamp;
ctx.fillRect(lampX - 35, ground - 170, 105, 110);
for (const d of droplets) {
const x = ((d.x * w) + S.time * 1.4) % w;
const y = ground + 17 + Math.sin(d.x * 39) * 6;
const radius = 1 + d.age * 7;
ctx.strokeStyle = `rgba(158,191,180,${(1 - d.age) * .16})`;
ctx.lineWidth = .7;
ctx.beginPath();
ctx.ellipse(x, y, radius, radius * .24, 0, 0, TAU);
ctx.stroke();
}
}
function drawUmbrellaLocal(angle, fold, damage) {
ctx.save();
ctx.rotate(angle);
ctx.translate(S.held ? S.shaftBend : S.shaftBend * .2, 0);
// Flexible central shaft and hooked handle.
const shaftColor = damage > .65 || S.buckle > .5 ? "#d99169" : "#334c4d";
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.bezierCurveTo(
S.held ? S.shaftBend * .42 : 1,
GRIP_LENGTH * .37,
S.held ? S.shaftBend * .32 : -1,
GRIP_LENGTH * .8,
0, GRIP_LENGTH
);
ctx.strokeStyle = "#0a2025";
ctx.lineWidth = 5;
ctx.lineCap = "round";
ctx.stroke();
ctx.strokeStyle = shaftColor;
ctx.lineWidth = 2.6;
ctx.stroke();
ctx.strokeStyle = "#a5b2a14d";
ctx.lineWidth = .7;
ctx.stroke();
line({ x: 0, y: 79 }, { x: 0, y: 95 }, "#a0aaa1", 3);
line({ x: -1, y: 91 }, { x: 0, y: 111 }, "#d48659", 6);
line({ x: 1, y: 92 }, { x: 1, y: 110 }, "#edaa75", 1);
line({ x: 0, y: GRIP_LENGTH }, { x: 0, y: GRIP_LENGTH + 5 }, "#7d4c3d", 4.5);
const tips = [];
for (let i = 0; i <= 8; i++) {
tips.push(ribPoint(-Math.PI / 2 + i * Math.PI / 8, fold, damage));
}
const outer = fold < -.15;
const palette = outer
? ["#35575a", "#416366", "#3a5b5e", "#4b6a6a", "#3d6062"]
: ["#c6c8b3", "#d8d6bd", "#e2dec3", "#d2d2b8", "#e9e1c6"];
// Eight fabric gores, with bowed hems rather than a rigid polygon.
for (let i = 0; i < 8; i++) {
const a = tips[i], b = tips[i + 1];
const hem = {
x: (a.x + b.x) * .495,
y: (a.y + b.y) * .5 + 2.8 * Math.abs(fold)
};
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(a.x, a.y);
ctx.quadraticCurveTo(
(a.x + hem.x) * .5,
(a.y + hem.y) * .5 + 1.6 * fold,
hem.x, hem.y
);
ctx.quadraticCurveTo(
(hem.x + b.x) * .5,
(hem.y + b.y) * .5 + 1.6 * fold,
b.x, b.y
);
ctx.closePath();
ctx.fillStyle = palette[i % palette.length];
ctx.fill();
ctx.strokeStyle = outer ? "#142e35aa" : "#43595635";
ctx.lineWidth = .7;
ctx.stroke();
}
// A darker inner surface makes the state change legible.
if (Math.abs(fold) < .7) {
ctx.fillStyle = fold > 0 ? "#233d3920" : "#bed1ba19";
ctx.beginPath();
ctx.moveTo(-RADIUS * .75, 0);
ctx.quadraticCurveTo(0, 18 * -fold, RADIUS * .75, 0);
ctx.quadraticCurveTo(0, 9, -RADIUS * .75, 0);
ctx.fill();
}
const ribColor = S.buckle > .55 ? "#e4a273" :
outer ? "#d8a17c" : "#3a514d";
for (let i = 0; i < tips.length; i++) {
const p = tips[i];
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(
p.x * .5 + Math.sin(S.time * 7 + i) * S.load * .6,
p.y * .5 - fold * S.load * 2.2,
p.x, p.y
);
ctx.strokeStyle = ribColor;
ctx.lineWidth = i % 2 ? 1.1 : 1.8;
ctx.lineCap = "round";
ctx.stroke();
circle(p.x, p.y, 1.35, outer ? "#e7b18b" : "#536861");
}
// Weighted hem and a small crown ferrule.
ctx.beginPath();
tips.forEach((p, i) => {
if (i === 0) ctx.moveTo(p.x, p.y);
else {
const prev = tips[i - 1];
const mx = (prev.x + p.x) * .5;
const my = (prev.y + p.y) * .5 + 2.8 * Math.abs(fold);
ctx.quadraticCurveTo(mx, my, p.x, p.y);
}
});
ctx.strokeStyle = outer ? "#bd9270" : "#f1e9cd";
ctx.lineWidth = 1.6;
ctx.stroke();
circle(0, 0, 3.8, "#dba16e");
circle(0, -1, 1.3, "#f2d6a3");
if (damage > .55 || S.buckle > .45) {
ctx.globalAlpha = clamp(damage + S.buckle * .3, 0, .75);
line({ x: -1, y: 3 }, { x: -1, y: 34 }, "#ef9b68", 2);
line({ x: 1, y: 47 }, { x: 1, y: 74 }, "#ef9b68", 2);
ctx.globalAlpha = 1;
}
ctx.restore();
}
function drawUmbrella() {
ctx.save();
if (S.held) {
const x = person.gripX + S.sideShift;
ctx.translate(x, person.gripY);
drawUmbrellaLocal(S.theta, S.fold, S.stress);
} else {
ctx.translate(lost.x, lost.y);
drawUmbrellaLocal(lost.angle, lost.fold, lost.damage);
}
ctx.restore();
}
function drawArm(hand, back) {
const shoulder = { x: person.shoulderX, y: person.shoulderY };
const elbow = back ? rig.elbowA : rig.elbowB;
line(shoulder, elbow, "#0c2428", back ? 8 : 9);
line(elbow, hand, "#0c2428", 7);
line(shoulder, elbow, back ? "#294449" : "#35565a", back ? 5 : 6);
line(elbow, { x: lerp(elbow.x, hand.x, .75), y: lerp(elbow.y, hand.y, .75) },
back ? "#294449" : "#35565a", 4);
circle(hand.x, hand.y, 4.6, "#d6b499");
ctx.strokeStyle = "#8b7662";
ctx.lineWidth = .7;
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.moveTo(hand.x - 3, hand.y - 1.5 + i * 1.8);
ctx.lineTo(hand.x + 3, hand.y - 1.5 + i * 1.8);
ctx.stroke();
}
if (!back && S.held && S.quality < .65) {
ctx.strokeStyle = "#e7a07b";
ctx.lineWidth = 1;
ctx.globalAlpha = (1 - S.quality) * .6;
ctx.beginPath();
ctx.arc(hand.x, hand.y, 7, -.9, 1.3);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
function drawPerson(backArm) {
const { ground } = view;
line(rig.hip, rig.kneeA, "#0a2025", 13);
line(rig.kneeA, rig.footA, "#0a2025", 11);
line(rig.hip, rig.kneeB, "#0a2025", 14);
line(rig.kneeB, rig.footB, "#0a2025", 12);
line(rig.hip, rig.kneeA, "#2b4145", 8);
line(rig.kneeA, rig.footA, "#33494a", 6);
line(rig.hip, rig.kneeB, "#3b5253", 9);
line(rig.kneeB, rig.footB, "#405758", 7);
// Both soles are planted. The feet turn and widen with the load.
for (const [foot, isFront] of [[rig.footA, false], [rig.footB, true]]) {
ctx.save();
ctx.translate(foot.x, foot.y);
ctx.rotate((isFront ? 1 : -1) * (.09 + S.load * .13));
ctx.fillStyle = "#101f25";
ctx.beginPath();
ctx.moveTo(-8, -5);
ctx.lineTo(12, -5);
ctx.quadraticCurveTo(17, -2, 17, 3);
ctx.lineTo(-8, 3);
ctx.closePath();
ctx.fill();
line({ x: -8, y: 3 }, { x: 17, y: 3 }, "#607674", 1);
ctx.restore();
}
if (backArm) {
drawArm(rig.handA, true);
return;
}
// A wind-whipped coat; the hem moves independently of the planted hips.
const sx = person.shoulderX;
const sy = person.shoulderY;
const hx = person.hipX;
const hy = person.hipY;
ctx.beginPath();
ctx.moveTo(sx - 15, sy - 5);
ctx.quadraticCurveTo(sx - 23, sy + 8, hx - 17, hy - 3);
ctx.lineTo(hx - 12 + S.load * 13, hy + 10);
ctx.quadraticCurveTo(
hx + 3, hy + 5 + Math.sin(S.time * 8) * S.load * 3,
hx + 18 + S.load * 18, hy + 7
);
ctx.quadraticCurveTo(sx + 22, sy + 15, sx + 15, sy - 5);
ctx.closePath();
ctx.fillStyle = S.wetness > .5 ? "#35585a" : "#416568";
ctx.fill();
ctx.strokeStyle = "#75908a66";
ctx.lineWidth = 1;
ctx.stroke();
line({ x: sx - 2, y: sy - 8 }, { x: hx - 2, y: hy - 8 }, "#203c4166", 1.3);
line({ x: hx - 15, y: hy - 10 }, { x: hx + 17, y: hy - 10 }, "#9b7950", 3);
circle(hx + 3, hy - 10, 1.3, "#d8bd82");
// Scarf: pulled toward downwind and lifted with the gust.
ctx.beginPath();
ctx.moveTo(sx + 2, sy - 10);
ctx.quadraticCurveTo(sx + 14, sy - 12, sx + 27 + S.load * 6, sy - 5);
ctx.quadraticCurveTo(
sx + 17 + S.load * 9, sy + 3,
sx + 1, sy - 3
);
ctx.closePath();
ctx.fillStyle = "#bd7654";
ctx.fill();
line({ x: sx + 2, y: sy - 5 }, { x: sx + 9, y: sy - 6 }, "#7f503e", 1.2);
// Head, tucked lower as the body braces.
ctx.save();
ctx.translate(person.headX, person.headY);
ctx.rotate(person.lean * .23);
circle(0, 0, 17, "#d3b195");
ctx.fillStyle = "#d9c59d";
ctx.beginPath();
ctx.arc(0, -4, 17, Math.PI, TAU);
ctx.lineTo(17, -1);
ctx.quadraticCurveTo(0, 3, -17, -1);
ctx.closePath();
ctx.fill();
line({ x: -17, y: 0 }, { x: 16, y: 0 }, "#8d9c8c", 2);
circle(7, 1, 1.3, "#253a3b");
ctx.beginPath();
ctx.moveTo(15, 1);
ctx.lineTo(20, 4);
ctx.lineTo(15, 7);
ctx.fillStyle = "#d3b195";
ctx.fill();
ctx.strokeStyle = "#bfb791";
ctx.lineWidth = 1.4;
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.moveTo(-13 + i * 4, -7);
ctx.lineTo(-20 - i * 3 + S.load * 2, -7 - i * 2 - S.load * 4);
ctx.stroke();
}
ctx.restore();
}
function drawRain() {
for (const r of rain) {
const vx = S.wind * (r.near ? 11 : 7);
const vy = r.near ? 515 : 350;
const raw = Math.hypot(vx, vy);
const f = Math.min(.024, 210 / Math.max(1, raw));
ctx.strokeStyle = r.near ? "#c4dedc4a" : "#a0c3c02b";
ctx.lineWidth = r.near ? .9 : .6;
ctx.beginPath();
ctx.moveTo(r.x, r.y);
ctx.lineTo(r.x - vx * f, r.y - vy * f);
ctx.stroke();
}
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#10252c";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(view.scale, view.scale);
drawBackdrop();
drawGround();
drawPerson(true);
drawUmbrella();
drawPerson(false);
drawRain();
}
function updateUI() {
const ratio = S.held ? Math.min(2, S.drive) : 0;
const quality = S.held ? S.quality : 0;
ui.wind.textContent = S.wind.toFixed(1);
ui.kmh.textContent = `${Math.round(S.wind * 3.6)} km/h · crosswind`;
ui.driver.textContent = `${S.target.toFixed(1)} m/s`;
ui.slider.value = S.target.toFixed(1);
ui.auto.checked = S.auto;
ui.autoLabel.textContent = S.paused ? "Simulation paused" :
S.auto ? `Automatic ramp · ${S.autoDirection > 0 ? "rising" : "easing"}` :
"Manual driver";
ui.load.style.transform = `scaleX(${ratio / 1.5})`;
ui.grip.style.transform = `scaleX(${quality})`;
ui.loadValue.textContent = `${Math.round(ratio * 100)}%`;
ui.gripValue.textContent = `${Math.round(quality * 100)}%`;
ui.gust.setAttribute("aria-pressed", String(gustHeld));
ui.contact.textContent = S.held ? "BOTH HANDS ON" : "NO CONTACT";
ui.contact.classList.toggle("lost", !S.held);
let state, detail;
if (!S.held) {
state = lost.x > view.w + 140 ? "GONE DOWNWIND" : "UMBRELLA LOST";
detail = S.lossReason + " The hands are still full of rain.";
} else if (S.buckle > .5) {
state = "FRAME YIELDING";
detail = "The ribs are taking a set. It will not be a graceful departure.";
} else if (S.inverted) {
state = S.calm > 1 ? "WAITING FOR A LULL" : "CANOPY INVERTED";
detail = "The fabric has turned its face to the sky. Less wind may bring it back.";
} else if (S.drive > .65) {
state = "CANOPY IS LOADING";
detail = "The crown is flattening, the hem is flapping, and the grip is slipping.";
} else if (S.wind > 10) {
state = "BRACING";
detail = "Weight into the heels. Shoulders down. Both hands staying on.";
} else {
state = "HOLDING";
detail = "A steady hand. A full canopy. The rain has not won yet.";
}
if (ui.state.textContent !== state) ui.state.textContent = state;
ui.detail.textContent = detail;
}
function frame(now) {
const dt = Math.min(.05, Math.max(0, (now - lastTime) / 1000));
lastTime = now;
if (!S.paused) {
accumulator += dt;
const fixed = 1 / 120;
while (accumulator >= fixed) {
step(fixed);
accumulator -= fixed;
}
} else {
accumulator = 0;
}
draw();
hudClock += dt;
if (hudClock > .10) {
updateUI();
hudClock = 0;
}
requestAnimationFrame(frame);
}
document.addEventListener("visibilitychange", () => {
lastTime = performance.now();
accumulator = 0;
});
new ResizeObserver(resize).observe(canvas);
window.addEventListener("resize", resize);
resize();
reset();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
What it gets wrong: this is a deliberately small force model, not cloth simulation or a real aerodynamic test. “Pressure” is a normalized, distributed approximation, and that normalization determines the inversion threshold rather than measured canopy pressure. There are no fabric collisions, self-contacting folds, seam tearing, progressive strand failures, or a real gust field—the flutter is procedural. The inverted canopy does not snap and chatter convincingly, and the handle is too straight to wrap realistically around the fingers. The person is an inverse-kinematic brace, not a muscle or balance solver: their feet are anchored instead of stepping, catching, or being dragged. Rain follows a uniform wind field rather than experiencing canopy shelter, and the wetness and puddles are visual rather than a water model.