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 hose is a 2-D Verlet chain of 43 point masses, node 0 pinned at the spigot with zero inverse mass, the nozzle node free, and 42 mass-weighted PBD distance constraints of rest length h=4.2/42=0.1 m; each 1/480 s step applies a clipped discrete beam-curvature force with K_b=EI/h³ and EI=3.82 N·m², then 24 Gauss-Seidel projections of |rᵢ₊₁−rᵢ|=h plus an optional barycentric mouse-pin projection, so it bends, carries mass, and is inextensible by constraint. With nozzle tangent t=(r₄₂−r₄₁)/|r₄₂−r₄₁|, the ideal jet reaction is F(ψ)=ρAv²=2P A_eff=2(6894.757ψ)A_eff N, applied as −F t; I choose A_eff=F_c/(40·2·6894.757)=7.86×10⁻⁶ m² from the Beck/follower flutter load F_c=20.05EI/L²≈4.34 N, so F(ψ)=F_cψ/40≈0.1085ψ N and P_c=F_c/(2·6894.757A_eff)=40 psi. Ground contact is top-down Coulomb friction μ_k=0.62 that reduces every point’s velocity by μ_k g Δt to a static stop, plus linear turf drag 1.25 s⁻¹, a 7 m/s numerical speed cap, and an artificial pressure-dependent Hopf term 0.9(F−F_c)v⊥−2.1v⊥|v⊥|v⊥ with small random jet turbulence to expose that ideal threshold; lowering ψ turns the Hopf term into positive damping.
40
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Released Garden Hose</title>
<style>
:root{
color-scheme:dark;
--ink:#ecfff4;
--muted:#a9c8b3;
--panel:rgba(7,24,17,.78);
--line:rgba(187,239,205,.22);
--hot:#ffca6b;
}
*{box-sizing:border-box}
html,body{
margin:0;width:100%;height:100%;overflow:hidden;
background:#183b22;
font-family:system-ui,-apple-system,"Segoe UI",sans-serif;
color:var(--ink);
user-select:none;
}
#scene{
position:fixed;inset:0;width:100vw;height:100vh;display:block;
touch-action:none;cursor:crosshair;z-index:0;
}
#panel{
position:fixed;left:16px;top:16px;z-index:2;
width:min(360px,calc(100vw - 32px));
padding:14px 15px 13px;
border:1px solid var(--line);
border-radius:18px;
background:var(--panel);
box-shadow:0 18px 55px rgba(0,0,0,.35);
backdrop-filter:blur(9px);
}
.brand{
display:flex;align-items:center;gap:9px;
font-weight:800;font-size:12px;letter-spacing:.14em;text-transform:uppercase;
color:#d9f6df;
}
.brand i{
width:9px;height:9px;border-radius:50%;
background:#79e28d;box-shadow:0 0 12px #65ff8b;
}
.brand span{color:#80a58f;font-weight:650;letter-spacing:.07em}
.sub{
margin-top:8px;font-size:11px;letter-spacing:.11em;color:var(--muted);
display:flex;gap:9px;align-items:center;white-space:nowrap;
}
.sub b{color:#d7f5df;font-weight:700}
.meter{
margin-top:13px;display:flex;align-items:baseline;justify-content:space-between;gap:12px;
}
.meter label{font-size:13px;color:#bad5c4}
#psiOut{
font-variant-numeric:tabular-nums;font-weight:850;font-size:25px;
color:#eafff1;text-shadow:0 0 18px rgba(97,210,255,.22);
}
.sliderWrap{position:relative;margin-top:9px;height:37px}
#psi{
-webkit-appearance:none;appearance:none;width:100%;height:22px;margin:0;
background:transparent;cursor:ew-resize;outline:none;
}
#psi::-webkit-slider-runnable-track{
height:8px;border-radius:99px;border:1px solid rgba(255,255,255,.16);
background:linear-gradient(90deg,#204e38,#397f62 45%,#63bdd7 72%,#b6ecff);
}
#psi::-webkit-slider-thumb{
-webkit-appearance:none;appearance:none;width:20px;height:20px;border-radius:50%;
margin-top:-7px;border:2px solid #f3ffe9;background:#9ddcc0;
box-shadow:0 2px 10px rgba(0,0,0,.45),0 0 0 4px rgba(130,220,170,.15);
}
#psi::-moz-range-track{
height:8px;border-radius:99px;border:1px solid rgba(255,255,255,.16);
background:linear-gradient(90deg,#204e38,#397f62 45%,#63bdd7 72%,#b6ecff);
}
#psi::-moz-range-thumb{
width:18px;height:18px;border-radius:50%;
border:2px solid #f3ffe9;background:#9ddcc0;
box-shadow:0 2px 10px rgba(0,0,0,.45);
}
#critMark{
position:absolute;left:50%;top:0;height:16px;
border-left:1px dashed rgba(255,210,120,.72);
pointer-events:none;
}
#critMark span{
position:absolute;left:0;top:17px;transform:translateX(-50%);
white-space:nowrap;font-size:9px;letter-spacing:.08em;text-transform:uppercase;
color:#ffdc93;background:rgba(10,25,18,.72);
padding:2px 5px;border:1px solid rgba(255,210,120,.25);border-radius:7px;
}
.ends{display:flex;justify-content:space-between;font-size:10px;color:#85a894;margin-top:-3px}
.state{
margin-top:10px;padding:8px 10px;border-radius:10px;
background:rgba(112,179,133,.10);border:1px solid rgba(164,225,182,.15);
font-size:12px;color:#c8e6d2;min-height:31px;
}
.state.hot{
color:#fff0cb;background:rgba(255,174,74,.13);border-color:rgba(255,202,107,.34);
}
.hint{
margin-top:9px;color:#89aa95;font-size:11px;line-height:1.45;
}
</style>
</head>
<body>
<canvas id="scene" aria-label="A simulated garden hose whipping on a lawn"></canvas>
<div id="panel">
<div class="brand"><i></i> Lawn Lab <span>/ released hose</span></div>
<div class="sub"><b>SPIGOT: ON</b> · nozzle: free · length fixed</div>
<div class="meter">
<label for="psi">Spigot pressure</label>
<output id="psiOut" for="psi">80.0 psi · FULL</output>
</div>
<div class="sliderWrap">
<input id="psi" type="range" min="0" max="80" step="0.1" value="80" aria-label="Spigot pressure in psi">
<div id="critMark"><span id="critLabel">40 psi flutter</span></div>
</div>
<div class="ends"><span>0 psi</span><span>FULL 80 psi</span></div>
<div id="stateLine" class="state">starting the pump…</div>
<div class="hint">Drag a point on the hose to pin it to the pointer; release to let go. Load #psi=40 to start at 40 psi. Hash values are clamped to 0–80.</div>
</div>
<script>
(() => {
"use strict";
const canvas = document.getElementById("scene");
const ctx = canvas.getContext("2d", { alpha: false });
const slider = document.getElementById("psi");
const psiOut = document.getElementById("psiOut");
const stateLine = document.getElementById("stateLine");
const critMark = document.getElementById("critMark");
const critLabel = document.getElementById("critLabel");
const TAU = Math.PI * 2;
const FULL_PSI = 80;
const PREDICTED_PSI = 40;
const PA_PER_PSI = 6894.757293168;
const N = 43;
const SEG = N - 1;
const L = 4.2;
const H = L / SEG;
const NODE_MASS = 0.075;
const EI = 3.82;
const BEND_K = EI / (H * H * H);
const F_CRIT = 20.05 * EI / (L * L);
const A_EFF = F_CRIT / (PREDICTED_PSI * 2 * PA_PER_PSI);
const F_PER_PSI = 2 * PA_PER_PSI * A_EFF;
const CRITICAL_PSI = F_CRIT / F_PER_PSI;
const STEP_DT = 1 / 480;
const MAX_STEPS = 20;
const PBD_ITERS = 24;
const FRICTION_A = 0.62 * 9.81;
const TURF_DRAG = 1.25;
const MAX_SPEED = 7.0;
const D_CURV_CAP = 0.032;
const FLUTTER_GAIN = 0.9;
const FLUTTER_SAT = 2.1;
const MAX_DROPS = 420;
const X = new Float64Array(N);
const Y = new Float64Array(N);
const PX = new Float64Array(N);
const PY = new Float64Array(N);
const AXX = new Float64Array(N);
const AYY = new Float64Array(N);
const W = new Float64Array(N);
for (let i = 0; i < N; i++) W[i] = i === 0 ? 0 : 1 / NODE_MASS;
let pressure = initialPsi();
let view = { w: 1, h: 1, dpr: 1, ox: 0, oy: 0, scale: 80 };
let lawnCanvas = null;
let grip = null;
const drops = [];
const splats = [];
let emitAcc = 0;
let last = performance.now();
let simAcc = 0;
let statusTick = 0;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function initialPsi() {
let raw = "";
try { raw = location.hash.replace(/^#/, ""); } catch (_) { raw = ""; }
if (raw) {
try {
const params = new URLSearchParams(raw);
if (params.has("psi")) {
const v = Number(params.get("psi"));
if (Number.isFinite(v)) return clamp(v, 0, FULL_PSI);
}
} catch (_) {}
}
return FULL_PSI;
}
function setPressure(v) {
pressure = clamp(v, 0, FULL_PSI);
slider.value = pressure.toFixed(1);
psiOut.textContent = pressure.toFixed(1) + " psi" + (pressure >= FULL_PSI - 0.05 ? " · FULL" : "");
}
function pxX(x) { return view.ox + x * view.scale; }
function pxY(y) { return view.oy + y * view.scale; }
function resize() {
view.dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
view.w = Math.max(1, window.innerWidth | 0);
view.h = Math.max(1, window.innerHeight | 0);
canvas.width = Math.max(1, Math.round(view.w * view.dpr));
canvas.height = Math.max(1, Math.round(view.h * view.dpr));
canvas.style.width = view.w + "px";
canvas.style.height = view.h + "px";
ctx.setTransform(view.dpr, 0, 0, view.dpr, 0, 0);
view.ox = view.w * 0.46;
view.oy = view.h * 0.52;
const margin = 38;
const fit = Math.min(
(view.ox - margin) / L,
(view.w - view.ox - margin) / L,
(view.oy - margin) / L,
(view.h - view.oy - margin) / L
);
view.scale = Math.max(26, Math.min(112, fit));
buildLawn();
}
function buildLawn() {
lawnCanvas = document.createElement("canvas");
lawnCanvas.width = Math.max(1, Math.round(view.w * view.dpr));
lawnCanvas.height = Math.max(1, Math.round(view.h * view.dpr));
const g = lawnCanvas.getContext("2d");
g.setTransform(view.dpr, 0, 0, view.dpr, 0, 0);
const w = view.w, h = view.h;
const bg = g.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, "#224c2a");
bg.addColorStop(0.45, "#2f6334");
bg.addColorStop(1, "#183a20");
g.fillStyle = bg;
g.fillRect(0, 0, w, h);
g.save();
g.globalAlpha = 0.085;
g.translate(w * 0.5, h * 0.5);
g.rotate(-0.11);
for (let x = -w * 1.2, k = 0; x < w * 1.7; x += 128, k++) {
g.fillStyle = (k & 1) ? "#c7ec98" : "#071d12";
g.fillRect(x, -h, 64, h * 3);
}
g.restore();
let seed = (1234567 ^ Math.floor(w * 37) ^ Math.floor(h * 91)) >>> 0;
const rnd = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
const blades = Math.min(5200, Math.max(0, Math.floor((w * h) / 145)));
g.lineWidth = 1;
for (let i = 0; i < blades; i++) {
const x = rnd() * w;
const y = rnd() * h;
const len = 2 + rnd() * 7;
const lean = (rnd() - 0.5) * 4;
const light = rnd() > 0.48;
const a = (0.045 + rnd() * 0.07).toFixed(3);
g.strokeStyle = light ? "rgba(158,215,113," + a + ")" : "rgba(11,63,29," + a + ")";
g.beginPath();
g.moveTo(x, y);
g.lineTo(x + lean, y - len);
g.stroke();
}
for (let i = 0; i < 30; i++) {
const x = rnd() * w, y = rnd() * h;
const yellow = rnd() < 0.55;
g.fillStyle = yellow ? "rgba(246,223,91,.52)" : "rgba(236,244,225,.40)";
for (let j = 0; j < 6; j++) {
const a = j * TAU / 6;
g.beginPath();
g.ellipse(x + Math.cos(a) * 3, y + Math.sin(a) * 3, 3.3, 1.5, a, 0, TAU);
g.fill();
}
g.fillStyle = yellow ? "rgba(124,89,22,.65)" : "rgba(210,220,190,.55)";
g.beginPath();
g.arc(x, y, 2.1, 0, TAU);
g.fill();
}
}
function initHose() {
X.fill(0); Y.fill(0); PX.fill(0); PY.fill(0);
let cx = 0, cy = 0;
for (let i = 1; i < N; i++) {
const a = 0.10 * Math.sin((i - 1) * 0.42) + 0.025 * Math.sin((i - 1) * 1.17);
cx += H * Math.cos(a);
cy += H * Math.sin(a);
X[i] = cx; Y[i] = cy; PX[i] = cx; PY[i] = cy;
}
for (let k = 0; k < 100; k++) projectLengthPass();
for (let i = 0; i < N; i++) { PX[i] = X[i]; PY[i] = Y[i]; }
X[0] = Y[0] = PX[0] = PY[0] = 0;
}
function addForce(i, fx, fy) {
if (W[i] > 0) { AXX[i] += fx * W[i]; AYY[i] += fy * W[i]; }
}
function nozzleInfo() {
let dx = X[N - 1] - X[N - 2];
let dy = Y[N - 1] - Y[N - 2];
let len = Math.hypot(dx, dy);
if (len < 1e-9) { dx = 1; dy = 0; len = 1; }
const tx = dx / len, ty = dy / len;
const nx = -ty, ny = tx;
return { tx, ty, nx, ny, tipx: X[N - 1] + tx * 0.13, tipy: Y[N - 1] + ty * 0.13 };
}
function applyFriction(dt) {
const drag = Math.exp(-TURF_DRAG * dt);
const decel = FRICTION_A * dt;
for (let i = 1; i < N; i++) {
let vx = (X[i] - PX[i]) / dt;
let vy = (Y[i] - PY[i]) / dt;
const sp = Math.hypot(vx, vy);
if (sp > 1e-12) {
const s = Math.max(0, sp - decel) / sp;
vx *= s * drag; vy *= s * drag;
}
PX[i] = X[i] - vx * dt;
PY[i] = Y[i] - vy * dt;
}
PX[0] = PY[0] = 0;
}
function addBendForces() {
for (let i = 1; i < N - 1; i++) {
let dx = X[i - 1] - 2 * X[i] + X[i + 1];
let dy = Y[i - 1] - 2 * Y[i] + Y[i + 1];
const m = Math.hypot(dx, dy);
if (m > D_CURV_CAP) {
const s = D_CURV_CAP / m;
dx *= s; dy *= s;
}
const fx = BEND_K * dx;
const fy = BEND_K * dy;
addForce(i - 1, -fx, -fy);
addForce(i, 2 * fx, 2 * fy);
addForce(i + 1, -fx, -fy);
}
}
function integrateNodes(dt) {
const dt2 = dt * dt;
for (let i = 1; i < N; i++) {
const ox = X[i], oy = Y[i];
const vx = ox - PX[i];
const vy = oy - PY[i];
X[i] = ox + vx + AXX[i] * dt2;
Y[i] = oy + vy + AYY[i] * dt2;
PX[i] = ox;
PY[i] = oy;
}
X[0] = Y[0] = PX[0] = PY[0] = 0;
}
function projectLengthPass() {
for (let i = 0; i < SEG; i++) {
const dx = X[i + 1] - X[i];
const dy = Y[i + 1] - Y[i];
const d = Math.hypot(dx, dy);
if (d < 1e-9) continue;
const wi = W[i], wj = W[i + 1], ws = wi + wj;
if (ws <= 0) continue;
const c = (d - H) / d;
const ci = c * wi / ws;
const cj = c * wj / ws;
X[i] += dx * ci; Y[i] += dy * ci;
X[i + 1] -= dx * cj; Y[i + 1] -= dy * cj;
}
X[0] = Y[0] = 0;
}
function projectGrip() {
if (!grip) return;
const i = grip.i, u = grip.u, a = 1 - u, b = u;
const den = a * a * W[i] + b * b * W[i + 1];
if (den < 1e-9) return;
const gx = a * X[i] + b * X[i + 1];
const gy = a * Y[i] + b * Y[i + 1];
const lx = (grip.tx - gx) / den;
const ly = (grip.ty - gy) / den;
X[i] += a * W[i] * lx; Y[i] += a * W[i] * ly;
X[i + 1] += b * W[i + 1] * lx; Y[i + 1] += b * W[i + 1] * ly;
X[0] = Y[0] = 0;
}
function solveConstraints() {
for (let k = 0; k < PBD_ITERS; k++) {
if (grip) projectGrip();
projectLengthPass();
}
if (grip) projectGrip();
X[0] = Y[0] = 0;
}
function limitVelocities(dt) {
for (let i = 1; i < N; i++) {
const vx = (X[i] - PX[i]) / dt;
const vy = (Y[i] - PY[i]) / dt;
const sp = Math.hypot(vx, vy);
if (sp > MAX_SPEED) {
const s = MAX_SPEED / sp;
PX[i] = X[i] - vx * s * dt;
PY[i] = Y[i] - vy * s * dt;
}
}
}
function physicsStep(dt) {
applyFriction(dt);
AXX.fill(0); AYY.fill(0);
addBendForces();
const nf = nozzleInfo();
const F = F_PER_PSI * pressure;
addForce(N - 1, -F * nf.tx, -F * nf.ty);
const vx = (X[N - 1] - PX[N - 1]) / dt;
const vy = (Y[N - 1] - PY[N - 1]) / dt;
const vperp = vx * nf.nx + vy * nf.ny;
let drive = FLUTTER_GAIN * (F - F_CRIT) * vperp -
FLUTTER_SAT * vperp * Math.abs(vperp) * vperp;
drive = clamp(drive, -5.5, 5.5);
const tail = 7;
const sumW = tail * (tail + 1) / 2;
for (let j = 0; j < tail; j++) {
const idx = N - tail + j;
const weight = (j + 1) / sumW;
addForce(idx, nf.nx * drive * weight, nf.ny * drive * weight);
}
const noise = (Math.random() + Math.random() + Math.random() - 1.5) / 1.5;
const turb = (0.010 * F + 0.055 * Math.max(0, F - F_CRIT)) * noise;
let tw = 0;
for (let j = 1; j <= 5; j++) tw += j;
for (let j = 0; j < 5; j++) {
const idx = N - 1 - j;
const weight = (5 - j) / tw;
addForce(idx, nf.nx * turb * weight, nf.ny * turb * weight);
}
integrateNodes(dt);
solveConstraints();
limitVelocities(dt);
PX[0] = PY[0] = 0;
const n2 = nozzleInfo();
emitWater(dt, F, n2);
updateWater(dt);
}
function emitWater(dt, F, n) {
if (pressure <= 0.05 || F <= 0) { emitAcc = 0; return; }
const rate = 4 + pressure * 1.15;
emitAcc += rate * dt;
let made = 0;
while (emitAcc >= 1 && made < 5 && drops.length < MAX_DROPS) {
emitAcc -= 1;
made++;
spawnDrop(n);
}
if (drops.length >= MAX_DROPS) emitAcc = 0;
}
function spawnDrop(n) {
const spread = (Math.random() - 0.5) * 0.24;
const c = Math.cos(spread), s = Math.sin(spread);
const dx = n.tx * c - n.ty * s;
const dy = n.tx * s + n.ty * c;
const speed = 0.86 * Math.sqrt(Math.max(0, pressure)) * (0.78 + 0.50 * Math.random());
const side = (Math.random() - 0.5) * 0.12 * speed;
drops.push({
x: n.tipx + (Math.random() - 0.5) * 0.025,
y: n.tipy + (Math.random() - 0.5) * 0.025,
vx: dx * speed + n.nx * side,
vy: dy * speed + n.ny * side,
z: 0.10 + Math.random() * 0.05,
vz: 0.75 + 0.018 * pressure + Math.random() * 1.05,
age: 0
});
}
function updateWater(dt) {
const G = 9.81;
for (let i = drops.length - 1; i >= 0; i--) {
const d = drops[i];
d.age += dt;
d.vz -= G * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
d.z += d.vz * dt;
if (d.z <= 0 || d.age > 1.35) {
addSplat(d.x, d.y);
drops.splice(i, 1);
}
}
for (let i = splats.length - 1; i >= 0; i--) {
splats[i].life -= dt;
if (splats[i].life <= 0) splats.splice(i, 1);
}
}
function addSplat(x, y) {
const life = 2.5 + Math.random() * 2.5;
splats.push({
x, y,
r: 0.025 + Math.random() * 0.055,
a: (Math.random() - 0.5) * 0.8,
life, maxLife: life
});
if (splats.length > 320) splats.splice(0, splats.length - 320);
}
function hosePath() {
ctx.beginPath();
ctx.moveTo(pxX(X[0]), pxY(Y[0]));
for (let i = 1; i < N - 1; i++) {
const x0 = pxX(X[i]), y0 = pxY(Y[i]);
const x1 = pxX(X[i + 1]), y1 = pxY(Y[i + 1]);
ctx.quadraticCurveTo(x0, y0, (x0 + x1) * 0.5, (y0 + y1) * 0.5);
}
ctx.lineTo(pxX(X[N - 1]), pxY(Y[N - 1]));
}
function drawHose() {
const w = Math.max(6, view.scale * 0.085);
ctx.save();
ctx.translate(4, 6);
ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.lineWidth = w + 5;
ctx.strokeStyle = "rgba(2,16,8,.25)";
hosePath(); ctx.stroke();
ctx.restore();
ctx.save();
ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.lineWidth = w + 2.6;
ctx.strokeStyle = "#082517";
hosePath(); ctx.stroke();
ctx.lineWidth = w;
ctx.strokeStyle = "#247d43";
hosePath(); ctx.stroke();
ctx.lineWidth = w * 0.42;
ctx.strokeStyle = "rgba(157,226,119,.16)";
hosePath(); ctx.stroke();
ctx.setLineDash([7, 10]);
ctx.lineWidth = Math.max(1, w * 0.14);
ctx.strokeStyle = "rgba(244,213,77,.62)";
hosePath(); ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth = Math.max(1, w * 0.13);
ctx.strokeStyle = "rgba(5,28,16,.40)";
for (let i = 3; i < N - 1; i += 4) {
const x = pxX(X[i]), y = pxY(Y[i]);
const ax = pxX(X[i - 1]), ay = pxY(Y[i - 1]);
const bx = pxX(X[i + 1]), by = pxY(Y[i + 1]);
const dx = bx - ax, dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len;
const half = w * 0.42;
ctx.beginPath();
ctx.moveTo(x - nx * half, y - ny * half);
ctx.lineTo(x + nx * half, y + ny * half);
ctx.stroke();
}
ctx.restore();
}
function drawSpigot() {
const x = pxX(0), y = pxY(0);
const r = Math.max(11, view.scale * 0.14);
ctx.save();
ctx.translate(x, y);
ctx.fillStyle = "rgba(0,0,0,.28)";
ctx.beginPath();
ctx.ellipse(5, 7, r * 1.55, r * 1.07, 0, 0, TAU);
ctx.fill();
ctx.lineCap = "round";
ctx.lineWidth = r * 0.75;
ctx.strokeStyle = "#70837e";
ctx.beginPath();
ctx.moveTo(-r * 2.15, 0); ctx.lineTo(-r * 0.42, 0); ctx.stroke();
ctx.lineWidth = r * 0.30;
ctx.strokeStyle = "rgba(224,238,232,.58)";
ctx.beginPath();
ctx.moveTo(-r * 2.05, -r * 0.18); ctx.lineTo(-r * 0.48, -r * 0.18); ctx.stroke();
const grad = ctx.createRadialGradient(-r * 0.38, -r * 0.46, r * 0.08, 0, 0, r);
grad.addColorStop(0, "#e6eee9");
grad.addColorStop(0.45, "#8fa09b");
grad.addColorStop(1, "#344a43");
ctx.fillStyle = grad;
ctx.strokeStyle = "rgba(7,25,19,.72)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, 0, r, 0, TAU); ctx.fill(); ctx.stroke();
ctx.rotate(-0.14);
ctx.lineWidth = Math.max(3, r * 0.22);
ctx.strokeStyle = "#b94134";
ctx.beginPath(); ctx.moveTo(-r * 1.25, 0); ctx.lineTo(r * 1.25, 0); ctx.stroke();
ctx.strokeStyle = "#dbe7df";
ctx.beginPath(); ctx.moveTo(0, -r * 1.12); ctx.lineTo(0, r * 1.12); ctx.stroke();
ctx.rotate(0.14);
ctx.fillStyle = "#eadb9d";
ctx.beginPath(); ctx.arc(0, 0, r * 0.31, 0, TAU); ctx.fill();
ctx.font = "800 " + Math.round(Math.max(9, r * 0.55)) + "px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "rgba(3,16,10,.72)";
ctx.fillRect(-r * 0.82, r * 1.55, r * 1.64, r * 0.62);
ctx.fillStyle = "#eaffdf";
ctx.fillText("ON", 0, r * 1.87);
ctx.restore();
}
function drawNozzle() {
const n = nozzleInfo();
const w = Math.max(6, view.scale * 0.085);
const bx = pxX(X[N - 1] - n.tx * 0.10);
const by = pxY(Y[N - 1] - n.ty * 0.10);
const ex = pxX(n.tipx), ey = pxY(n.tipy);
ctx.save();
ctx.lineCap = "butt"; ctx.lineJoin = "round";
ctx.lineWidth = w * 1.34;
ctx.strokeStyle = "#0b201c";
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(ex, ey); ctx.stroke();
ctx.lineWidth = w * 0.92;
ctx.strokeStyle = "#9ba9a4";
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(ex, ey); ctx.stroke();
ctx.lineWidth = Math.max(1, w * 0.16);
ctx.strokeStyle = "rgba(240,250,246,.75)";
ctx.beginPath();
ctx.moveTo(bx + n.nx * w * 0.24, by + n.ny * w * 0.24);
ctx.lineTo(ex + n.nx * w * 0.24, ey + n.ny * w * 0.24);
ctx.stroke();
ctx.lineCap = "round";
ctx.lineWidth = Math.max(2, w * 0.30);
ctx.strokeStyle = "#253a35";
ctx.beginPath();
ctx.moveTo(ex - n.nx * w * 0.61, ey - n.ny * w * 0.61);
ctx.lineTo(ex + n.nx * w * 0.61, ey + n.ny * w * 0.61);
ctx.stroke();
ctx.restore();
}
function drawSplats() {
ctx.save();
for (const s of splats) {
const x = pxX(s.x), y = pxY(s.y);
if (x < -60 || x > view.w + 60 || y < -60 || y > view.h + 60) continue;
const q = clamp(s.life / s.maxLife, 0, 1);
const r = s.r * view.scale * (1 + 0.65 * (1 - q));
ctx.fillStyle = "rgba(26,90,108," + (0.14 * q).toFixed(3) + ")";
ctx.beginPath();
ctx.ellipse(x, y, r, r * 0.55, s.a, 0, TAU);
ctx.fill();
ctx.strokeStyle = "rgba(153,224,233," + (0.08 * q).toFixed(3) + ")";
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.restore();
}
function drawDrops() {
ctx.save();
ctx.lineCap = "round";
for (const d of drops) {
const x = pxX(d.x), y = pxY(d.y);
const h = d.z * view.scale * 0.65;
const dy = y - h;
if (x < -70 || x > view.w + 70 || dy < -70 || dy > view.h + 70) continue;
const a = clamp(0.25 + d.z * 4, 0, 0.9);
ctx.fillStyle = "rgba(2,24,31," + (0.13 * a).toFixed(3) + ")";
ctx.beginPath();
ctx.ellipse(x, y, 2 + d.z * 12, 1.1 + d.z * 4, 0, 0, TAU);
ctx.fill();
ctx.strokeStyle = "rgba(112,203,242," + (0.55 * a).toFixed(3) + ")";
ctx.lineWidth = 1.2 + 1.3 * a;
ctx.beginPath();
ctx.moveTo(x - d.vx * view.scale * 0.018, dy - d.vy * view.scale * 0.018 + d.vz * view.scale * 0.006);
ctx.lineTo(x, dy);
ctx.stroke();
ctx.fillStyle = "rgba(226,250,255," + (0.72 * a).toFixed(3) + ")";
ctx.beginPath();
ctx.arc(x, dy, 1.2 + 1.4 * a, 0, TAU);
ctx.fill();
}
ctx.restore();
}
function drawGrip() {
if (!grip) return;
const x = pxX(grip.tx), y = pxY(grip.ty);
ctx.save();
ctx.strokeStyle = "#ffd479";
ctx.fillStyle = "rgba(255,206,100,.13)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x, y, 9, 0, TAU); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x - 15, y); ctx.lineTo(x + 15, y); ctx.moveTo(x, y - 15); ctx.lineTo(x, y + 15); ctx.stroke();
ctx.font = "800 10px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.fillStyle = "#ffe5aa";
ctx.fillText("PIN", x, y - 18);
ctx.restore();
}
function updateStatus() {
if ((statusTick++ & 7) !== 0) return;
let v = 0;
const count = 12;
for (let i = N - count; i < N; i++) {
const vx = (X[i] - PX[i]) / STEP_DT;
const vy = (Y[i] - PY[i]) / STEP_DT;
v += Math.hypot(vx, vy);
}
v /= count;
let msg;
if (pressure < 0.5) msg = "0 psi: no jet; the hose is coming to rest";
else if (pressure > CRITICAL_PSI && v > 0.28) msg = "WHIPPING: jet load is feeding flutter";
else if (pressure > CRITICAL_PSI + 0.2) msg = "unstable: turbulence is looking for a bend";
else msg = "SETTLING: turf friction is winning";
if (grip) msg += " · one point pinned";
stateLine.textContent = msg;
stateLine.classList.toggle("hot", pressure > CRITICAL_PSI && v > 0.28);
}
function render() {
if (lawnCanvas) ctx.drawImage(lawnCanvas, 0, 0, view.w, view.h);
else { ctx.fillStyle = "#2a5b31"; ctx.fillRect(0, 0, view.w, view.h); }
drawSplats();
drawHose();
drawSpigot();
drawNozzle();
drawDrops();
drawGrip();
updateStatus();
}
function frame(now) {
let elapsed = Math.max(0, (now - last) / 1000);
last = now;
elapsed = Math.min(0.05, elapsed);
simAcc += elapsed;
let steps = 0;
while (simAcc >= STEP_DT && steps < MAX_STEPS) {
physicsStep(STEP_DT);
simAcc -= STEP_DT;
steps++;
}
if (steps >= MAX_STEPS) simAcc = 0;
render();
requestAnimationFrame(frame);
}
function worldFromEvent(e) {
const r = canvas.getBoundingClientRect();
return {
x: (e.clientX - r.left - view.ox) / view.scale,
y: (e.clientY - r.top - view.oy) / view.scale
};
}
function findNearestGrip(wx, wy) {
let best = Infinity, bi = 0, bu = 0;
for (let i = 0; i < SEG; i++) {
const ax = X[i], ay = Y[i];
const dx = X[i + 1] - ax, dy = Y[i + 1] - ay;
const len2 = dx * dx + dy * dy;
if (len2 < 1e-12) continue;
let u = ((wx - ax) * dx + (wy - ay) * dy) / len2;
u = clamp(u, 0, 1);
const qx = ax + u * dx, qy = ay + u * dy;
const d2 = (wx - qx) * (wx - qx) + (wy - qy) * (wy - qy);
if (d2 < best) { best = d2; bi = i; bu = u; }
}
if (Math.sqrt(best) > 0.22) return null;
if (bi === 0 && bu < 0.04) bu = 0.04;
return { i: bi, u: bu, tx: wx, ty: wy, id: null };
}
canvas.addEventListener("pointerdown", e => {
const w = worldFromEvent(e);
const g = findNearestGrip(w.x, w.y);
if (!g) return;
g.id = e.pointerId;
grip = g;
try { canvas.setPointerCapture(e.pointerId); } catch (_) {}
canvas.style.cursor = "grabbing";
e.preventDefault();
});
canvas.addEventListener("pointermove", e => {
if (!grip || e.pointerId !== grip.id) return;
const w = worldFromEvent(e);
grip.tx = w.x; grip.ty = w.y;
e.preventDefault();
});
function release(e) {
if (grip && (!e || grip.id === e.pointerId)) {
if (e) { try { canvas.releasePointerCapture(e.pointerId); } catch (_) {} }
grip = null;
canvas.style.cursor = "crosshair";
}
}
canvas.addEventListener("pointerup", release);
canvas.addEventListener("pointercancel", release);
window.addEventListener("blur", () => release(null));
slider.addEventListener("input", () => setPressure(Number(slider.value)));
initHose();
resize();
window.addEventListener("resize", resize);
critMark.style.left = (100 * CRITICAL_PSI / FULL_PSI).toFixed(2) + "%";
critLabel.textContent = Math.round(CRITICAL_PSI) + " psi flutter";
setPressure(pressure);
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
It looks hose-like where it should: the chain keeps its length while bending, the spigot end stays pinned, a dragged barycentric point follows the pointer until release, the nozzle direction sets both the recoil and the spray direction, low pressures die away under friction, high pressures self-excite into a traveling whip, lowering pressure damps it back down, and water dots leave along the current nozzle vector, arc in a pseudo-height, and make wet landing spots. The exact motion is not a real hose: it is a flattened top-down rod with no lift-off, twist, ovalization, hose memory, distributed internal-flow momentum, real contact patches, true stick-slip transitions, nozzle torque, ground collisions, or a person’s hand; the jet speed used for drawing water is scaled down from the Bernoulli speed used in the force. The artificial pieces are explicit: Coulomb friction plus linear turf drag, clipped curvature forces, a hard 7 m/s speed clamp, random nozzle turbulence, and the pressure-dependent Hopf positive/negative damping term centered on the chosen flutter load; there is no scripted sine-wave oscillation, but the whipping is helped by injected noise rather than arising from a fully resolved fluid-structure interaction. Everyone has watched a hose get away from somebody; mine gets wrong the heavy, three-dimensional, damped-but-buckling chaos and replaces it with a planar instability that is cleaner, more periodic, and more eager to settle than the real thing.