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 a reduced two-dimensional rib-and-panel model: curved radial ribs carry tensioned fabric panels, while a damped torsional spring loads the shaft and a second spring lets the ribs flex downwind. Inversion is a latched state change that reverses the canopy curvature and changes the visible fabric; it occurs when the combined wind pressure, shaft tilt, and any manual pull push the normalized frame load above its buckling threshold for long enough, and it remains inverted until the simulation is reset. The person’s planted feet provide the reaction force while their lean, crouch, arm extension, and footing respond to the same load.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<title>Hold the Line — Umbrella Wind Study</title>
<style>
:root {
--ink: #edf4ef;
--muted: #9fb3b7;
--panel: rgba(8, 19, 28, 0.72);
--line: rgba(213, 234, 231, 0.14);
--accent: #f2b84b;
--teal: #75d1c1;
--danger: #ff6658;
}
* { box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #07131f;
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input { font: inherit; }
.app {
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
isolation: isolate;
}
#scene {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.glass {
background:
linear-gradient(135deg, rgba(255,255,255,0.055), transparent 45%),
var(--panel);
border: 1px solid var(--line);
box-shadow: 0 18px 55px rgba(0,0,0,0.24);
backdrop-filter: blur(14px) saturate(125%);
-webkit-backdrop-filter: blur(14px) saturate(125%);
}
.masthead {
position: absolute;
z-index: 3;
top: 30px;
left: 34px;
pointer-events: none;
text-shadow: 0 2px 20px rgba(0,0,0,0.35);
}
.eyebrow {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 10px;
color: #b6c9c8;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.19em;
text-transform: uppercase;
}
.eyebrow::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--teal);
box-shadow: 0 0 15px rgba(117,209,193,0.85);
}
h1 {
margin: 0;
font-size: clamp(30px, 3.2vw, 52px);
font-weight: 680;
line-height: 0.98;
letter-spacing: -0.045em;
}
.masthead p {
margin: 11px 0 0;
color: #a9bdbe;
font-size: 13px;
letter-spacing: 0.015em;
}
.wind-card {
position: absolute;
z-index: 4;
top: 28px;
right: 32px;
width: 248px;
padding: 17px 18px 16px;
border-radius: 18px;
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
color: #b4c6c6;
font-size: 9px;
font-weight: 750;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.wind-arrow {
color: var(--teal);
font-size: 23px;
font-weight: 400;
line-height: 0.6;
}
.wind-number {
display: flex;
align-items: baseline;
gap: 8px;
margin: 7px 0 3px;
}
.wind-number strong {
font-size: 54px;
font-weight: 580;
line-height: 1;
letter-spacing: -0.065em;
font-variant-numeric: tabular-nums;
}
.wind-number small {
color: #b6c8c8;
font-size: 13px;
font-weight: 650;
}
.wind-description {
display: flex;
justify-content: space-between;
gap: 10px;
color: #d1dddd;
font-size: 10px;
line-height: 1.35;
}
.wind-description span:last-child {
color: #829b9f;
font-variant-numeric: tabular-nums;
}
.wind-gauge,
.meter {
position: relative;
overflow: hidden;
background: rgba(213,234,231,0.1);
}
.wind-gauge {
height: 3px;
margin-top: 14px;
border-radius: 999px;
}
.wind-gauge span {
display: block;
width: 12%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--teal), var(--accent));
transition: width 100ms linear;
}
.load-card {
position: absolute;
z-index: 4;
top: 214px;
right: 32px;
width: 248px;
padding: 16px 18px;
border-radius: 18px;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.micro-label {
color: #819a9e;
font-size: 9px;
font-weight: 750;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.state {
display: flex;
align-items: center;
gap: 7px;
color: #b8c9c8;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.11em;
}
.state::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--teal);
box-shadow: 0 0 11px rgba(117,209,193,0.55);
}
.state[data-mode="buckling"]::before,
.state[data-mode="inverting"]::before {
background: var(--accent);
box-shadow: 0 0 11px rgba(242,184,75,0.7);
}
.state[data-mode="inside"]::before,
.state[data-mode="lost"]::before {
background: var(--danger);
box-shadow: 0 0 11px rgba(255,102,88,0.75);
}
.meter-row {
display: grid;
grid-template-columns: 66px 1fr 38px;
align-items: center;
gap: 9px;
margin-top: 9px;
color: #a5b9bb;
font-size: 9px;
font-weight: 650;
letter-spacing: 0.05em;
}
.meter {
height: 5px;
border-radius: 99px;
}
.meter span {
display: block;
height: 100%;
width: 0;
border-radius: inherit;
transition: width 90ms linear;
}
#loadFill { background: linear-gradient(90deg, #70cbbd, #f0b448 68%, #ff6658); }
#gripFill { background: linear-gradient(90deg, #6fc9bc, #b3e2c9); }
.meter-value {
text-align: right;
color: #d9e5e2;
font-variant-numeric: tabular-nums;
}
.event-line {
min-height: 30px;
margin: 14px 0 0;
padding-top: 12px;
border-top: 1px solid rgba(213,234,231,0.1);
color: #a8babc;
font-size: 10px;
line-height: 1.45;
}
.stance {
float: right;
color: var(--teal);
font-size: 8px;
font-weight: 800;
letter-spacing: 0.1em;
}
.dock {
position: absolute;
z-index: 5;
left: 50%;
bottom: max(24px, env(safe-area-inset-bottom));
width: min(760px, calc(100% - 48px));
padding: 16px 18px 14px;
border-radius: 20px;
transform: translateX(-50%);
}
.slider-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 11px;
}
.slider-heading label {
color: #b7c9c9;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.slider-heading output {
color: #f3f5ef;
font-size: 12px;
font-weight: 680;
font-variant-numeric: tabular-nums;
}
input[type="range"] {
--fill: 16%;
width: 100%;
height: 5px;
margin: 0;
border: 0;
border-radius: 999px;
outline: none;
appearance: none;
-webkit-appearance: none;
background: linear-gradient(90deg, var(--teal) 0%, var(--teal) var(--fill), rgba(213,234,231,0.13) var(--fill), rgba(213,234,231,0.13) 100%);
cursor: ew-resize;
}
input[type="range"]::-webkit-slider-thumb {
width: 18px;
height: 18px;
border: 3px solid #d9eee7;
border-radius: 50%;
appearance: none;
-webkit-appearance: none;
background: #0c1b25;
box-shadow: 0 0 0 5px rgba(117,209,193,0.11);
}
input[type="range"]::-moz-range-thumb {
width: 13px;
height: 13px;
border: 3px solid #d9eee7;
border-radius: 50%;
background: #0c1b25;
box-shadow: 0 0 0 5px rgba(117,209,193,0.11);
}
input[type="range"]:focus-visible { box-shadow: 0 0 0 3px rgba(117,209,193,0.2); }
.range-labels {
display: flex;
justify-content: space-between;
margin-top: 7px;
color: #70898e;
font-size: 8px;
font-weight: 650;
letter-spacing: 0.08em;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 13px;
}
button {
min-height: 34px;
padding: 0 13px;
border: 1px solid rgba(213,234,231,0.14);
border-radius: 10px;
color: #dce8e5;
background: rgba(255,255,255,0.055);
font-size: 9px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease, transform 100ms ease;
}
button:hover {
border-color: rgba(213,234,231,0.32);
background: rgba(255,255,255,0.095);
}
button:active { transform: translateY(1px); }
button:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
button.active {
color: #0a1a20;
border-color: var(--teal);
background: var(--teal);
}
#gustBtn.active {
color: #271707;
border-color: var(--accent);
background: var(--accent);
}
.auto-state {
margin-left: 5px;
opacity: 0.72;
font-size: 8px;
}
.keys {
margin-left: auto;
color: #728b8f;
font-size: 9px;
letter-spacing: 0.025em;
}
kbd {
padding: 2px 4px;
border: 1px solid rgba(213,234,231,0.16);
border-radius: 4px;
color: #aebfc0;
background: rgba(255,255,255,0.04);
font: inherit;
}
.gesture-hint {
position: absolute;
z-index: 3;
left: 50%;
bottom: 151px;
display: flex;
align-items: center;
gap: 9px;
padding: 8px 12px;
border: 1px solid rgba(213,234,231,0.11);
border-radius: 999px;
color: #a7babb;
background: rgba(6,16,24,0.48);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
transform: translateX(-50%);
pointer-events: none;
}
.gesture-hint::before {
content: "↔";
color: var(--accent);
font-size: 16px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 700px) {
.masthead {
top: 17px;
left: 16px;
}
.eyebrow {
margin-bottom: 7px;
font-size: 8px;
}
h1 { font-size: 27px; }
.masthead p { display: none; }
.wind-card {
top: 14px;
right: 14px;
width: 151px;
padding: 12px 13px;
border-radius: 14px;
}
.wind-number strong { font-size: 36px; }
.wind-number small { font-size: 10px; }
.wind-description { font-size: 8px; }
.wind-description span:last-child { display: none; }
.wind-gauge { margin-top: 9px; }
.load-card {
top: 153px;
right: 14px;
width: 151px;
padding: 12px 13px;
border-radius: 14px;
}
.status-row { margin-bottom: 10px; }
.meter-row { grid-template-columns: 51px 1fr 32px; gap: 6px; font-size: 8px; }
.event-line { display: none; }
.dock {
bottom: max(10px, env(safe-area-inset-bottom));
width: calc(100% - 20px);
padding: 13px 14px 11px;
border-radius: 16px;
}
.actions { gap: 6px; }
button { min-height: 32px; padding: 0 10px; font-size: 8px; }
.keys { display: none; }
.gesture-hint { bottom: 141px; font-size: 8px; }
}
@media (max-width: 420px) {
.masthead { max-width: 180px; }
.wind-card { width: 143px; }
.load-card { width: 143px; }
.actions button { flex: 1; padding: 0 6px; }
}
@media (max-height: 660px) {
.gesture-hint { display: none; }
.load-card { top: 184px; }
}
</style>
</head>
<body>
<main class="app">
<canvas id="scene" aria-label="Side view of a person holding an umbrella as the wind rises">
This interactive wind simulation requires a browser with canvas support.
</canvas>
<header class="masthead">
<div class="eyebrow">Live wind study / 04</div>
<h1>Hold the line.</h1>
<p>One umbrella. Increasingly unreasonable weather.</p>
</header>
<section class="wind-card glass" aria-label="Current wind speed">
<div class="card-top">
<span>Current wind</span>
<span class="wind-arrow" aria-hidden="true">→</span>
</div>
<div class="wind-number">
<strong id="windValue">6.0</strong>
<small>m/s</small>
</div>
<div class="wind-description">
<span id="windWord">moderate breeze</span>
<span id="baseReadout">base 6.0</span>
</div>
<div class="wind-gauge" aria-hidden="true"><span id="windGaugeFill"></span></div>
</section>
<section class="load-card glass" aria-label="Umbrella and person status">
<div class="status-row">
<span class="micro-label">Frame state</span>
<span class="state" id="stateText" data-mode="intact">INTACT</span>
</div>
<div class="meter-row">
<span>FRAME LOAD</span>
<div class="meter"><span id="loadFill"></span></div>
<span class="meter-value" id="loadValue">0%</span>
</div>
<div class="meter-row">
<span>GRIP</span>
<div class="meter"><span id="gripFill"></span></div>
<span class="meter-value" id="gripValue">100%</span>
</div>
<p class="event-line">
<span class="stance" id="stanceValue">PLANTED</span>
<span id="eventText">The frame is intact. Both feet are planted.</span>
</p>
</section>
<div class="gesture-hint">Drag the canopy to wrench it sideways</div>
<section class="dock glass" aria-label="Wind controls">
<div class="slider-heading">
<label for="windSlider">Base wind</label>
<output id="baseValue" for="windSlider">6.0 m/s</output>
</div>
<input id="windSlider" type="range" min="0" max="38" step="0.5" value="6"
aria-label="Base wind speed in meters per second">
<div class="range-labels" aria-hidden="true">
<span>CALM</span><span>FRESH</span><span>GALE</span><span>STORM</span>
</div>
<div class="actions">
<button id="autoBtn" type="button" class="active" aria-pressed="true">
Auto rise <span class="auto-state" id="autoState">ON</span>
</button>
<button id="gustBtn" type="button">Gust +</button>
<button id="resetBtn" type="button">Reset</button>
<span class="keys"><kbd>←</kbd> <kbd>→</kbd> wind · <kbd>A</kbd> auto · <kbd>G</kbd> gust · <kbd>R</kbd> reset</span>
</div>
</section>
<div id="live" class="sr-only" aria-live="polite"></div>
</main>
<script>
(() => {
"use strict";
const canvas = document.getElementById("scene");
const ctx = canvas.getContext("2d", { alpha: false });
const ui = {
windValue: document.getElementById("windValue"),
windWord: document.getElementById("windWord"),
baseReadout: document.getElementById("baseReadout"),
windGaugeFill: document.getElementById("windGaugeFill"),
stateText: document.getElementById("stateText"),
loadFill: document.getElementById("loadFill"),
loadValue: document.getElementById("loadValue"),
gripFill: document.getElementById("gripFill"),
gripValue: document.getElementById("gripValue"),
stanceValue: document.getElementById("stanceValue"),
eventText: document.getElementById("eventText"),
windSlider: document.getElementById("windSlider"),
baseValue: document.getElementById("baseValue"),
autoBtn: document.getElementById("autoBtn"),
autoState: document.getElementById("autoState"),
gustBtn: document.getElementById("gustBtn"),
resetBtn: document.getElementById("resetBtn"),
live: document.getElementById("live")
};
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const mod = (n, m) => ((n % m) + m) % m;
function rgba(hex, alpha) {
const h = hex.replace("#", "");
const n = parseInt(h, 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${alpha})`;
}
function mixColor(a, b, t) {
t = clamp(t, 0, 1);
const aa = parseInt(a.slice(1), 16);
const bb = parseInt(b.slice(1), 16);
const ar = (aa >> 16) & 255, ag = (aa >> 8) & 255, ab = aa & 255;
const br = (bb >> 16) & 255, bg = (bb >> 8) & 255, bbv = bb & 255;
return `rgb(${Math.round(lerp(ar, br, t))},${Math.round(lerp(ag, bg, t))},${Math.round(lerp(ab, bbv, t))})`;
}
let W = 1, H = 1, DPR = 1, S = 1, groundY = 1, baseX = 1;
let time = 0;
let windSet = 6;
let autoRise = true;
let actualWind = 6;
let pressure = 0;
let gustTimer = 0;
const gustDuration = 1.8;
const person = {
lean: 0, leanV: 0,
crouch: 0, crouchV: 0,
brace: 0, braceV: 0,
handTension: 1
};
const umbrella = {
attached: true,
inverted: false,
inv: 0,
invV: 0,
angle: -0.04,
angVel: 0,
flex: 0,
flexV: 0,
stress: 0,
fatigue: 0,
slip: 0
};
const loose = {
x: 0, y: 0, vx: 0, vy: 0,
angle: 0, angularV: 0,
gone: false
};
const drag = {
active: false,
startX: 0, startY: 0,
startAngle: 0,
pointerX: 0, pointerY: 0,
load: 0,
lastDx: 0
};
let rain = [];
let splashes = [];
let windLines = [];
let puddles = [];
let pose = null;
let eventText = "The frame is intact. Both feet are planted.";
let umbrellaHit = { x: 0, y: 0, rx: 1, ry: 1 };
function makeRain() {
const count = Math.round(clamp((W * H) / 5200, 90, 280));
rain = Array.from({ length: count }, () => ({
x: Math.random() * W,
y: Math.random() * H,
speed: 570 + Math.random() * 440,
z: Math.random() > 0.76 ? 1 : 0.56
}));
}
function makeWindLines() {
windLines = Array.from({ length: Math.round(clamp(W / 26, 24, 58)) }, (_, i) => ({
x: Math.random() * W,
y: 50 + ((i * 83) % Math.max(180, groundY - 40)),
length: 35 + Math.random() * 130,
speed: 0.45 + Math.random() * 1.25,
alpha: 0.025 + Math.random() * 0.06
}));
}
function makePuddles() {
puddles = Array.from({ length: 18 }, (_, i) => ({
x: ((i * 0.173 + 0.037) % 1) * W,
y: groundY + 15 + ((i * 37) % 95),
rx: 24 + ((i * 29) % 80),
ry: 2 + ((i * 7) % 6)
}));
}
function resize() {
const rect = canvas.getBoundingClientRect();
W = Math.max(1, rect.width);
H = Math.max(1, rect.height);
DPR = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
S = clamp(Math.min(H / 740, W / 820), 0.58, 1.15);
groundY = W < 700 ? H * 0.69 : H * 0.80;
baseX = W < 700 ? W * 0.32 : W * 0.355;
makeRain();
makeWindLines();
makePuddles();
}
function makePose() {
const hip = {
x: baseX + (-2 + person.lean * 11) * S,
y: groundY - (94 - person.crouch * 18) * S
};
const shoulder = {
x: hip.x + (39 + person.lean * 11) * S,
y: hip.y - (66 - person.crouch * 4) * S
};
const head = {
x: shoulder.x + (10 + person.lean * 5) * S,
y: shoulder.y - 27 * S
};
let hand;
if (umbrella.attached) {
const reach = (53 + Math.min(42, pressure * 25)) * S;
hand = {
x: shoulder.x + reach,
y: shoulder.y + (10 + Math.min(8, pressure * 4)) * S
};
} else {
hand = {
x: shoulder.x + 43 * S,
y: shoulder.y + 39 * S
};
}
return { hip, shoulder, head, hand };
}
function announce(message) {
ui.live.textContent = "";
window.setTimeout(() => { ui.live.textContent = message; }, 20);
}
function resetSimulation(withAnnouncement = true) {
time = 0;
windSet = 6;
autoRise = true;
actualWind = 6;
pressure = Math.pow(actualWind / 19.5, 2);
gustTimer = 0;
Object.assign(person, {
lean: 0, leanV: 0, crouch: 0, crouchV: 0,
brace: 0, braceV: 0, handTension: 1
});
Object.assign(umbrella, {
attached: true, inverted: false, inv: 0, invV: 0,
angle: -0.04, angVel: 0, flex: 0, flexV: 0,
stress: 0, fatigue: 0, slip: 0
});
Object.assign(loose, {
x: 0, y: 0, vx: 0, vy: 0,
angle: 0, angularV: 0, gone: false
});
Object.assign(drag, {
active: false, load: 0, lastDx: 0
});
eventText = "The frame is intact. Both feet are planted.";
ui.windSlider.value = 6;
pose = makePose();
if (withAnnouncement) announce("The wind study has been reset.");
}
function fireGust() {
gustTimer = gustDuration;
eventText = "A hard lateral gust is crossing the frame.";
announce("A hard gust is arriving.");
}
function setManualWind(value) {
windSet = clamp(value, 0, 38);
autoRise = false;
}
function triggerInversion() {
if (umbrella.inverted) return;
umbrella.inverted = true;
umbrella.inv = Math.max(umbrella.inv, 0.035);
eventText = "The ribs buckled. The canopy is turning inside out.";
announce("The umbrella is inverting.");
}
function releaseUmbrella() {
if (!umbrella.attached) return;
umbrella.attached = false;
person.handTension = 0;
loose.x = pose.hand.x;
loose.y = pose.hand.y;
loose.vx = 75 + actualWind * 4.2 + Math.max(0, umbrella.angVel) * 45;
loose.vy = -85 - actualWind * 0.7;
loose.angle = umbrella.angle;
loose.angularV = 2.1 + actualWind * 0.025;
eventText = "The grip failed. The umbrella is loose in the wind.";
announce("The umbrella has torn free and is blowing away.");
}
function updateWind(dt) {
if (autoRise) windSet = Math.min(38, windSet + dt * 0.16);
const turbulence = windSet > 0
? (Math.sin(time * 0.71) + 0.55 * Math.sin(time * 1.93 + 1.1) +
0.25 * Math.sin(time * 3.17)) * (0.7 + windSet * 0.035)
: 0;
if (gustTimer > 0) gustTimer = Math.max(0, gustTimer - dt);
let gust = 0;
if (gustTimer > 0) {
const p = 1 - gustTimer / gustDuration;
gust = Math.sin(Math.PI * p) * (5 + windSet * 0.22);
}
actualWind = clamp(windSet + turbulence + gust, 0, 48);
pressure = Math.pow(actualWind / 19.5, 2);
for (const line of windLines) {
line.x += actualWind * line.speed * 34 * dt;
if (line.x > W + line.length + 40) {
line.x = -line.length - Math.random() * 80;
line.y = 40 + Math.random() * Math.max(150, groundY - 70);
}
}
}
function updatePerson(dt) {
const leanTarget = clamp(pressure * 0.62, 0, 1);
const crouchTarget = clamp((pressure - 0.38) * 0.48, 0, 0.78);
const braceTarget = clamp((pressure - 0.35) * 0.86, 0, 1);
person.leanV += (leanTarget - person.lean) * 18 * dt;
person.leanV *= Math.exp(-7.5 * dt);
person.lean = clamp(person.lean + person.leanV * dt, 0, 1);
person.crouchV += (crouchTarget - person.crouch) * 17 * dt;
person.crouchV *= Math.exp(-7 * dt);
person.crouch = clamp(person.crouch + person.crouchV * dt, 0, 0.8);
person.braceV += (braceTarget - person.brace) * 19 * dt;
person.braceV *= Math.exp(-8 * dt);
person.brace = clamp(person.brace + person.braceV * dt, 0, 1);
if (umbrella.attached) {
person.handTension = clamp(
1 - Math.max(0, umbrella.stress - 0.72) * 0.64 - umbrella.inv * 0.23,
0,
1
);
} else {
person.handTension = 0;
}
pose = makePose();
}
function updateAttachedUmbrella(dt) {
if (!drag.active) {
let desired = -0.05 + pressure * 0.30 + pressure * pressure * 0.22;
desired *= 1 - person.brace * 0.075;
desired += umbrella.inv * 0.18;
const angularAcceleration =
(desired - umbrella.angle) * 3.7 - umbrella.angVel * 2.45 +
Math.sin(time * 3.4) * pressure * 0.035;
umbrella.angVel += angularAcceleration * dt;
umbrella.angle += umbrella.angVel * dt;
} else {
drag.load = clamp(Math.abs(drag.lastDx) / (115 * S), 0, 1.8);
}
const flexTarget = clamp(pressure * 0.49 + drag.load * 0.57, 0, 1.5);
umbrella.flexV += (flexTarget - umbrella.flex) * 27 * dt;
umbrella.flexV *= Math.exp(-7 * dt);
umbrella.flex = clamp(umbrella.flex + umbrella.flexV * dt, 0, 1.6);
const tiltMultiplier = 1 + Math.min(0.35, Math.abs(umbrella.angle) * 0.12);
umbrella.stress = pressure * tiltMultiplier + drag.load * 0.86;
if (!umbrella.inverted) {
umbrella.fatigue += Math.max(0, umbrella.stress - 0.78) * dt;
if (
umbrella.stress > 1.08 &&
(umbrella.fatigue > 0.08 || Math.abs(umbrella.angle) > 0.68)
) {
triggerInversion();
}
}
if (umbrella.inverted) {
umbrella.invV += (1 - umbrella.inv) * 20 * dt;
umbrella.invV *= Math.exp(-7 * dt);
umbrella.inv = clamp(umbrella.inv + umbrella.invV * dt, 0, 1);
}
umbrella.slip = clamp(
Math.max(0, umbrella.stress - 0.88) * 0.75 + umbrella.inv * 0.28,
0,
1
);
if (
umbrella.stress > 1.55 ||
(umbrella.inv > 0.72 && umbrella.stress > 1.18) ||
umbrella.angle > 1.18
) {
releaseUmbrella();
}
if (!drag.active) drag.load *= Math.exp(-4.8 * dt);
}
function estimateLowest(angle) {
const shaftLength = 145 * S;
const radius = 132 * S;
const hubY = -Math.cos(angle) * shaftLength;
const rimY = radius * Math.abs(Math.sin(angle)) + 38 * S;
const handleY = Math.abs(Math.cos(angle)) * 34 * S;
return Math.max(30 * S, hubY + rimY, handleY);
}
function updateLooseUmbrella(dt) {
loose.vx += (actualWind * 2.4 - loose.vx * 0.28) * dt;
loose.vy += (620 - loose.vy * 1.25) * dt;
loose.x += loose.vx * dt;
loose.y += loose.vy * dt;
loose.angularV += (
Math.sin(time * 3.2 + loose.x * 0.008) * 0.38 - loose.angularV * 0.25
) * dt;
loose.angle += loose.angularV * dt;
const lowest = estimateLowest(loose.angle);
if (loose.y + lowest > groundY) {
loose.y = groundY - lowest;
loose.vy = Math.abs(loose.vy) * 0.2;
loose.angularV *= 0.72;
loose.vx *= 0.97;
}
if (loose.x > W + 540) {
loose.gone = true;
eventText = "The umbrella is gone. The wind kept it.";
announce("The umbrella has left the scene.");
}
}
function updateRain(dt) {
for (const drop of rain) {
drop.x += actualWind * 22 * drop.z * dt;
drop.y += drop.speed * dt;
if (drop.x > W + 45) drop.x = -45;
if (drop.y > groundY + 5) {
if (drop.z > 0.9 && Math.random() < 0.22) {
splashes.push({
x: drop.x,
y: groundY + 2,
vx: 18 + Math.random() * 38,
vy: -25 - Math.random() * 42,
life: 0.28 + Math.random() * 0.2,
maxLife: 0.48
});
}
drop.y = -25 - Math.random() * H * 0.25;
drop.x = Math.random() * W;
}
}
for (let i = splashes.length - 1; i >= 0; i--) {
const p = splashes[i];
p.life -= dt;
p.vy += 360 * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.life <= 0) splashes.splice(i, 1);
}
}
function step(dt) {
time += dt;
updateWind(dt);
updatePerson(dt);
if (umbrella.attached) updateAttachedUmbrella(dt);
else updateLooseUmbrella(dt);
updateRain(dt);
}
function jointIK(a, b, lengthA, lengthB, bend) {
const dx = b.x - a.x;
const dy = b.y - a.y;
let d = Math.hypot(dx, dy);
const ux = d > 0 ? dx / d : 0;
const uy = d > 0 ? dy / d : 1;
if (d >= lengthA + lengthB) {
return { x: a.x + ux * lengthA, y: a.y + uy * lengthA };
}
d = Math.max(d, Math.abs(lengthA - lengthB) + 0.001);
const along = (lengthA * lengthA - lengthB * lengthB + d * d) / (2 * d);
const height = Math.sqrt(Math.max(0, lengthA * lengthA - along * along));
return {
x: a.x + ux * along - uy * height * bend,
y: a.y + uy * along + ux * height * bend
};
}
function drawLimb(a, b, width, color) {
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = "rgba(3,10,16,0.58)";
ctx.lineWidth = width + 3 * S;
ctx.stroke();
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.stroke();
ctx.restore();
}
function drawArm(shoulder, hand, far) {
const elbow = jointIK(shoulder, hand, 47 * S, 50 * S, 1);
const color = far ? "#102a3b" : "#23495d";
drawLimb(shoulder, elbow, far ? 11 * S : 13 * S, color);
drawLimb(elbow, hand, far ? 9.5 * S : 11.5 * S, color);
ctx.save();
ctx.strokeStyle = far ? "#19394b" : "#31596a";
ctx.lineWidth = 2 * S;
ctx.beginPath();
ctx.moveTo(lerp(elbow.x, hand.x, 0.74), lerp(elbow.y, hand.y, 0.74));
ctx.lineTo(lerp(elbow.x, hand.x, 0.9), lerp(elbow.y, hand.y, 0.9));
ctx.stroke();
ctx.restore();
}
function drawBoot(x, y, front) {
ctx.save();
ctx.translate(x, y);
ctx.fillStyle = front ? "#071117" : "#050b10";
ctx.beginPath();
ctx.moveTo(-12 * S, -7 * S);
ctx.lineTo(5 * S, -7 * S);
ctx.quadraticCurveTo(15 * S, -5 * S, 20 * S, 0);
ctx.quadraticCurveTo(21 * S, 4 * S, 15 * S, 4 * S);
ctx.lineTo(-14 * S, 4 * S);
ctx.quadraticCurveTo(-18 * S, 1 * S, -12 * S, -7 * S);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(177,204,204,0.2)";
ctx.lineWidth = 1.2 * S;
ctx.beginPath();
ctx.moveTo(-8 * S, -5 * S);
ctx.lineTo(10 * S, -5 * S);
ctx.stroke();
ctx.restore();
}
function drawPerson() {
if (!pose) return;
const p = pose;
const hip = p.hip;
const shoulder = p.shoulder;
const head = p.head;
const backFoot = {
x: baseX - 30 * S - person.brace * 13 * S,
y: groundY - 3 * S
};
const frontFoot = {
x: baseX + 34 * S + person.brace * 10 * S,
y: groundY - 2 * S
};
const backKnee = jointIK(hip, backFoot, 49 * S, 52 * S, -1);
const frontKnee = jointIK(hip, frontFoot, 49 * S, 52 * S, -1);
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.32)";
ctx.beginPath();
ctx.ellipse(baseX + 8 * S, groundY + 2 * S, 64 * S, 10 * S, 0, 0, Math.PI * 2);
ctx.fill();
if (person.brace > 0.12) {
ctx.strokeStyle = `rgba(224,181,97,${0.12 + person.brace * 0.22})`;
ctx.lineWidth = 1.4 * S;
for (let i = 0; i < 3; i++) {
const x = backFoot.x - 12 * S - i * 8 * S;
ctx.beginPath();
ctx.moveTo(x, groundY + 1 * S);
ctx.lineTo(x - 13 * S, groundY - 1 * S);
ctx.stroke();
}
}
drawLimb(hip, backKnee, 15 * S, "#102331");
drawLimb(backKnee, backFoot, 12 * S, "#102331");
drawLimb(hip, frontKnee, 16 * S, "#193544");
drawLimb(frontKnee, frontFoot, 12.5 * S, "#193544");
drawBoot(backFoot.x, backFoot.y, false);
drawBoot(frontFoot.x, frontFoot.y, true);
const farHand = umbrella.attached
? { x: p.hand.x - 5 * S, y: p.hand.y - 5 * S }
: { x: shoulder.x + 17 * S, y: shoulder.y + 53 * S };
drawArm({ x: shoulder.x - 5 * S, y: shoulder.y + 3 * S }, farHand, true);
const tailWave = Math.sin(time * 8.2) * 5 * S * clamp(actualWind / 14, 0, 1);
const tailLength = (40 + Math.min(54, actualWind * 3.2)) * S;
ctx.fillStyle = "#102c3d";
ctx.beginPath();
ctx.moveTo(hip.x - 8 * S, hip.y + 4 * S);
ctx.quadraticCurveTo(hip.x - tailLength * 0.55, hip.y + 9 * S + tailWave, hip.x - tailLength, hip.y + 26 * S + tailWave);
ctx.quadraticCurveTo(hip.x - tailLength * 0.58, hip.y + 37 * S, hip.x - 5 * S, hip.y + 36 * S);
ctx.closePath();
ctx.fill();
const coatGradient = ctx.createLinearGradient(shoulder.x, shoulder.y, hip.x, hip.y + 35 * S);
coatGradient.addColorStop(0, "#315a6c");
coatGradient.addColorStop(0.5, "#23495d");
coatGradient.addColorStop(1, "#102b3c");
ctx.fillStyle = coatGradient;
ctx.beginPath();
ctx.moveTo(shoulder.x - 17 * S, shoulder.y + 2 * S);
ctx.quadraticCurveTo(shoulder.x - 13 * S, shoulder.y - 9 * S, shoulder.x + 7 * S, shoulder.y - 8 * S);
ctx.quadraticCurveTo(shoulder.x + 21 * S, shoulder.y - 5 * S, shoulder.x + 19 * S, shoulder.y + 9 * S);
ctx.lineTo(hip.x + 23 * S, hip.y + 39 * S);
ctx.quadraticCurveTo(hip.x + 7 * S, hip.y + 57 * S, hip.x - 19 * S, hip.y + 43 * S);
ctx.quadraticCurveTo(hip.x - 22 * S, hip.y + 18 * S, shoulder.x - 17 * S, shoulder.y + 2 * S);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(169,205,207,0.16)";
ctx.lineWidth = 1.4 * S;
ctx.beginPath();
ctx.moveTo(shoulder.x + 3 * S, shoulder.y + 3 * S);
ctx.quadraticCurveTo(hip.x + 5 * S, hip.y + 18 * S, hip.x + 8 * S, hip.y + 43 * S);
ctx.stroke();
const scarfLength = (27 + Math.min(42, actualWind * 2.7)) * S;
const scarfWave = Math.sin(time * 10 + 0.7) * 5 * S;
ctx.fillStyle = "#cf653f";
ctx.beginPath();
ctx.moveTo(shoulder.x - 2 * S, shoulder.y - 7 * S);
ctx.quadraticCurveTo(
shoulder.x - scarfLength * 0.58, shoulder.y - 10 * S + scarfWave,
shoulder.x - scarfLength, shoulder.y - 3 * S + scarfWave
);
ctx.quadraticCurveTo(
shoulder.x - scarfLength * 0.6, shoulder.y + 3 * S + scarfWave,
shoulder.x - 7 * S, shoulder.y + 1 * S
);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "#c9a17e";
ctx.beginPath();
ctx.ellipse(head.x, head.y, 14 * S, 16 * S, -0.04, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#111d25";
ctx.beginPath();
ctx.moveTo(head.x - 13 * S, head.y - 2 * S);
ctx.quadraticCurveTo(head.x - 17 * S, head.y - 18 * S, head.x + 2 * S, head.y - 16 * S);
ctx.quadraticCurveTo(head.x + 12 * S, head.y - 15 * S, head.x + 14 * S, head.y - 5 * S);
ctx.quadraticCurveTo(head.x + 2 * S, head.y - 12 * S, head.x - 13 * S, head.y - 2 * S);
ctx.closePath();
ctx.fill();
const hairLength = (10 + Math.min(24, actualWind * 1.35)) * S;
ctx.strokeStyle = "#111d25";
ctx.lineWidth = 4 * S;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(head.x - 8 * S, head.y - 8 * S);
ctx.quadraticCurveTo(head.x - hairLength, head.y - 13 * S, head.x - hairLength - 7 * S, head.y - 5 * S);
ctx.stroke();
ctx.fillStyle = "#c9a17e";
ctx.beginPath();
ctx.moveTo(head.x + 11 * S, head.y - 1 * S);
ctx.lineTo(head.x + 18 * S, head.y + 3 * S);
ctx.lineTo(head.x + 11 * S, head.y + 6 * S);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "#263035";
ctx.lineWidth = 1.8 * S;
ctx.beginPath();
ctx.moveTo(head.x + 4 * S, head.y - 1 * S);
ctx.lineTo(head.x + 10 * S, head.y - 2 * S);
ctx.stroke();
ctx.fillStyle = "#cf653f";
ctx.beginPath();
ctx.moveTo(shoulder.x - 13 * S, shoulder.y - 8 * S);
ctx.quadraticCurveTo(shoulder.x, shoulder.y - 15 * S, shoulder.x + 13 * S, shoulder.y - 6 * S);
ctx.lineTo(shoulder.x + 12 * S, shoulder.y + 2 * S);
ctx.quadraticCurveTo(shoulder.x, shoulder.y - 3 * S, shoulder.x - 14 * S, shoulder.y + 2 * S);
ctx.closePath();
ctx.fill();
drawArm(shoulder, p.hand, false);
ctx.restore();
}
function stressColor(value) {
if (value <= 1) return mixColor("#cadbd5", "#f2b84b", clamp(value, 0, 1));
return mixColor("#f2b84b", "#ff5b4d", clamp(value - 1, 0, 1));
}
function drawUmbrella(gx, gy, angle) {
const shaftLength = 145 * S;
const radius = 132 * S;
const inv = umbrella.inv;
const stress = umbrella.stress;
const frameColor = stressColor(stress);
ctx.save();
ctx.translate(gx, gy);
ctx.rotate(angle);
ctx.save();
if (stress > 0.95) {
ctx.shadowColor = rgba(stress > 1.25 ? "#ff5b4d" : "#f2b84b", 0.65);
ctx.shadowBlur = (stress - 0.8) * 12 * S;
}
ctx.strokeStyle = frameColor;
ctx.lineWidth = 5.3 * S;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(0, 19 * S);
ctx.quadraticCurveTo(umbrella.flex * 6 * S, -shaftLength * 0.48, 0, -shaftLength);
ctx.stroke();
ctx.restore();
ctx.strokeStyle = "#27343b";
ctx.lineWidth = 8 * S;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(0, 2 * S);
ctx.lineTo(0, 19 * S);
ctx.quadraticCurveTo(0, 34 * S, -12 * S, 34 * S);
ctx.quadraticCurveTo(-24 * S, 34 * S, -24 * S, 22 * S);
ctx.stroke();
ctx.translate(0, -shaftLength);
function surface(u) {
const u2 = u * u;
const normalOuter = (-42 + 50 * u2) * S;
const invertedOuter = (42 - 72 * u2) * S;
const normalInner = normalOuter + (12 + 18 * (1 - u2)) * S;
const invertedInner = invertedOuter - (10 + 4 * (1 - u2)) * S;
let outer = lerp(normalOuter, invertedOuter, inv);
let inner = lerp(normalInner, invertedInner, inv);
const flutter = Math.sin(time * 9.2 + u * 5.1 + 1.3) *
umbrella.flex * 3.2 * S * (0.35 + 0.65 * (1 - u2));
outer += flutter;
inner += flutter * 0.72;
const localWindX = Math.cos(angle);
const localWindY = -Math.sin(angle);
const shift = umbrella.flex * 15 * S * (0.35 + 0.65 * (u + 1) * 0.5);
return {
outer: { x: u * radius + localWindX * shift, y: outer + localWindY * shift },
inner: { x: u * radius + localWindX * shift, y: inner + localWindY * shift }
};
}
const outer = [];
const inner = [];
for (let i = 0; i <= 48; i++) {
const u = -1 + (2 * i) / 48;
const p = surface(u);
outer.push(p.outer);
inner.push(p.inner);
}
const fabricA = mixColor("#f4c45e", "#e36a6c", inv);
const fabricB = mixColor("#d47a32", "#843f59", inv);
const fabric = ctx.createLinearGradient(-radius, -42 * S, radius, 35 * S);
fabric.addColorStop(0, fabricA);
fabric.addColorStop(0.52, mixColor("#efaa42", "#cf5d67", inv));
fabric.addColorStop(1, fabricB);
ctx.beginPath();
ctx.moveTo(outer[0].x, outer[0].y);
for (let i = 1; i < outer.length; i++) ctx.lineTo(outer[i].x, outer[i].y);
for (let i = inner.length - 1; i >= 0; i--) ctx.lineTo(inner[i].x, inner[i].y);
ctx.closePath();
ctx.fillStyle = fabric;
ctx.globalAlpha = 0.9;
ctx.fill();
ctx.globalAlpha = 1;
ctx.save();
ctx.clip();
const sheen = ctx.createLinearGradient(-radius, 0, radius, 0);
sheen.addColorStop(0, "rgba(255,255,255,0.02)");
sheen.addColorStop(0.5, "rgba(255,255,255,0.14)");
sheen.addColorStop(1, "rgba(0,0,0,0.08)");
ctx.fillStyle = sheen;
ctx.fillRect(-radius, -60 * S, radius * 2, 120 * S);
ctx.restore();
ctx.strokeStyle = rgba(inv > 0.5 ? "#ffd2c1" : "#ffe39b", 0.72);
ctx.lineWidth = 2.2 * S;
ctx.beginPath();
ctx.moveTo(outer[0].x, outer[0].y);
for (let i = 1; i < outer.length; i++) ctx.lineTo(outer[i].x, outer[i].y);
ctx.stroke();
ctx.save();
ctx.lineCap = "round";
for (const u of [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]) {
const p = surface(u).inner;
const controlX = p.x * 0.43;
const controlY = p.y * 0.24 + inv * 20 * S * (1 - u * u);
ctx.strokeStyle = frameColor;
ctx.globalAlpha = 0.72;
ctx.lineWidth = (u === 0 ? 2.8 : 2.15) * S;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(controlX, controlY, p.x, p.y);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.fillStyle = frameColor;
ctx.beginPath();
ctx.arc(p.x, p.y, 2.5 * S, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
ctx.fillStyle = "#263b43";
ctx.beginPath();
ctx.arc(0, 0, 6.5 * S, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = frameColor;
ctx.beginPath();
ctx.arc(-1 * S, -1 * S, 2.2 * S, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
if (umbrella.attached) {
umbrellaHit = {
x: gx + Math.sin(angle) * shaftLength,
y: gy - Math.cos(angle) * shaftLength,
rx: radius * 1.16,
ry: 68 * S
};
}
}
function drawHand() {
if (!pose) return;
ctx.save();
if (umbrella.attached) {
ctx.translate(pose.hand.x, pose.hand.y);
ctx.rotate(umbrella.angle);
ctx.translate(0, umbrella.slip * 6 * S);
ctx.fillStyle = "#cfa582";
ctx.beginPath();
ctx.ellipse(3 * S, 1 * S, 7.2 * S, 5.2 * S, -0.15, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#a8755c";
ctx.lineWidth = 1.5 * S;
ctx.lineCap = "round";
for (let i = -1; i <= 1; i++) {
ctx.beginPath();
ctx.moveTo(-1 * S, i * 3.2 * S);
ctx.quadraticCurveTo(4 * S, i * 3.2 * S + 1 * S, 6 * S, i * 3.2 * S - 1 * S);
ctx.stroke();
}
} else {
const hand = pose.hand;
ctx.translate(hand.x, hand.y);
ctx.fillStyle = "#cfa582";
ctx.beginPath();
ctx.ellipse(0, 0, 6 * S, 8 * S, -0.25, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#cfa582";
ctx.lineWidth = 2.6 * S;
ctx.lineCap = "round";
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(3 * S, (i - 1.5) * 3 * S);
ctx.lineTo(12 * S, (i - 1.5) * 4 * S);
ctx.stroke();
}
}
ctx.restore();
}
function drawBackground() {
const storm = clamp(pressure / 2.1, 0, 1);
const sky = ctx.createLinearGradient(0, 0, 0, groundY);
sky.addColorStop(0, mixColor("#071521", "#050811", storm));
sky.addColorStop(0.58, mixColor("#244552", "#172634", storm));
sky.addColorStop(1, mixColor("#60777b", "#2a3d49", storm));
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < 7; i++) {
const x = mod(i * 291 - time * (10 + actualWind * 3.2), W + 600) - 300;
const y = 52 + (i * 83) % Math.max(130, groundY * 0.36);
const r = (68 + (i * 17) % 62) * S;
ctx.save();
ctx.translate(x, y);
ctx.scale(2.3, 0.72);
const cloud = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
cloud.addColorStop(0, `rgba(190,207,207,${0.055 + storm * 0.025})`);
cloud.addColorStop(0.62, "rgba(145,170,176,0.027)");
cloud.addColorStop(1, "rgba(80,110,120,0)");
ctx.fillStyle = cloud;
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
const horizon = groundY - 37 * S;
ctx.fillStyle = rgba("#07141d", 0.66);
const spacing = 72 * S;
for (let i = -1; i < Math.ceil(W / spacing) + 1; i++) {
const x = i * spacing;
const bw = (33 + ((i * 37) % 43)) * S;
const bh = (25 + ((i * 53) % 100)) * S;
ctx.fillRect(x, horizon - bh, bw, bh + 4 * S);
if (i % 3 === 0) {
ctx.fillStyle = rgba("#b9c9c5", 0.045);
for (let wy = horizon - bh + 13 * S; wy < horizon - 7 * S; wy += 18 * S) {
ctx.fillRect(x + 9 * S, wy, 3 * S, 5 * S);
if (bw > 50 * S) ctx.fillRect(x + 26 * S, wy, 3 * S, 5 * S);
}
ctx.fillStyle = rgba("#07141d", 0.66);
}
}
ctx.strokeStyle = "rgba(173,203,204,0.12)";
ctx.lineWidth = 1.2 * S;
ctx.beginPath();
ctx.moveTo(0, horizon - 3 * S);
ctx.lineTo(W, horizon - 3 * S);
ctx.stroke();
for (let x = 18 * S; x < W; x += 94 * S) {
ctx.beginPath();
ctx.moveTo(x, horizon - 3 * S);
ctx.lineTo(x, horizon + 20 * S);
ctx.stroke();
}
const ground = ctx.createLinearGradient(0, groundY, 0, H);
ground.addColorStop(0, "#102127");
ground.addColorStop(0.25, "#0b171d");
ground.addColorStop(1, "#050b10");
ctx.fillStyle = ground;
ctx.fillRect(0, groundY, W, H - groundY);
ctx.strokeStyle = "rgba(179,213,212,0.19)";
ctx.lineWidth = 1.2 * S;
ctx.beginPath();
ctx.moveTo(0, groundY + 0.5 * S);
ctx.lineTo(W, groundY + 0.5 * S);
ctx.stroke();
for (const puddle of puddles) {
ctx.fillStyle = "rgba(111,161,168,0.055)";
ctx.beginPath();
ctx.ellipse(puddle.x, puddle.y, puddle.rx, puddle.ry, -0.04, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(165,210,210,0.09)";
ctx.lineWidth = 0.8 * S;
ctx.beginPath();
ctx.moveTo(puddle.x - puddle.rx * 0.5, puddle.y - 0.5 * S);
ctx.lineTo(puddle.x + puddle.rx * 0.35, puddle.y - 0.5 * S);
ctx.stroke();
}
if (umbrella.attached && pose) {
const hubX = pose.hand.x + Math.sin(umbrella.angle) * 145 * S;
const color = umbrella.inv > 0.5 ? "#df6570" : "#e8a943";
ctx.fillStyle = rgba(color, 0.035);
ctx.beginPath();
ctx.moveTo(hubX - 54 * S, groundY + 3 * S);
ctx.lineTo(hubX + 65 * S, groundY + 3 * S);
ctx.lineTo(hubX + 92 * S, H);
ctx.lineTo(hubX - 90 * S, H);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(color, 0.11);
for (let i = 0; i < 7; i++) {
const y = groundY + 12 * S + i * 11 * S;
const width = (48 - i * 4) * S;
ctx.fillRect(hubX - width * 0.5 + Math.sin(i * 2.3) * 8 * S, y, width, 1.5 * S);
}
}
}
function drawWindStreaks() {
ctx.save();
ctx.lineCap = "round";
for (const line of windLines) {
const strength = clamp(actualWind / 24, 0, 1.5);
ctx.strokeStyle = `rgba(194,220,220,${line.alpha * strength})`;
ctx.lineWidth = (0.7 + strength * 0.7) * S;
ctx.beginPath();
ctx.moveTo(line.x, line.y);
ctx.bezierCurveTo(
line.x + line.length * 0.33, line.y - 2 * S,
line.x + line.length * 0.68, line.y + 2 * S,
line.x + line.length, line.y
);
ctx.stroke();
}
ctx.restore();
}
function drawRain(layer) {
ctx.save();
ctx.lineCap = "round";
for (const drop of rain) {
if (layer === 0 && drop.z >= 0.78) continue;
if (layer === 1 && drop.z < 0.78) continue;
const vx = actualWind * 22 * drop.z;
const norm = Math.max(1, Math.hypot(vx, drop.speed));
const length = (8 + actualWind * 0.42) * drop.z;
const dx = vx / norm * length;
const dy = drop.speed / norm * length;
ctx.strokeStyle = drop.z > 0.78
? `rgba(196,221,225,${0.22 + clamp(actualWind / 35, 0, 1) * 0.16})`
: "rgba(166,200,207,0.095)";
ctx.lineWidth = (drop.z > 0.78 ? 1.25 : 0.75) * S;
ctx.beginPath();
ctx.moveTo(drop.x, drop.y);
ctx.lineTo(drop.x - dx, drop.y - dy);
ctx.stroke();
}
ctx.restore();
}
function drawSplashes() {
ctx.save();
ctx.lineCap = "round";
for (const p of splashes) {
const alpha = clamp(p.life / p.maxLife, 0, 1);
ctx.strokeStyle = `rgba(180,214,216,${alpha * 0.38})`;
ctx.lineWidth = 1 * S;
ctx.beginPath();
ctx.moveTo(p.x - 3 * S, p.y);
ctx.quadraticCurveTo(p.x, p.y - 4 * S, p.x + 3 * S, p.y);
ctx.stroke();
}
ctx.restore();
}
function drawDragIndicator() {
if (!drag.active) return;
ctx.save();
ctx.setLineDash([4 * S, 5 * S]);
ctx.strokeStyle = "rgba(255,193,80,0.8)";
ctx.lineWidth = 1.5 * S;
ctx.beginPath();
ctx.moveTo(umbrellaHit.x, umbrellaHit.y);
ctx.lineTo(drag.pointerX, drag.pointerY);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
ctx.arc(drag.pointerX, drag.pointerY, 5 * S, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = "rgba(255,201,94,0.9)";
ctx.font = `700 ${9 * S}px system-ui, sans-serif`;
ctx.fillText("PULL", drag.pointerX + 9 * S, drag.pointerY - 8 * S);
ctx.restore();
}
function render() {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.clearRect(0, 0, W, H);
drawBackground();
drawWindStreaks();
drawRain(0);
drawPerson();
if (umbrella.attached) {
drawUmbrella(pose.hand.x, pose.hand.y, umbrella.angle);
} else if (!loose.gone) {
drawUmbrella(loose.x, loose.y, loose.angle);
}
drawHand();
drawRain(1);
drawSplashes();
drawDragIndicator();
}
function windDescriptor(value) {
if (value < 0.5) return "calm";
if (value < 3.4) return "light air";
if (value < 5.5) return "light breeze";
if (value < 8) return "gentle breeze";
if (value < 10.8) return "moderate breeze";
if (value < 13.9) return "strong breeze";
if (value < 17.2) return "near gale";
if (value < 20.8) return "gale";
if (value < 24.5) return "severe gale";
if (value < 28.5) return "storm";
return "violent storm";
}
function updateUI() {
ui.windValue.textContent = actualWind.toFixed(1);
ui.windWord.textContent = windDescriptor(actualWind);
ui.baseReadout.textContent = `base ${windSet.toFixed(1)}`;
ui.baseValue.textContent = `${windSet.toFixed(1)} m/s`;
const windPercent = clamp(actualWind / 38, 0, 1) * 100;
ui.windGaugeFill.style.width = `${windPercent}%`;
if (autoRise) {
ui.windSlider.value = String(Math.round(windSet * 2) / 2);
}
const sliderPercent = clamp(windSet / 38, 0, 1) * 100;
ui.windSlider.style.setProperty("--fill", `${sliderPercent}%`);
ui.windSlider.setAttribute("aria-valuetext", `${windSet.toFixed(1)} meters per second`);
let state = "INTACT";
let mode = "intact";
if (loose.gone || (!umbrella.attached && loose.gone)) {
state = "LOST";
mode = "lost";
} else if (!umbrella.attached) {
state = "LOOSE";
mode = "lost";
} else if (umbrella.inv > 0.72) {
state = "INSIDE OUT";
mode = "inside";
} else if (umbrella.inv > 0) {
state = "INVERTING";
mode = "inverting";
} else if (umbrella.stress > 0.88) {
state = "BUCKLING";
mode = "buckling";
}
ui.stateText.textContent = state;
ui.stateText.dataset.mode = mode;
const load = Math.max(0, umbrella.stress);
ui.loadValue.textContent = `${Math.round(load * 100)}%`;
ui.loadFill.style.width = `${clamp(load / 1.55, 0, 1) * 100}%`;
const grip = clamp(person.handTension, 0, 1);
ui.gripValue.textContent = `${Math.round(grip * 100)}%`;
ui.gripFill.style.width = `${grip * 100}%`;
ui.stanceValue.textContent = person.brace > 0.66
? "BRACED"
: person.lean > 0.38 ? "LEANING" : "PLANTED";
ui.eventText.textContent = eventText;
ui.autoBtn.classList.toggle("active", autoRise);
ui.autoBtn.setAttribute("aria-pressed", String(autoRise));
ui.autoState.textContent = autoRise ? "ON" : "OFF";
ui.gustBtn.classList.toggle("active", gustTimer > 0);
}
function pointerPosition(event) {
const rect = canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
function hitsUmbrella(x, y) {
if (!umbrella.attached) return false;
const dx = (x - umbrellaHit.x) / umbrellaHit.rx;
const dy = (y - umbrellaHit.y) / umbrellaHit.ry;
return dx * dx + dy * dy <= 1;
}
canvas.addEventListener("pointerdown", event => {
if (event.button !== 0) return;
const p = pointerPosition(event);
if (!hitsUmbrella(p.x, p.y)) return;
drag.active = true;
drag.startX = p.x;
drag.startY = p.y;
drag.startAngle = umbrella.angle;
drag.pointerX = p.x;
drag.pointerY = p.y;
drag.load = 0;
drag.lastDx = 0;
umbrella.angVel = 0;
canvas.style.cursor = "grabbing";
canvas.setPointerCapture(event.pointerId);
event.preventDefault();
});
canvas.addEventListener("pointermove", event => {
const p = pointerPosition(event);
if (drag.active) {
const dx = p.x - drag.startX;
const nextAngle = clamp(drag.startAngle + dx / (145 * S), -0.7, 1.55);
umbrella.angVel = clamp((nextAngle - umbrella.angle) * 6, -3.8, 3.8);
umbrella.angle = nextAngle;
drag.pointerX = p.x;
drag.pointerY = p.y;
drag.lastDx = dx;
drag.load = clamp(Math.abs(dx) / (115 * S), 0, 1.8);
event.preventDefault();
} else {
canvas.style.cursor = hitsUmbrella(p.x, p.y) ? "grab" : "default";
}
});
function endDrag(event) {
if (!drag.active) return;
drag.active = false;
umbrella.angVel = clamp(drag.lastDx / (110 * S) * 1.2, -3.5, 3.5);
drag.load = clamp(drag.load * 1.08, 0, 1.8);
canvas.style.cursor = "default";
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
}
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
ui.windSlider.addEventListener("input", event => {
setManualWind(Number(event.target.value));
});
ui.autoBtn.addEventListener("click", () => {
autoRise = !autoRise;
if (autoRise) {
eventText = "The base wind is rising again.";
} else {
eventText = "Automatic rise paused. The slider has control.";
}
});
ui.gustBtn.addEventListener("click", fireGust);
ui.resetBtn.addEventListener("click", () => resetSimulation(true));
window.addEventListener("keydown", event => {
const key = event.key.toLowerCase();
const onSlider = event.target === ui.windSlider;
if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && !onSlider) {
event.preventDefault();
const amount = event.shiftKey ? 3 : 1;
setManualWind(windSet + (event.key === "ArrowRight" ? amount : -amount));
return;
}
if (key === "w" || key === "s") {
event.preventDefault();
setManualWind(windSet + (key === "w" ? 1 : -1));
} else if (key === "a") {
event.preventDefault();
autoRise = !autoRise;
} else if (key === "g") {
event.preventDefault();
fireGust();
} else if (key === "r") {
event.preventDefault();
resetSimulation(true);
} else if (event.code === "Space" && event.target.tagName !== "BUTTON") {
event.preventDefault();
fireGust();
}
});
window.addEventListener("resize", resize);
resize();
resetSimulation(false);
let last = performance.now();
let accumulator = 0;
const fixedStep = 1 / 120;
function loop(now) {
const frameDt = Math.min(0.05, (now - last) / 1000);
last = now;
accumulator += frameDt;
while (accumulator >= fixedStep) {
step(fixedStep);
accumulator -= fixedStep;
}
render();
updateUI();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();
</script>
</body>
</html>
It gets the sequence broadly right: the canopy bows and the frame changes color before anything catastrophic happens, the ribs fan and buckle, inversion is latched, and the person widens their stance, drops their hips, extends both arms, and finally loses the umbrella when the load becomes unreasonable. The rain and the escaped umbrella share the same wind vector, so increasing the wind visibly changes both.
It is also deliberately more forgiving and more orderly than a real umbrella. The cloth is represented by curved panels rather than an actual tensioned mesh, so the inversion is a clean geometric flip instead of ribs buckling one at a time. There is no three-dimensional aerodynamics, handle torsion, fabric catching on the frame, or asymmetric gust turbulence; the person’s stance is a kinematic response rather than a full balance model. A real umbrella may invert around 10–15 m/s if its ribs are weak, while a well-made one may survive much stronger wind, and the person will usually be less composed than this one.