27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 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
You build physics toys, and you have dropped a garden hose with the water on full.
Build me a single HTML page: a garden hose lying on a lawn, the spigot on, and the nozzle released.
#psi=40 sets the pressure to 40 psi with the nozzle free on load. Any number works.data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file: how the hose discretization and inextensibility constraints are implemented, the formula for the nozzle reaction force as a function of psi, and ground friction handling.
The pressure in psi at which the hose transitions from stationary or settling to continuous whipping, as one number, derived from your model parameters.
The single fenced html block.
Where the motion looks like a real hose and where it does not. Name any artificial damping, injected noise or scripted oscillation. Everyone has watched a hose get away from somebody; say what yours gets wrong before I see it.
The 10 m hose is discretized into 47 wet-mass nodes and 46 segments, with light three-node bending forces and a 0.12 kg nozzle mass. Semi-implicit 180 Hz integration is followed by 18 alternating, mass-weighted position projections per segment; these enforce fixed rest lengths to solver tolerance while the spigot and an optional barycentric drag point remain pinned. For gauge pressure (p=\mathrm{psi}\times6894.757), nozzle area (A), and discharge coefficient (C_d=0.78), (Q=C_dA\sqrt{2p/\rho}) and the reaction on the nozzle is (\mathbf F=-\rho Q^2/A,\hat{\mathbf t}=-2C_d^2Ap,\hat{\mathbf t}), opposite the nozzle vector. The 9 mm nozzle, 3.52 kg total wet mass, and lawn friction determine the predicted breakaway: ground contact applies bounded Coulomb velocity impulses with (\mu_s=0.40), (\mu_k=0.34), plus weak viscous drag, and the prediction equates total static friction (\mu_sMg) with jet reaction.
25.9 psi
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Runaway Hose</title>
<style>
:root {
color-scheme: dark;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #47753d;
}
body { color: #f7f3df; }
canvas {
position: fixed;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.panel {
position: fixed;
z-index: 3;
top: max(18px, env(safe-area-inset-top));
left: max(18px, env(safe-area-inset-left));
width: min(322px, calc(100vw - 36px));
padding: 20px 21px 17px;
border: 1px solid rgba(255,255,255,.17);
border-radius: 21px;
background:
linear-gradient(145deg, rgba(20,40,31,.91), rgba(17,30,26,.80));
box-shadow: 0 18px 55px rgba(13,28,17,.28), inset 0 1px rgba(255,255,255,.07);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
user-select: none;
}
.eyebrow {
color: #b7c9a6;
font: 700 10px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
letter-spacing: .18em;
text-transform: uppercase;
}
h1 {
margin: 6px 0 3px;
font-size: 23px;
line-height: 1.1;
font-weight: 660;
letter-spacing: -.035em;
}
.readout {
display: flex;
align-items: baseline;
gap: 8px;
margin: 7px 0 8px;
}
.readout output {
font: 650 57px/.98 ui-monospace, SFMono-Regular, Consolas, monospace;
letter-spacing: -.075em;
color: #ffe08b;
color: #ffe08b;
font-variant-numeric: tabular-nums;
}
.readout span {
color: #c4cfb7;
font: 700 14px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
letter-spacing: .08em;
}
label {
display: block;
margin: 11px 0 10px;
color: #d8dfcf;
font-size: 12px;
font-weight: 600;
}
input[type="range"] {
--fill: 100%;
appearance: none;
-webkit-appearance: none;
display: block;
width: 100%;
height: 6px;
margin: 0;
border-radius: 999px;
outline: none;
cursor: pointer;
background: linear-gradient(90deg,
#edc474 0 var(--fill),
rgba(255,255,255,.15) var(--fill) 100%);
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 19px;
height: 19px;
border: 3px solid #243b2d;
border-radius: 50%;
background: #fff0bd;
box-shadow: 0 2px 8px rgba(0,0,0,.35);
}
input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border: 3px solid #243b2d;
border-radius: 50%;
background: #fff0bd;
box-shadow: 0 2px 8px rgba(0,0,0,.35);
}
input[type="range"]:focus-visible {
box-shadow: 0 0 0 3px rgba(255,224,139,.3);
}
.range-labels {
display: flex;
justify-content: space-between;
margin-top: 8px;
color: #91a58e;
font: 600 9px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
letter-spacing: .07em;
text-transform: uppercase;
}
.buttons {
display: grid;
grid-template-columns: 1fr 1fr 1.45fr;
gap: 7px;
margin-top: 15px;
}
button {
min-height: 33px;
padding: 7px 8px;
border: 1px solid rgba(255,255,255,.14);
border-radius: 9px;
color: #e9eddf;
background: rgba(255,255,255,.065);
font: 650 11px/1 ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
}
button:hover { background: rgba(255,255,255,.12); }
button:active { transform: translateY(1px); }
button:focus-visible { outline: 2px solid #ffe08b; outline-offset: 2px; }
button.active {
color: #263526;
border-color: #e7ca86;
background: #e7ca86;
}
.meta {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 7px;
margin-top: 15px;
padding-top: 13px;
border-top: 1px solid rgba(255,255,255,.10);
color: #bfcbb8;
font-size: 11px;
}
#pinState { color: #8fa38c; }
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #88b381;
box-shadow: 0 0 0 3px rgba(136,179,129,.10);
}
.dot.moving {
background: #edc474;
box-shadow: 0 0 0 3px rgba(237,196,116,.11);
}
.dot.whipping {
background: #f18a63;
box-shadow: 0 0 0 3px rgba(241,138,99,.13);
}
.hint {
position: fixed;
z-index: 2;
left: 50%;
bottom: max(18px, env(safe-area-inset-bottom));
transform: translateX(-50%);
width: max-content;
max-width: calc(100vw - 28px);
padding: 9px 14px;
border: 1px solid rgba(255,255,255,.14);
border-radius: 999px;
color: rgba(250,248,226,.88);
background: rgba(20,38,27,.64);
box-shadow: 0 7px 25px rgba(20,35,20,.16);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
font-size: 11px;
letter-spacing: .015em;
text-align: center;
pointer-events: none;
}
@media (max-width: 600px) {
.panel {
top: max(11px, env(safe-area-inset-top));
left: max(11px, env(safe-area-inset-left));
width: min(276px, calc(100vw - 22px));
padding: 15px 16px 13px;
border-radius: 17px;
}
h1 { font-size: 19px; }
.readout { margin: 5px 0; }
.readout output { font-size: 43px; }
label { margin: 8px 0; }
.buttons { margin-top: 12px; }
.meta { margin-top: 11px; padding-top: 10px; }
.hint { font-size: 10px; bottom: 12px; }
}
</style>
</head>
<body>
<canvas id="scene" aria-label="A pressurized garden hose on a lawn. Drag any point of the hose to pin it, and release to free it."></canvas>
<section class="panel" aria-label="Hose controls">
<div class="eyebrow">Backyard dynamics · No. 01</div>
<h1>Runaway hose</h1>
<div class="readout">
<output id="psiReadout" for="pressure">100</output>
<span>PSI</span>
</div>
<label for="pressure">Spigot pressure</label>
<input id="pressure" type="range" min="0" max="100" step="0.1" value="100" aria-label="Water pressure in psi">
<div class="range-labels"><span>0 · closed</span><span>100 · full</span></div>
<div class="buttons">
<button id="offButton" type="button">Off</button>
<button id="fullButton" type="button" class="active">Full</button>
<button id="resetButton" type="button">Reset hose</button>
</div>
<div class="meta">
<span id="motionDot" class="dot"></span>
<span id="motionText">Settled</span>
<span id="pinState">Nozzle free</span>
</div>
</section>
<div class="hint" id="hint">Drag any point of the hose to pin it · release to let go</div>
<script>
(() => {
"use strict";
const canvas = document.getElementById("scene");
const ctx = canvas.getContext("2d");
const pressureInput = document.getElementById("pressure");
const psiReadout = document.getElementById("psiReadout");
const offButton = document.getElementById("offButton");
const fullButton = document.getElementById("fullButton");
const resetButton = document.getElementById("resetButton");
const motionDot = document.getElementById("motionDot");
const motionText = document.getElementById("motionText");
const pinState = document.getElementById("pinState");
const hint = document.getElementById("hint");
const TAU = Math.PI * 2;
const G = 9.81;
const MAX_PSI = 100;
const PA_PER_PSI = 6894.757293168;
const SEGMENTS = 46;
const NODES = SEGMENTS + 1;
const LENGTH = 10;
const REST = LENGTH / SEGMENTS;
const LINE_DENSITY = 0.34;
const NOZZLE_MASS = 0.12;
const BENDING_EI = 0.55;
const BEND_K = BENDING_EI / (REST * REST * REST);
const DT = 1 / 180;
const PROJECTION_PASSES = 18;
const MU_STATIC = 0.40;
const MU_KINETIC = 0.34;
const LAWN_VISCOUS = 0.10;
const NUMERICAL_DAMPING = 0.04;
const SPEED_CAP = 35;
const WATER_DENSITY = 1000;
const DISCHARGE_COEFFICIENT = 0.78;
const NOZZLE_DIAMETER = 0.009;
const NOZZLE_AREA = Math.PI * NOZZLE_DIAMETER * NOZZLE_DIAMETER / 4;
const NOZZLE_HEIGHT = 0.16;
const NOZZLE_OUTLET = 0.17;
const MAX_FLOW = DISCHARGE_COEFFICIENT * NOZZLE_AREA *
Math.sqrt(2 * MAX_PSI * PA_PER_PSI / WATER_DENSITY);
const TOTAL_WET_MASS = LINE_DENSITY * LENGTH + NOZZLE_MASS;
/*
Static breakaway estimate:
MU_STATIC * TOTAL_WET_MASS * G /
(2 * Cd^2 * NOZZLE_AREA * PA_PER_PSI) = 25.8797 psi.
It is not used as a switch anywhere in the dynamics.
*/
const SPIGOT = { x: -4.2, y: 1.0 };
const x = new Float64Array(NODES);
const y = new Float64Array(NODES);
const vx = new Float64Array(NODES);
const vy = new Float64Array(NODES);
const oldX = new Float64Array(NODES);
const oldY = new Float64Array(NODES);
const fx = new Float64Array(NODES);
const fy = new Float64Array(NODES);
const mass = new Float64Array(NODES);
const invMass = new Float64Array(NODES);
for (let i = 0; i < NODES; i++) {
const endFactor = (i === 0 || i === SEGMENTS) ? 0.5 : 1;
mass[i] = LINE_DENSITY * REST * endFactor;
}
mass[SEGMENTS] += NOZZLE_MASS;
for (let i = 0; i < NODES; i++) invMass[i] = 1 / mass[i];
invMass[0] = 0;
function initialPressure() {
const fallback = 100;
try {
const params = new URLSearchParams(location.hash.slice(1));
if (!params.has("psi")) return fallback;
const raw = params.get("psi");
if (raw === null || raw.trim() === "") return fallback;
const value = Number(raw);
if (!Number.isFinite(value)) return fallback;
return Math.max(0, Math.min(MAX_PSI, value));
} catch (_) {
return fallback;
}
}
let psi = initialPressure();
let nozzleTx = 1;
let nozzleTy = 0;
let W = 1;
let H = 1;
let DPR = 1;
let scale = 0;
let baseScale = 60;
let anchorX = 100;
let anchorY = 100;
let grab = null;
let capturedPointer = null;
let pointerScreen = { x: 0, y: 0 };
const water = [];
const landingRings = [];
const wetSpots = [];
let emissionCredit = 0;
let wetCounter = 0;
let motionAverage = 0;
let statusClock = 0;
function mulberry32(seed) {
return function() {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
const sprayRandom = mulberry32(0x48A21F);
function clamp(value, lo, hi) {
return Math.max(lo, Math.min(hi, value));
}
function roundedPath(c, px, py, width, height, radius) {
const r = Math.min(radius, width / 2, height / 2);
c.beginPath();
c.moveTo(px + r, py);
c.lineTo(px + width - r, py);
c.quadraticCurveTo(px + width, py, px + width, py + r);
c.lineTo(px + width, py + height - r);
c.quadraticCurveTo(px + width, py + height, px + width - r, py + height);
c.lineTo(px + r, py + height);
c.quadraticCurveTo(px, py + height, px, py + height - r);
c.lineTo(px, py + r);
c.quadraticCurveTo(px, py, px + r, py);
c.closePath();
}
const lawnImage = document.createElement("canvas");
function buildLawn() {
lawnImage.width = Math.max(1, Math.round(W * DPR));
lawnImage.height = Math.max(1, Math.round(H * DPR));
const c = lawnImage.getContext("2d");
c.setTransform(DPR, 0, 0, DPR, 0, 0);
const gradient = c.createLinearGradient(0, 0, W, H);
gradient.addColorStop(0, "#7b9e4c");
gradient.addColorStop(0.46, "#638a43");
gradient.addColorStop(1, "#3f6d3b");
c.fillStyle = gradient;
c.fillRect(0, 0, W, H);
c.save();
c.translate(W * 0.5, H * 0.5);
c.rotate(-0.16);
const stripeWidth = Math.max(95, W / 8);
for (let k = -10; k < 11; k++) {
c.fillStyle = k % 2 ?
"rgba(237,244,174,.035)" :
"rgba(23,70,37,.035)";
c.fillRect(k * stripeWidth, -H * 1.5, stripeWidth, H * 3);
}
c.restore();
const random = mulberry32(0x19C377);
const bladeCount = Math.min(6500, Math.floor(W * H / 175));
c.lineCap = "round";
for (let i = 0; i < bladeCount; i++) {
const px = random() * W;
const py = random() * H;
const length = 1 + random() * 3.4;
const lean = (random() - 0.5) * 2.2;
const shade = random();
c.strokeStyle = shade < 0.34 ?
"rgba(31,80,39,.22)" :
shade < 0.72 ?
"rgba(221,229,145,.13)" :
"rgba(70,111,48,.25)";
c.lineWidth = 0.55 + random() * 0.65;
c.beginPath();
c.moveTo(px, py);
c.lineTo(px + lean, py - length);
c.stroke();
}
for (let i = 0; i < 42; i++) {
const px = random() * W;
const py = random() * H;
c.fillStyle = "rgba(41,91,45,.18)";
for (let j = 0; j < 3; j++) {
const a = j * TAU / 3 + random();
c.beginPath();
c.ellipse(px + Math.cos(a) * 2.1, py + Math.sin(a) * 2.1,
2.3, 1.4, a, 0, TAU);
c.fill();
}
}
const vignette = c.createRadialGradient(
W * 0.54, H * 0.44, Math.min(W, H) * 0.12,
W * 0.50, H * 0.48, Math.max(W, H) * 0.76
);
vignette.addColorStop(0, "rgba(255,246,190,.055)");
vignette.addColorStop(0.66, "rgba(20,54,31,0)");
vignette.addColorStop(1, "rgba(16,42,28,.20)");
c.fillStyle = vignette;
c.fillRect(0, 0, W, H);
}
function resize() {
W = Math.max(1, window.innerWidth);
H = Math.max(1, window.innerHeight);
DPR = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
anchorX = W < 650 ? W * 0.50 : W * 0.32;
anchorY = H < 600 ? H * 0.65 : H * 0.58;
baseScale = Math.max(17, Math.min(W / 13.8, H / 9.4));
if (!scale) scale = baseScale;
buildLawn();
}
function screenX(worldX) {
return anchorX + (worldX - SPIGOT.x) * scale;
}
function screenY(worldY) {
return anchorY + (worldY - SPIGOT.y) * scale;
}
function resetHose() {
x[0] = SPIGOT.x;
y[0] = SPIGOT.y;
for (let i = 1; i < NODES; i++) {
const s = (i - 1) / SEGMENTS;
const angle = 0.12 + 1.05 * Math.sin(TAU * s);
x[i] = x[i - 1] + REST * Math.cos(angle);
y[i] = y[i - 1] + REST * Math.sin(angle);
}
vx.fill(0);
vy.fill(0);
oldX.set(x);
oldY.set(y);
grab = null;
capturedPointer = null;
water.length = 0;
landingRings.length = 0;
wetSpots.length = 0;
emissionCredit = 0;
motionAverage = 0;
updateNozzleVector();
updatePinUI();
}
function updateNozzleVector() {
const dx = x[SEGMENTS] - x[SEGMENTS - 1];
const dy = y[SEGMENTS] - y[SEGMENTS - 1];
const length = Math.hypot(dx, dy);
if (length > 1e-8) {
nozzleTx = dx / length;
nozzleTy = dy / length;
}
}
function pinTargetForStep() {
if (!grab) return null;
let tx = SPIGOT.x + (pointerScreen.x - anchorX) / scale;
let ty = SPIGOT.y + (pointerScreen.y - anchorY) / scale;
const materialDistance = Math.max(
REST * 0.025,
(grab.segment + grab.t) * REST
);
const dx = tx - SPIGOT.x;
const dy = ty - SPIGOT.y;
const distance = Math.hypot(dx, dy);
const reachable = materialDistance * 0.998;
if (distance > reachable && distance > 1e-8) {
tx = SPIGOT.x + dx * reachable / distance;
ty = SPIGOT.y + dy * reachable / distance;
}
return { x: tx, y: ty };
}
function projectGrab(target) {
if (!grab) return;
const i = grab.segment;
const j = i + 1;
const b = grab.t;
const a = 1 - b;
const wi = invMass[i];
const wj = invMass[j];
const denominator = wi * a * a + wj * b * b;
if (denominator < 1e-12) return;
const rx = a * x[i] + b * x[j];
const ry = a * y[i] + b * y[j];
const cx = rx - target.x;
const cy = ry - target.y;
x[i] -= wi * a * cx / denominator;
y[i] -= wi * a * cy / denominator;
x[j] -= wj * b * cx / denominator;
y[j] -= wj * b * cy / denominator;
}
function solveSegment(i) {
const j = i + 1;
const wi = invMass[i];
const wj = invMass[j];
const weight = wi + wj;
if (weight < 1e-12) return;
const dx = x[j] - x[i];
const dy = y[j] - y[i];
const distance = Math.hypot(dx, dy);
if (distance < 1e-9) return;
const factor = (distance - REST) / (distance * weight);
const cx = dx * factor;
const cy = dy * factor;
x[i] += wi * cx;
y[i] += wi * cy;
x[j] -= wj * cx;
y[j] -= wj * cy;
}
function physicsStep(dt) {
fx.fill(0);
fy.fill(0);
for (let i = 1; i < SEGMENTS; i++) {
const ddx = x[i - 1] - 2 * x[i] + x[i + 1];
const ddy = y[i - 1] - 2 * y[i] + y[i + 1];
const bx = -BEND_K * ddx;
const by = -BEND_K * ddy;
fx[i - 1] += bx;
fy[i - 1] += by;
fx[i + 1] += bx;
fy[i + 1] += by;
fx[i] -= 2 * bx;
fy[i] -= 2 * by;
}
updateNozzleVector();
const pressurePa = psi * PA_PER_PSI;
const thrust = 2 * DISCHARGE_COEFFICIENT * DISCHARGE_COEFFICIENT *
NOZZLE_AREA * pressurePa;
fx[SEGMENTS] -= thrust * nozzleTx;
fy[SEGMENTS] -= thrust * nozzleTy;
for (let i = 1; i < NODES; i++) {
const previousSpeed = Math.hypot(vx[i], vy[i]);
vx[i] += fx[i] * invMass[i] * dt;
vy[i] += fy[i] * invMass[i] * dt;
let speed = Math.hypot(vx[i], vy[i]);
if (speed > 1e-10) {
const mu = previousSpeed < 0.035 ? MU_STATIC : MU_KINETIC;
let nextSpeed = (speed - mu * G * dt) *
Math.exp(-LAWN_VISCOUS * dt);
if (nextSpeed <= 0) {
vx[i] = 0;
vy[i] = 0;
} else {
const ratio = nextSpeed / speed;
vx[i] *= ratio;
vy[i] *= ratio;
}
}
}
oldX.set(x);
oldY.set(y);
for (let i = 1; i < NODES; i++) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
}
x[0] = SPIGOT.x;
y[0] = SPIGOT.y;
const pinTarget = pinTargetForStep();
for (let pass = 0; pass < PROJECTION_PASSES; pass++) {
if (pass & 1) {
for (let i = SEGMENTS - 1; i >= 0; i--) solveSegment(i);
} else {
for (let i = 0; i < SEGMENTS; i++) solveSegment(i);
}
x[0] = SPIGOT.x;
y[0] = SPIGOT.y;
if (pinTarget) projectGrab(pinTarget);
}
x[0] = SPIGOT.x;
y[0] = SPIGOT.y;
if (pinTarget) projectGrab(pinTarget);
const damping = Math.exp(-NUMERICAL_DAMPING * dt);
for (let i = 1; i < NODES; i++) {
let nvx = (x[i] - oldX[i]) / dt * damping;
let nvy = (y[i] - oldY[i]) / dt * damping;
const speed = Math.hypot(nvx, nvy);
if (speed > SPEED_CAP) {
const ratio = SPEED_CAP / speed;
nvx *= ratio;
nvy *= ratio;
} else if (speed < 0.00025) {
nvx = 0;
nvy = 0;
}
vx[i] = nvx;
vy[i] = nvy;
}
vx[0] = 0;
vy[0] = 0;
}
function landWater(p) {
if (landingRings.length < 130 && sprayRandom() < 0.48) {
landingRings.push({ x: p.x, y: p.y, age: 0, life: 0.42 });
}
if ((wetCounter++ % 4) === 0) {
wetSpots.push({
x: p.x,
y: p.y,
radius: 0.07 + sprayRandom() * 0.11,
age: 0,
life: 8 + sprayRandom() * 4
});
if (wetSpots.length > 155) wetSpots.shift();
}
}
function updateWater(dt) {
for (let i = landingRings.length - 1; i >= 0; i--) {
landingRings[i].age += dt;
if (landingRings[i].age >= landingRings[i].life) {
landingRings.splice(i, 1);
}
}
for (let i = wetSpots.length - 1; i >= 0; i--) {
wetSpots[i].age += dt;
if (wetSpots[i].age >= wetSpots[i].life) wetSpots.splice(i, 1);
}
for (let i = water.length - 1; i >= 0; i--) {
const p = water[i];
p.ox = p.x;
p.oy = p.y;
p.oz = p.z;
p.vz -= G * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
if (p.z <= 0) {
p.z = 0;
landWater(p);
water.splice(i, 1);
}
}
if (psi <= 0.0001) {
emissionCredit = 0;
return;
}
const pressurePa = psi * PA_PER_PSI;
const flow = DISCHARGE_COEFFICIENT * NOZZLE_AREA *
Math.sqrt(2 * pressurePa / WATER_DENSITY);
const jetSpeed = flow / NOZZLE_AREA;
const emissionRate = 850 * flow / MAX_FLOW;
emissionCredit += emissionRate * dt;
let count = Math.floor(emissionCredit);
if (count <= 0) return;
emissionCredit -= count;
count = Math.min(count, 42, Math.max(0, 720 - water.length));
const outletX = x[SEGMENTS] + nozzleTx * NOZZLE_OUTLET;
const outletY = y[SEGMENTS] + nozzleTy * NOZZLE_OUTLET;
for (let k = 0; k < count; k++) {
const lag = dt * (k + 0.5) / count;
const speed = jetSpeed * (0.985 + sprayRandom() * 0.03);
const pvx = nozzleTx * speed;
const pvy = nozzleTy * speed;
const px = outletX + pvx * lag;
const py = outletY + pvy * lag;
const pz = Math.max(0.01, NOZZLE_HEIGHT - 0.5 * G * lag * lag);
const trailTime = Math.min(0.018, lag + 0.003);
water.push({
x: px,
y: py,
z: pz,
ox: px - pvx * trailTime,
oy: py - pvy * trailTime,
oz: Math.min(NOZZLE_HEIGHT, pz + 0.5 * G * trailTime * trailTime),
vx: pvx,
vy: pvy,
vz: -G * lag,
size: 0.85 + sprayRandom() * 1.1
});
}
}
function fitCamera(dt) {
let left = 0;
let right = 0;
let up = 0;
let down = 0;
for (let i = 0; i < NODES; i++) {
const dx = x[i] - SPIGOT.x;
const dy = y[i] - SPIGOT.y;
left = Math.max(left, -dx);
right = Math.max(right, dx);
up = Math.max(up, -dy);
down = Math.max(down, dy);
}
const pad = W < 650 ? 19 : 38;
let desired = baseScale;
if (right > 0.01) desired = Math.min(desired, (W - anchorX - pad) / right);
if (left > 0.01) desired = Math.min(desired, (anchorX - pad) / left);
if (down > 0.01) desired = Math.min(desired, (H - anchorY - pad) / down);
if (up > 0.01) desired = Math.min(desired, (anchorY - pad) / up);
desired = Math.max(8, desired);
const response = desired < scale ? 8 : 0.72;
scale += (desired - scale) * (1 - Math.exp(-response * dt));
}
function traceHose() {
ctx.beginPath();
ctx.moveTo(screenX(x[0]), screenY(y[0]));
for (let i = 1; i < SEGMENTS; i++) {
const cx = screenX(x[i]);
const cy = screenY(y[i]);
const mx = (cx + screenX(x[i + 1])) * 0.5;
const my = (cy + screenY(y[i + 1])) * 0.5;
ctx.quadraticCurveTo(cx, cy, mx, my);
}
ctx.lineTo(screenX(x[SEGMENTS]), screenY(y[SEGMENTS]));
}
function drawSpigotPad() {
ctx.save();
ctx.translate(anchorX, anchorY);
ctx.fillStyle = "rgba(23,45,27,.20)";
roundedPath(ctx, -39, -24 + 6, 78, 57, 12);
ctx.fill();
ctx.fillStyle = "rgba(160,165,137,.48)";
roundedPath(ctx, -39, -24, 78, 57, 12);
ctx.fill();
ctx.strokeStyle = "rgba(232,226,184,.18)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
function drawWetGround() {
for (const spot of wetSpots) {
const fade = 1 - spot.age / spot.life;
ctx.fillStyle = `rgba(18,62,42,${0.12 * fade})`;
ctx.beginPath();
ctx.ellipse(
screenX(spot.x), screenY(spot.y),
Math.max(1, spot.radius * scale * 1.35),
Math.max(1, spot.radius * scale),
0, 0, TAU
);
ctx.fill();
}
ctx.lineWidth = 1;
for (const ring of landingRings) {
const t = ring.age / ring.life;
const radius = scale * (0.025 + t * 0.19);
ctx.strokeStyle = `rgba(206,235,218,${0.38 * (1 - t)})`;
ctx.beginPath();
ctx.ellipse(screenX(ring.x), screenY(ring.y),
radius, radius * 0.55, 0, 0, TAU);
ctx.stroke();
}
}
function drawHose() {
const hoseWidth = clamp(scale * 0.105, 5, 12);
ctx.lineCap = "round";
ctx.lineJoin = "round";
traceHose();
ctx.strokeStyle = "rgba(30,57,28,.13)";
ctx.lineWidth = hoseWidth + 11;
ctx.stroke();
ctx.save();
ctx.translate(4, 7);
traceHose();
ctx.strokeStyle = "rgba(17,35,25,.28)";
ctx.lineWidth = hoseWidth + 5;
ctx.stroke();
ctx.restore();
traceHose();
ctx.strokeStyle = "#173f30";
ctx.lineWidth = hoseWidth + 3.5;
ctx.stroke();
traceHose();
ctx.strokeStyle = "#2f7350";
ctx.lineWidth = hoseWidth;
ctx.stroke();
traceHose();
ctx.strokeStyle = "rgba(156,199,121,.29)";
ctx.lineWidth = Math.max(1.2, hoseWidth * 0.24);
ctx.stroke();
}
function drawSpigot() {
ctx.save();
ctx.translate(anchorX, anchorY);
ctx.fillStyle = "rgba(16,31,24,.25)";
ctx.beginPath();
ctx.ellipse(2, 8, 21, 11, 0, 0, TAU);
ctx.fill();
const metal = ctx.createLinearGradient(-15, -15, 16, 15);
metal.addColorStop(0, "#d7d6bd");
metal.addColorStop(0.45, "#77867d");
metal.addColorStop(1, "#44564f");
ctx.fillStyle = metal;
ctx.beginPath();
ctx.arc(0, 0, 15, 0, TAU);
ctx.fill();
ctx.strokeStyle = "#384a43";
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = "#c79a4c";
roundedPath(ctx, -2, -7, 26, 14, 5);
ctx.fill();
ctx.fillStyle = "#72532b";
ctx.fillRect(16, -8, 4, 16);
ctx.save();
ctx.translate(-8, -16);
ctx.strokeStyle = "#8e3d2e";
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(0, 0, 11, 0, TAU);
ctx.stroke();
ctx.strokeStyle = "#c85f44";
ctx.lineWidth = 3;
for (let i = 0; i < 5; i++) {
const a = i * TAU / 5;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a) * 10, Math.sin(a) * 10);
ctx.stroke();
}
ctx.fillStyle = "#d6aa73";
ctx.beginPath();
ctx.arc(0, 0, 3.2, 0, TAU);
ctx.fill();
ctx.restore();
ctx.fillStyle = "rgba(29,54,34,.55)";
ctx.font = "700 8px ui-monospace, monospace";
ctx.textAlign = "center";
ctx.fillText("SPIGOT", 0, 45);
ctx.restore();
}
function drawWater() {
ctx.save();
ctx.lineCap = "round";
for (const p of water) {
const x1 = screenX(p.ox);
const y1 = screenY(p.oy) - p.oz * scale * 0.82;
const x2 = screenX(p.x);
const y2 = screenY(p.y) - p.z * scale * 0.82;
ctx.strokeStyle = "rgba(202,237,242,.72)";
ctx.lineWidth = p.size;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.strokeStyle = "rgba(247,255,244,.48)";
ctx.lineWidth = Math.max(0.45, p.size * 0.32);
ctx.beginPath();
ctx.moveTo(x1, y1 - 0.45);
ctx.lineTo(x2, y2 - 0.45);
ctx.stroke();
}
ctx.restore();
}
function drawNozzle() {
const tipX = screenX(x[SEGMENTS]);
const tipY = screenY(y[SEGMENTS]);
const length = Math.max(2, scale * 0.40);
const radius = Math.max(3.5, clamp(scale * 0.105, 5, 12) * 0.60);
ctx.save();
ctx.translate(tipX + 3, tipY + 5);
ctx.rotate(Math.atan2(nozzleTy, nozzleTx));
ctx.fillStyle = "rgba(17,30,22,.25)";
roundedPath(ctx, -length * 0.62, -radius, length, radius * 2, radius * 0.55);
ctx.fill();
ctx.restore();
ctx.save();
ctx.translate(tipX, tipY);
ctx.rotate(Math.atan2(nozzleTy, nozzleTx));
ctx.fillStyle = "#354b3c";
roundedPath(ctx, -length * 0.68, -radius * 0.85,
length * 0.24, radius * 1.7, 2);
ctx.fill();
const brass = ctx.createLinearGradient(0, -radius, 0, radius);
brass.addColorStop(0, "#f3daa0");
brass.addColorStop(0.28, "#c59a52");
brass.addColorStop(0.72, "#8a612e");
brass.addColorStop(1, "#5f4326");
ctx.fillStyle = brass;
roundedPath(ctx, -length * 0.48, -radius,
length * 0.82, radius * 2, radius * 0.45);
ctx.fill();
ctx.fillStyle = "#d4b06a";
roundedPath(ctx, length * 0.22, -radius * 0.80,
length * 0.20, radius * 1.6, 1.5);
ctx.fill();
ctx.fillStyle = "#243b37";
ctx.beginPath();
ctx.ellipse(length * 0.42, 0, Math.max(1.1, length * 0.035),
radius * 0.58, 0, 0, TAU);
ctx.fill();
ctx.strokeStyle = "rgba(255,240,180,.48)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(-length * 0.34, -radius * 0.52);
ctx.lineTo(length * 0.18, -radius * 0.52);
ctx.stroke();
ctx.restore();
}
function drawGrab() {
if (!grab) return;
const i = grab.segment;
const a = 1 - grab.t;
const b = grab.t;
const px = a * x[i] + b * x[i + 1];
const py = a * y[i] + b * y[i + 1];
const sx = screenX(px);
const sy = screenY(py);
ctx.save();
if (Math.hypot(pointerScreen.x - sx, pointerScreen.y - sy) > 5) {
ctx.strokeStyle = "rgba(255,239,177,.55)";
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(pointerScreen.x, pointerScreen.y);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.fillStyle = "rgba(255,239,177,.18)";
ctx.beginPath();
ctx.arc(sx, sy, 12, 0, TAU);
ctx.fill();
ctx.strokeStyle = "#fff0b7";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx, sy, 6, 0, TAU);
ctx.stroke();
ctx.restore();
}
function draw() {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.drawImage(lawnImage, 0, 0, W, H);
drawSpigotPad();
drawWetGround();
drawHose();
drawWater();
drawSpigot();
drawNozzle();
drawGrab();
}
function formatPsi(value) {
if (Math.abs(value - Math.round(value)) < 0.049) {
return String(Math.round(value));
}
return value.toFixed(1);
}
function updatePressureUI() {
const text = formatPsi(psi);
psiReadout.textContent = text;
pressureInput.value = String(psi);
pressureInput.style.setProperty("--fill", `${psi}%`);
pressureInput.setAttribute("aria-valuetext", `${text} psi`);
offButton.classList.toggle("active", psi === 0);
fullButton.classList.toggle("active", psi === MAX_PSI);
}
function setPressure(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return;
psi = clamp(numeric, 0, MAX_PSI);
updatePressureUI();
}
function updatePinUI() {
if (!grab) {
pinState.textContent = "Nozzle free";
hint.textContent = "Drag any point of the hose to pin it · release to let go";
return;
}
const atNozzle = grab.segment === SEGMENTS - 1 && grab.t > 0.94;
pinState.textContent = atNozzle ? "Nozzle pinned" : "Hose pinned";
hint.textContent = "Pinned to your pointer · release to let go";
}
function updateMotion(dt) {
let speedSum = 0;
for (let i = 1; i < NODES; i++) speedSum += Math.hypot(vx[i], vy[i]);
const average = speedSum / SEGMENTS;
const tipSpeed = Math.hypot(vx[SEGMENTS], vy[SEGMENTS]);
const measure = average + 0.18 * tipSpeed;
motionAverage += (measure - motionAverage) * (1 - Math.exp(-5 * dt));
statusClock += dt;
if (statusClock < 0.12) return;
statusClock = 0;
let text = "Settled";
let className = "dot";
if (motionAverage > 0.70 && tipSpeed > 1.0) {
text = "Whipping";
className = "dot whipping";
} else if (motionAverage > 0.045) {
text = psi > 0 ? "Moving" : "Settling";
className = "dot moving";
}
motionText.textContent = text;
motionDot.className = className;
}
function eventPosition(event) {
const rect = canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
function findGrabPoint(position) {
const tipDistance = Math.hypot(
position.x - screenX(x[SEGMENTS]),
position.y - screenY(y[SEGMENTS])
);
if (tipDistance < 25) {
return { segment: SEGMENTS - 1, t: 1 };
}
let best = null;
let bestDistance = 23;
for (let i = 0; i < SEGMENTS; i++) {
const ax = screenX(x[i]);
const ay = screenY(y[i]);
const bx = screenX(x[i + 1]);
const by = screenY(y[i + 1]);
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared < 1e-8) continue;
const t = clamp(
((position.x - ax) * dx + (position.y - ay) * dy) / lengthSquared,
0, 1
);
const px = ax + t * dx;
const py = ay + t * dy;
const distance = Math.hypot(position.x - px, position.y - py);
if (distance < bestDistance) {
bestDistance = distance;
best = { segment: i, t };
}
}
if (best && best.segment === 0 && best.t < 0.025) best.t = 0.025;
return best;
}
pressureInput.addEventListener("input", event => {
setPressure(event.currentTarget.value);
});
offButton.addEventListener("click", () => setPressure(0));
fullButton.addEventListener("click", () => setPressure(MAX_PSI));
resetButton.addEventListener("click", resetHose);
canvas.addEventListener("pointerdown", event => {
if (event.pointerType === "mouse" && event.button !== 0) return;
const position = eventPosition(event);
const hit = findGrabPoint(position);
if (!hit) return;
event.preventDefault();
grab = hit;
capturedPointer = event.pointerId;
pointerScreen = position;
canvas.style.cursor = "grabbing";
try { canvas.setPointerCapture(event.pointerId); } catch (_) {}
updatePinUI();
});
canvas.addEventListener("pointermove", event => {
const position = eventPosition(event);
if (grab && event.pointerId === capturedPointer) {
event.preventDefault();
pointerScreen = position;
} else {
canvas.style.cursor = findGrabPoint(position) ? "grab" : "default";
}
});
function releasePointer(event) {
if (!grab || event.pointerId !== capturedPointer) return;
grab = null;
capturedPointer = null;
canvas.style.cursor = "default";
updatePinUI();
}
canvas.addEventListener("pointerup", releasePointer);
canvas.addEventListener("pointercancel", releasePointer);
canvas.addEventListener("lostpointercapture", releasePointer);
canvas.addEventListener("pointerleave", () => {
if (!grab) canvas.style.cursor = "default";
});
window.addEventListener("resize", resize, { passive: true });
let previousTime = performance.now();
let accumulator = 0;
function frame(now) {
const elapsed = clamp((now - previousTime) / 1000, 0, 0.05);
previousTime = now;
accumulator += elapsed;
let steps = 0;
while (accumulator >= DT && steps < 12) {
physicsStep(DT);
accumulator -= DT;
steps++;
}
if (steps === 12) accumulator = 0;
updateNozzleVector();
fitCamera(elapsed);
updateWater(elapsed);
updateMotion(elapsed);
draw();
requestAnimationFrame(frame);
}
resize();
resetHose();
updatePressureUI();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
The lagging bends, nozzle-led sweeps, pressure-dependent water range, stick–slip, and ability to throw the hose with a drag all come from the solver; there is no scripted oscillation or noise in the hose dynamics. The initial S-curve supplies the symmetry-breaking seed, while randomness is confined to the lawn and spray. Damping consists of the stated lawn friction and viscous drag, a small 0.04 s⁻¹ numerical velocity decay, projection losses, and a 35 m/s safety cap for extreme yanks. It is nevertheless much flatter and more orderly than a real escaping hose: it cannot lift, roll, twist, tumble in 3D, collide with itself, or pressure-stiffen, so at some settings it resembles a folding green rope or a regular rotating sweep rather than chaotic thrashing. The 25.9 psi figure is the static-breakaway calculation, not a guaranteed sharp visual bifurcation—near onset it can creep or stick–slip, and already-sliding motion has a little hysteresis. Aggressive dragging can also produce brief visible segment error before the projection passes recover it.