磁性流体の進み具合

部品 · 進み具合のバー

見られた数 1,100コピーされた数 20

MIT · コードの1行目に作者の名前が残ります

<!-- UI by @Lattice_Node — ludion.ai/p/progress-ferro (MIT) -->
<div data-ld-part="progress-ferro" lang="ja">
  <canvas class="scene" aria-hidden="true"></canvas>
  <div class="bar" role="progressbar" aria-label="アップロードの進み具合" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%" tabindex="-1" data-bar></div>
  <div class="readout">
    <p class="state" role="status" aria-live="polite" data-state>アップロード中</p>
    <p class="value" aria-hidden="true"><span data-value>0</span><span class="unit">%</span></p>
    <button class="again" type="button" data-again>もう一度</button>
  </div>
  <span class="probe" aria-hidden="true"></span>
</div>
<style>
/* UI by @Lattice_Node — ludion.ai/p/progress-ferro (MIT) */
[data-ld-part="progress-ferro"] {
  /* 固定しないトークン。流体の黒は --_fg の色相、光は --_bg の色相、薄い膜の色は --_accent から作る */
  --_bg: var(--ld-bg, #efede8);
  --_surface: var(--ld-surface, #d5dbd8);
  --_fg: var(--ld-fg, #0d0d10);
  --_muted: var(--ld-muted, #5c5a55);
  --_accent: var(--ld-accent, #9c4a16);
  --_radius: var(--ld-radius, 9999px);
  --_font: var(--ld-font, "Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", system-ui, sans-serif);
  --_space: var(--ld-space, 8px);
  --_shadow: var(--ld-shadow, 0 12px 32px rgb(13 13 16 / .18));
  --_motion: var(--ld-motion, 200ms);

  /* どの枠にも収まる。管、床の映り込み、文字の段を含めた比率 */
  --ratio: 2.9;

  position: relative;
  display: block;
  box-sizing: border-box;
  container-type: inline-size;
  width: min(100%, calc((100vh - 64px) * var(--ratio)));
  aspect-ratio: var(--ratio);
  font-family: var(--_font);
  color: var(--_fg);
}

/* 中くらいの枠では、左右に少し余白を残す。サムネイルと OG で、管の全体が中央に収まるように */
@media (max-width: 900px) {
  [data-ld-part="progress-ferro"] {
    --ratio: 2.4;
    width: min(94%, calc((100vh - 64px) * var(--ratio)));
  }
}

@media (max-width: 560px) {
  [data-ld-part="progress-ferro"] {
    --ratio: 1.7;
    width: min(100%, calc((100vh - 64px) * var(--ratio)));
  }
}

/* 色を読むための見えない目印 */
[data-ld-part="progress-ferro"] .probe {
  display: none;
}

[data-ld-part="progress-ferro"] .scene {
  position: absolute;
  top: 0;
  left: 0;
  display: block;
  width: 100%;
  height: 100%;
}

/* 本物の進み具合。絵は canvas が描き、ここは管の場所だけを持つ */
[data-ld-part="progress-ferro"] .bar {
  position: absolute;
  border-radius: var(--_radius);
}

[data-ld-part="progress-ferro"] .bar:focus {
  outline: none;
}

[data-ld-part="progress-ferro"][data-done] .bar {
  cursor: pointer;
}

[data-ld-part="progress-ferro"] .readout {
  position: absolute;
  right: 2.4cqw;
  bottom: 0;
  left: 2.4cqw;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: end;
}

[data-ld-part="progress-ferro"] .state {
  grid-area: 1 / 1;
  margin: 0;
  font-size: max(14px, 1.62cqw);
  font-weight: 500;
  line-height: 1.5;
  letter-spacing: .06em;
}

[data-ld-part="progress-ferro"] .value {
  grid-area: 1 / 2;
  justify-self: end;
  margin: 0;
  font-size: max(30px, 5.4cqw);
  font-weight: 200;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  letter-spacing: -.02em;
  transition: opacity var(--_motion) ease;
}

[data-ld-part="progress-ferro"] .unit {
  margin-left: calc(var(--_space) * .5);
  color: var(--_muted);
  font-size: .46em;
  font-weight: 400;
  letter-spacing: 0;
}

[data-ld-part="progress-ferro"] .again {
  grid-area: 1 / 2;
  justify-self: end;
  display: inline-grid;
  place-items: center;
  min-width: 7.4em;
  min-height: max(44px, 3.2em);
  margin: 0;
  padding: 0;
  border: 1px solid var(--_fg);
  border-radius: var(--_radius);
  background: transparent;
  color: var(--_fg);
  font: inherit;
  font-size: max(14px, 1.46cqw);
  font-weight: 500;
  letter-spacing: .08em;
  cursor: pointer;
  visibility: hidden;
  opacity: 0;
  transition: opacity var(--_motion) ease, background-color var(--_motion) ease, color var(--_motion) ease;
}

[data-ld-part="progress-ferro"] .again:hover {
  background: var(--_fg);
  color: var(--_bg);
}

[data-ld-part="progress-ferro"] .again:focus {
  outline: none;
}

[data-ld-part="progress-ferro"] .again:focus-visible {
  outline: 2px solid var(--_accent);
  outline-offset: calc(var(--_space) * .375);
}

[data-ld-part="progress-ferro"][data-done] .value {
  visibility: hidden;
  opacity: 0;
}

[data-ld-part="progress-ferro"][data-done] .again {
  visibility: visible;
  opacity: 1;
}

/* WebGL が使えないとき:ふつうの棒で同じことを伝える */
[data-ld-part="progress-ferro"][data-flat] .bar {
  border: 1px solid var(--_muted);
  background: linear-gradient(to right, var(--_fg) calc(var(--p, 0) * 100%), transparent 0);
}

@media (prefers-reduced-motion: reduce) {
  [data-ld-part="progress-ferro"] .value,
  [data-ld-part="progress-ferro"] .again {
    transition: none;
  }
}
</style>
<script type="module">
/* UI by @Lattice_Node — ludion.ai/p/progress-ferro (MIT) */
// part.js — ESモジュール。rootの中だけを触る
// 磁性流体の進み具合:横長のガラス管の中に、鏡のように黒い磁性流体が入っている。
//
// 形:管の中を光線で追う。流体は、細い帯、頭の塊、とげ(六方の格子)を、なめらかに溶け合わせたもの。
//     進み具合の先に見えない磁石があり、とげの高さと傾きは格子ごとのばねで追いかける。だから波のように移る。
//     100% で磁石が消えると、とげはほどけ、両端が引き戻されて2つの玉になり、ぶつかって1つの球になる。
// 光:ガラスは入るときと出るときで4回曲げて、面の映り込みと、縁の暗い線を出す。流体は周りの光だけを映す。
//     とげどうしで映り合うところは、反射の光線を短く追って暗くする。
// 床:影、管が集めた光、下へ薄れていく映り込みを、絵を描いたあとで重ねる。
// 色は JS に書かない。--_bg、--_fg、--_surface、--_muted、--_accent を読み、色相を保って明るさだけを決める。

const R_IN = 0.945;
const ROW_Z = 0.27;
const PITCH = 0.34;
const MAX_COLS = 40;
const ROWS = 3;
const TEX = 512;
const MAX_BLOBS = 6;
const BALL = 0.76;
const WIDTH_K = 0.62;
const SPIKE_R = 0.15;
const ROOT = 0.26; // とげの根が、体の中へ細くなって消える深さ
const CAMERA_TILT = (9.5 * Math.PI) / 180;
const CAMERA_DIST = 30;
const SIM_DT = 1 / 120;
const HOLD = 0.45; // 動き出すまでの間
const GATHER = 0.16;
const PULL = 1.62;
const MERGE = 0.3;
const RETURN_TIME = 1.05;

// アップロードの進み方:ひと息ごとに進み、少し止まる
const KEYS = [
  [0, 0],
  [1.1, 0.06],
  [1.9, 0.075],
  [3.4, 0.27],
  [4.2, 0.3],
  [5.9, 0.53],
  [6.6, 0.55],
  [8.3, 0.81],
  [8.9, 0.83],
  [10.4, 1],
];
const RUN_TIME = KEYS[KEYS.length - 1][0];

const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const mix = (a, b, t) => a + (b - a) * t;
const sq = (v) => v * v;
const smoothstep = (a, b, x) => {
  const t = clamp((x - a) / (b - a));
  return t * t * (3 - 2 * t);
};

function progressAt(t) {
  if (t <= 0) return 0;
  for (let i = 1; i < KEYS.length; i++) {
    if (t <= KEYS[i][0]) {
      const a = KEYS[i - 1];
      const b = KEYS[i];
      return mix(a[1], b[1], smoothstep(a[0], b[0], t));
    }
  }
  return 1;
}

function seeded(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// ---- 色の計算(OKLab)。色の値は書かず、トークンの色相に明るさだけを与える
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
function rgbToOklab(r, g, b) {
  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
  return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
}
function oklabToLinear(L, a, b) {
  const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
  const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
  const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3);
  return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
}
// 明るさを決め、色域に入るまで鮮やかさを落とす
function labTone(lab, L, keep = 1) {
  let chroma = keep;
  for (let i = 0; i < 24; i++) {
    const rgb = oklabToLinear(L, lab[1] * chroma, lab[2] * chroma);
    if (rgb.every((v) => v >= -0.001 && v <= 1.001)) return rgb.map((v) => clamp(v));
    chroma *= 0.9;
  }
  return oklabToLinear(L, 0, 0).map((v) => clamp(v));
}

// ---- シェーダー
const VERT = `#version 300 es
void main() {
  vec2 p = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));
  gl_Position = vec4(p * 4.0 - 1.0, 0.0, 1.0);
}`;

// 床:絵の中(ガラス越し)と、合成の段の両方で使う
const FLOOR = `
uniform sampler2D uProfile;
uniform float uTexX, uTexK, uL;
uniform vec3 uBackdrop, uLight;
float sq(float x) { return x * x; }
vec3 floorAt(vec2 q) {
  vec4 P = textureLod(uProfile, vec2((q.x + uTexX) * uTexK, 0.5), 0.0);
  float z = q.y;
  float ax = abs(q.x);
  float body = 1.0 - smoothstep(uL + 0.1, uL + 1.3, ax);
  float near = 1.0 - smoothstep(uL + 0.55, uL + 0.98, ax);
  float sh = 0.0;
  sh += 0.085 * body * exp(-sq((z - 0.8) / 1.6));
  sh += 0.26 * near * exp(-sq(z / 0.07));
  sh += 0.13 * near * exp(-sq(z / 0.3));
  sh += 0.86 * P.b * exp(-sq((z - 0.5) / 0.85));
  sh += 0.40 * P.a * exp(-sq((z - 1.9) / 2.3));
  float line = exp(-sq((z - 1.36) / 0.06)) + 0.36 * exp(-sq((z - 1.36) / 0.32));
  float caustic = line * near * sq(1.0 - P.b);
  return uBackdrop * exp(-sh * 1.15) + uLight * caustic * 0.17;
}`;

const SCENE_FRAG = `#version 300 es
precision highp float;
precision highp int;
#define R_IN ${R_IN.toFixed(4)}
#define WIDTH_K ${WIDTH_K.toFixed(3)}
#define PITCH ${PITCH.toFixed(3)}
#define ROW_Z ${ROW_Z.toFixed(3)}
#define COLS ${MAX_COLS}
#define MAX_BLOBS ${MAX_BLOBS}
#define SPIKE_R ${SPIKE_R.toFixed(3)}
#define ROOT ${ROOT.toFixed(3)}
#define SPIKE_BASE SPIKE_R
#define SPIKE_TIP tipR
#define STEPS 72
${FLOOR}
uniform vec2 uOrigin, uCenter;
uniform float uScale, uFocal, uPixel;
uniform vec3 uRo, uRight, uUp, uFwd;
uniform vec4 uTrail;
uniform float uTrailOn, uBlend, uCrownOn, uCellX0;
uniform vec4 uBlob[MAX_BLOBS];
uniform int uBlobs;
uniform vec4 uCell[COLS * 3];
uniform vec4 uCrown;
uniform vec3 uTall, uBound;
uniform vec3 uInk, uAmber, uCool, uGlassAbs;
uniform vec3 uTick;
out vec4 outColor;
float tipR = 0.0;

// ---- 流体の形
float ellipsoid(vec3 q, vec3 r) {
  float k0 = length(q / r);
  float k1 = length(q / (r * r));
  return k0 * (k0 - 1.0) / max(k1, 1.0e-4);
}
float smin3(float a, float b, float k) {
  float h = max(k - abs(a - b), 0.0) / k;
  return min(a, b) - h * h * h * k / 6.0;
}
float sdTrail(vec3 p) {
  float xc = clamp(p.x, uTrail.x, uTrail.y);
  vec2 P = textureLod(uProfile, vec2((xc + uTexX) * uTexK, 0.5), 0.0).rg;
  float h = max(P.x, 0.003);
  float dx = p.x - xc;
  vec3 r = vec3(dx < 0.0 ? uTrail.z : uTrail.w, h, sqrt(WIDTH_K * h) * (1.0 - exp(-h / 0.06)));
  vec3 q = vec3(dx, p.y + R_IN - h, p.z);
  return ellipsoid(q, r) / sqrt(1.0 + P.y * P.y * 0.9);
}
float sdSpike(vec3 p, int row, float col, float off, float cz) {
  col = clamp(col, 0.0, float(COLS - 1));
  vec4 c = uCell[row * COLS + int(col)];
  float H = c.y;
  if (H < 0.012) return 9.0;
  float y = p.y - c.x;
  float u = clamp(y / H, 0.0, 1.0);
  vec2 q = vec2(p.x - (uCellX0 + (col + off) * PITCH + c.w) - c.z * u * u, p.z - cz);
  float rr = SPIKE_TIP + 0.05 * (1.0 - smoothstep(0.03, 0.2, H));
  float a = SPIKE_BASE * min(1.0, sqrt(H / 0.4));
  float sl0 = 1.25 * a / H;
  float scale = 1.0 / sqrt(1.0 + sl0 * sl0);
  if (y > H) return (length(vec3(q, y - H)) - rr) * scale;
  if (y < -ROOT) return (length(vec3(q, y + ROOT)) - rr) * scale;
  if (y < 0.0) {
    float n = y / ROOT;
    return (length(q) - (a - sl0 * y) * (1.0 - n * n) - rr) * scale;
  }
  float w = 1.0 - u;
  return (length(q) - a * w * sqrt(sqrt(w)) - rr) * scale;
}
float map(vec3 p) {
  float d = 9.0;
  if (uTrailOn > 0.5) d = sdTrail(p);
  for (int i = 0; i < uBlobs; i++) {
    vec4 b = uBlob[i];
    d = smin3(d, ellipsoid(vec3(p.x - b.x, p.y + R_IN - b.y, p.z), b.zyw), uBlend);
  }
  if (uCrownOn > 0.5) {
    vec2 bq = vec2(max(uCrown.x - p.x, p.x - uCrown.y), max(uCrown.z - p.y, p.y - uCrown.w));
    float bd = length(max(bq, 0.0));
    if (bd < d + 0.16) {
      float u = (p.x - uCellX0) / PITCH;
      float i0 = floor(u);
      float i1 = floor(u - 0.5);
      int side = p.z >= 0.0 ? 2 : 0;
      float cz = p.z >= 0.0 ? ROW_Z : -ROW_Z;
      float s = sdSpike(p, 1, i0, 0.0, 0.0);
      s = smin3(s, sdSpike(p, 1, i0 + 1.0, 0.0, 0.0), 0.05);
      s = smin3(s, sdSpike(p, side, i1, 0.5, cz), 0.05);
      s = smin3(s, sdSpike(p, side, i1 + 1.0, 0.5, cz), 0.05);
      d = smin3(d, s, 0.15);
    }
  }
  return d;
}

// ---- 向き:差分を取らず、式から直接求める。鏡の面に段が出ないように
void sminG(inout float a, inout vec3 ga, float b, vec3 gb, float k) {
  float h = max(k - abs(a - b), 0.0) / k;
  float m = 0.5 * h * h;
  float s = h * h * h * k / 6.0;
  if (a < b) { ga = mix(ga, gb, m); a -= s; }
  else { ga = mix(gb, ga, m); a = b - s; }
}
float trailG(vec3 p, out vec3 g) {
  float xc = clamp(p.x, uTrail.x, uTrail.y);
  vec2 P = textureLod(uProfile, vec2((xc + uTexX) * uTexK, 0.5), 0.0).rg;
  float h = max(P.x, 0.003);
  float dx = p.x - xc;
  float cap = dx < 0.0 ? uTrail.z : uTrail.w;
  float wr = sqrt(WIDTH_K * h);
  float we = exp(-h / 0.06);
  float w = wr * (1.0 - we);
  float dwdh = WIDTH_K / (2.0 * wr) * (1.0 - we) + wr * we / 0.06;
  vec3 r = vec3(cap, h, w);
  vec3 q = vec3(dx, p.y + R_IN - h, p.z);
  float k0 = max(length(q / r), 1.0e-4);
  float slope = dx == 0.0 ? P.y : 0.0;
  float dh = (-q.y / (h * h) * (1.0 + q.y / h) - q.z * q.z / (w * w * w) * dwdh) / k0;
  g = normalize(vec3(q.x / (cap * cap) / k0 + slope * dh, q.y / (h * h) / k0, q.z / (w * w) / k0));
  return ellipsoid(q, r) / sqrt(1.0 + P.y * P.y * 0.9);
}
float spikeG(vec3 p, int row, float col, float off, float cz, out vec3 g) {
  g = vec3(0.0, 1.0, 0.0);
  col = clamp(col, 0.0, float(COLS - 1));
  vec4 c = uCell[row * COLS + int(col)];
  float H = c.y;
  if (H < 0.012) return 9.0;
  float y = p.y - c.x;
  float u = clamp(y / H, 0.0, 1.0);
  vec2 q = vec2(p.x - (uCellX0 + (col + off) * PITCH + c.w) - c.z * u * u, p.z - cz);
  float rr = SPIKE_TIP + 0.05 * (1.0 - smoothstep(0.03, 0.2, H));
  float a = SPIKE_BASE * min(1.0, sqrt(H / 0.4));
  float sl0 = 1.25 * a / H;
  float scale = 1.0 / sqrt(1.0 + sl0 * sl0);
  if (y > H) {
    vec3 t = vec3(q.x, y - H, q.y);
    float l = max(length(t), 1.0e-5);
    g = t / l;
    return (l - rr) * scale;
  }
  float l = max(length(q), 1.0e-5);
  vec2 rad = q / l;
  if (y < -ROOT) {
    vec3 t = vec3(q.x, y + ROOT, q.y);
    float lt = max(length(t), 1.0e-5);
    g = t / lt;
    return (lt - rr) * scale;
  }
  if (y < 0.0) {
    float n = y / ROOT;
    float fall = 1.0 - n * n;
    float slope = -sl0 * fall - (a - sl0 * y) * 2.0 * y / (ROOT * ROOT);
    g = normalize(vec3(rad.x, -slope, rad.y));
    return (l - (a - sl0 * y) * fall - rr) * scale;
  }
  float w = 1.0 - u;
  float rw = sqrt(sqrt(w));
  float sl = 1.25 * a * rw / H;
  g = normalize(vec3(rad.x, sl - rad.x * 2.0 * c.z * u / H, rad.y));
  return (l - a * w * rw - rr) * scale;
}
vec3 normalAt(vec3 p) {
  float d = 9.0;
  vec3 g = vec3(0.0, 1.0, 0.0);
  if (uTrailOn > 0.5) d = trailG(p, g);
  for (int i = 0; i < uBlobs; i++) {
    vec4 b = uBlob[i];
    vec3 r = b.zyw;
    vec3 q = vec3(p.x - b.x, p.y + R_IN - b.y, p.z);
    sminG(d, g, ellipsoid(q, r), normalize(q / (r * r) + vec3(0.0, 1.0e-5, 0.0)), uBlend);
  }
  if (uCrownOn > 0.5) {
    float u = (p.x - uCellX0) / PITCH;
    float i0 = floor(u);
    float i1 = floor(u - 0.5);
    int side = p.z >= 0.0 ? 2 : 0;
    float cz = p.z >= 0.0 ? ROW_Z : -ROW_Z;
    vec3 gs, gt;
    float s = spikeG(p, 1, i0, 0.0, 0.0, gs);
    float t = spikeG(p, 1, i0 + 1.0, 0.0, 0.0, gt);
    sminG(s, gs, t, gt, 0.05);
    t = spikeG(p, side, i1, 0.5, cz, gt);
    sminG(s, gs, t, gt, 0.05);
    t = spikeG(p, side, i1 + 1.0, 0.5, cz, gt);
    sminG(s, gs, t, gt, 0.05);
    sminG(d, g, s, gs, 0.15);
  }
  return normalize(g);
}

// ---- 管:胴と両端の球
vec2 capHit(vec3 ro, vec3 rd, float r) {
  float tn = 1.0e9;
  float tf = -1.0e9;
  float a = dot(rd.yz, rd.yz);
  float b = dot(ro.yz, rd.yz);
  float c = dot(ro.yz, ro.yz) - r * r;
  float h = b * b - a * c;
  if (h > 0.0 && a > 1.0e-7) {
    h = sqrt(h);
    float t0 = (-b - h) / a;
    float t1 = (-b + h) / a;
    float rx = abs(rd.x) < 1.0e-6 ? 1.0e-6 : rd.x;
    float sa = (-uL - ro.x) / rx;
    float sb = (uL - ro.x) / rx;
    t0 = max(t0, min(sa, sb));
    t1 = min(t1, max(sa, sb));
    if (t0 <= t1) { tn = t0; tf = t1; }
  }
  for (int i = 0; i < 2; i++) {
    vec3 oc = ro - vec3(i == 0 ? -uL : uL, 0.0, 0.0);
    float bb = dot(oc, rd);
    float hh = bb * bb - dot(oc, oc) + r * r;
    if (hh > 0.0) {
      hh = sqrt(hh);
      tn = min(tn, -bb - hh);
      tf = max(tf, -bb + hh);
    }
  }
  return vec2(tn, tf);
}
vec3 capNormal(vec3 p) {
  vec3 q = p;
  q.x -= clamp(p.x, -uL, uL);
  return normalize(q);
}
float schlick(float c, float f0) {
  return f0 + (1.0 - f0) * pow(clamp(1.0 - c, 0.0, 1.0), 5.0);
}

// ---- 部屋の光
float box(vec2 p, vec2 c, vec2 h, float s) {
  vec2 q = abs(p - c) - h;
  float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
  return 1.0 - smoothstep(-s, s, d);
}
float strip(vec2 q, vec2 c, vec2 h, vec2 s) {
  vec2 d = abs(q - c) - h;
  return (1.0 - smoothstep(-s.x, s.x, d.x)) * (1.0 - smoothstep(-s.y, s.y, d.y));
}
vec3 env(vec3 d, float b) {
  float back = 1.0 - smoothstep(-0.6, 0.35, d.z);
  vec3 c = uBackdrop * back * 0.72 + uLight * 0.006;
  float fl = 1.0 - smoothstep(-0.03 - b * 2.5, 0.03 + b * 2.5, d.y);
  if (fl > 0.0) {
    float toward = smoothstep(-0.95, -0.06, d.y);
    vec3 ground = uBackdrop * (0.03 + 0.5 * toward * toward);
    c = mix(c, ground, fl);
  }
  if (d.y > 0.02) {
    vec2 q = d.xz / d.y;
    c += uLight * 11.0 * box(q, vec2(0.0, 0.16), vec2(3.8, 0.085), 0.05 + b * 2.0);
  }
  if (d.z > 0.02) {
    vec2 q = d.xy / d.z;
    vec2 g = abs(q - vec2(-0.92, 0.98));
    float m = smoothstep(0.012, 0.024 + b, g.x) * smoothstep(0.012, 0.024 + b, g.y);
    c += uLight * 24.0 * box(q, vec2(-0.92, 0.98), vec2(0.4, 0.52), 0.014 + b) * mix(1.0, m, 0.9);
    c += uCool * 46.0 * strip(q, vec2(-1.75, 0.42), vec2(0.03, 0.95), vec2(0.016 + b, 0.7 + b));
    c += uLight * 38.0 * strip(q, vec2(1.42, 0.5), vec2(0.04, 1.0), vec2(0.018 + b, 0.7 + b));
  }
  if (d.z < -0.02) {
    vec2 q = d.xy / -d.z;
    c += uLight * 9.0 * strip(q, vec2(0.0, 0.92), vec2(2.4, 0.03), vec2(1.0 + b, 0.014 + b));
    c += uAmber * 7.0 * strip(q, vec2(1.6, 0.3), vec2(0.05, 0.6), vec2(0.03 + b, 0.5 + b));
  }
  return c;
}
vec3 behind(vec3 p, vec3 d) {
  if (d.y < -0.002) {
    float t = (-1.0 - p.y) / d.y;
    return floorAt(p.xz + d.xz * t) * (1.0 - 0.62 * smoothstep(0.3, 0.85, -d.y));
  }
  return uBackdrop * (1.0 - 0.86 * smoothstep(0.1, 0.6, d.y));
}

// ---- 流体を追う
bool traceFluid(vec3 ro, vec3 rd, float tEnd, out vec3 hit, out float cov) {
  cov = 0.0;
  hit = ro;
  float xa = ro.x;
  float xb = ro.x + rd.x * tEnd;
  if (max(xa, xb) < uBound.x || min(xa, xb) > uBound.y) return false;
  float xm = 0.5 * (xa + xb);
  float topY = (xm > uTall.x && xm < uTall.y) ? uTall.z : uBound.z;
  float t = 0.0;
  if (ro.y > topY) {
    if (rd.y >= 0.0) return false;
    t = (topY - ro.y) / rd.y;
    if (t >= tEnd) return false;
  }
  float best = 1.0e9;
  float tb = t;
  for (int i = 0; i < STEPS; i++) {
    float d = map(ro + rd * t);
    float r = d / uPixel;
    if (r < best) { best = r; tb = t; }
    if (r < 0.25) { best = 0.0; break; }
    t += d * 0.9;
    if (t > tEnd) break;
  }
  cov = 1.0 - smoothstep(0.25, 1.25, best);
  hit = ro + rd * tb;
  return cov > 0.0;
}
float reflectShade(vec3 p, vec3 R) {
  float o = 1.0;
  float t = 0.07;
  for (int i = 0; i < 10; i++) {
    float d = map(p + R * t);
    o = min(o, d / (t * 0.2 + 0.004));
    if (o < 0.02) break;
    t += clamp(d, 0.04, 0.34);
    if (t > 1.8) break;
  }
  o = clamp(o, 0.0, 1.0);
  return o * o * (3.0 - 2.0 * o);
}
vec3 shadeFluid(vec3 p, vec3 rd, vec3 N, vec3 R, vec3 rx, vec3 ry, float blur) {
  float ndv = clamp(dot(N, -rd), 0.0, 1.0);
  float f = 0.042 + 0.46 * pow(1.0 - ndv, 5.0);
  // 1画素に映る範囲を4点で拾う。細い光が点線にならない
  vec3 L = env(normalize(R - 0.375 * rx - 0.125 * ry), blur);
  L += env(normalize(R + 0.125 * rx - 0.375 * ry), blur);
  L += env(normalize(R + 0.375 * rx + 0.125 * ry), blur);
  L += env(normalize(R - 0.125 * rx + 0.375 * ry), blur);
  L *= 0.25;
  float occ = 1.0;
  if (uCrownOn > 0.5 && p.x > uCrown.x - 0.2 && p.x < uCrown.y + 0.2) occ = reflectShade(p + N * 0.02, R);
  vec3 c = L * f * occ;
  c += uInk * (0.25 + 0.5 * max(N.y, 0.0));
  c += uAmber * pow(1.0 - ndv, 3.0) * 0.022;
  return c;
}

void main() {
  tipR = max(0.0065, uPixel * 0.62);
  vec2 px = uOrigin + gl_FragCoord.xy / uScale;
  vec2 v = px - uCenter;
  vec3 rd = normalize(uRight * v.x + uUp * v.y + uFwd * uFocal);

  // 外の輪郭:線で切らず、1画素ぶんだけ薄くする
  float tc = -dot(uRo.yz, rd.yz) / dot(rd.yz, rd.yz);
  vec3 pc = uRo + rd * tc;
  vec3 axis = vec3(clamp(pc.x, -uL, uL), 0.0, 0.0);
  if (abs(pc.x) > uL) {
    tc = dot(axis - uRo, rd);
    pc = uRo + rd * tc;
  }
  float dist = length(pc - axis);
  float cover = clamp((1.0 - dist) / uPixel + 0.5, 0.0, 1.0);
  if (cover <= 0.0) { outColor = vec4(0.0); return; }
  if (dist > 0.9992) {
    vec3 onEdge = axis + (pc - axis) / dist * 0.9992;
    rd = normalize(onEdge - uRo);
  }

  vec3 start = uRo + rd * ((uRo.z - 1.6) / -rd.z);
  vec2 ho = capHit(start, rd, 1.0);
  if (ho.x > ho.y) { outColor = vec4(0.0); return; }
  vec3 P0 = start + rd * ho.x;
  vec3 N0 = capNormal(P0);
  float c0 = clamp(dot(-rd, N0), 0.0, 1.0);
  float F0 = schlick(c0, 0.04);
  vec3 R0 = reflect(rd, N0);
  vec3 T1 = refract(rd, N0, 1.0 / 1.5);

  vec3 inner = vec3(0.0);
  vec3 fluidN = vec3(0.0);
  vec3 fluidR = vec3(0.0);
  vec3 fluidP = vec3(0.0);
  float fluidCov = 0.0;
  vec3 fluidRd = rd;
  vec3 through = vec3(1.0);

  vec2 hi = capHit(P0 + T1 * 1.0e-3, T1, R_IN);
  vec3 T2 = vec3(0.0);
  vec3 N1 = vec3(0.0);
  float Fi = 0.0;
  if (hi.x < hi.y && hi.x > 0.0) {
    vec3 P1 = P0 + T1 * hi.x;
    N1 = capNormal(P1);
    T2 = refract(T1, N1, 1.5);
  }
  bool inside = dot(T2, T2) > 0.5;
  vec3 exitP = P0;
  vec3 exitD = rd;
  float wallPath = 0.0;

  if (inside) {
    vec3 P1 = P0 + T1 * hi.x;
    wallPath = hi.x;
    Fi = schlick(clamp(dot(T2, -N1), 0.0, 1.0), 0.04);
    float far = max(capHit(P1 + T2 * 1.0e-3, T2, R_IN).y, 0.0);
    vec3 hit;
    float cov;
    if (traceFluid(P1 + T2 * 1.0e-3, T2, far, hit, cov)) {
      fluidCov = cov;
      fluidP = hit;
      fluidRd = T2;
      fluidN = normalAt(hit);
      fluidR = reflect(T2, fluidN);
    }
    // 流体の向こう:奥の壁を通って、外へ
    vec3 P2 = P1 + T2 * far;
    vec3 N2 = capNormal(P2);
    vec3 T3 = refract(T2, -N2, 1.0 / 1.5);
    float out3 = max(capHit(P2 + T3 * 1.0e-3, T3, 1.0).y, 0.0);
    vec3 P3 = P2 + T3 * out3;
    vec3 N3 = capNormal(P3);
    vec3 T4 = refract(T3, -N3, 1.5);
    if (dot(T4, T4) < 0.5) T4 = reflect(T3, -N3);
    wallPath += out3;
    exitP = P3;
    exitD = T4;
    float Fb = schlick(clamp(dot(T2, N2), 0.0, 1.0), 0.04);
    inner += env(reflect(T2, -N2), 0.03) * Fb * 0.5 * (1.0 - fluidCov);
    through *= (1.0 - Fb) * (1.0 - Fb);
    through *= 1.0 - Fi;
  } else {
    // 肉の中だけを通る光:曲がりが大きく、縁の暗い線になる
    float far = max(capHit(P0 + T1 * 1.0e-3, T1, 1.0).y, 0.0);
    vec3 P3 = P0 + T1 * far;
    vec3 N3 = capNormal(P3);
    vec3 T4 = refract(T1, -N3, 1.5);
    if (dot(T4, T4) < 0.5) T4 = reflect(T1, -N3);
    wallPath = far;
    exitP = P3;
    exitD = T4;
  }

  vec3 rx = dFdx(fluidR);
  vec3 ry = dFdy(fluidR);
  float foot = max(length(rx), length(ry));
  // となりの画素が流体でないときの差は使わない
  if (foot > 0.9) { rx = vec3(0.0); ry = vec3(0.0); foot = 0.5; }
  float blurF = clamp(foot * 0.14, 0.002, 0.2);

  vec3 glassT = exp(-uGlassAbs * clamp(wallPath, 0.0, 3.0));
  vec3 c = behind(exitP, exitD) * through * glassT;
  if (fluidCov > 0.0) {
    vec3 f = shadeFluid(fluidP, fluidRd, fluidN, fluidR, rx, ry, blurF);
    c = mix(c, f * exp(-uGlassAbs * hi.x), fluidCov);
  }
  if (inside) c += env(reflect(T1, N1), 0.03) * Fi * 0.55;
  c = c * (1.0 - F0) + inner * (1.0 - F0) + env(R0, 0.03) * F0;

  // 目盛り:ガラスの面に細く彫ってある
  if (uTick.z > 0.0 && P0.z > 0.0) {
    float f = (P0.x - uTick.x) / uTick.y;
    float n = clamp(floor(f + 0.5), 0.0, 50.0);
    float dx = abs(P0.x - (uTick.x + n * uTick.y));
    float tall = mod(n, 5.0) < 0.5 ? 1.0 : 0.0;
    float bandTop = mix(0.555, 0.63, tall);
    float hw = max(0.0055, uPixel * 0.55);
    float line = (1.0 - smoothstep(hw - uPixel * 0.5, hw + uPixel * 0.5, dx)) * min(1.0, 0.011 / (hw * 2.0));
    line *= smoothstep(0.47 - uPixel, 0.47 + uPixel, P0.y) * (1.0 - smoothstep(bandTop - uPixel, bandTop + uPixel, P0.y));
    c = mix(c, uBackdrop * 0.34 + uLight * 0.1, line * uTick.z * mix(0.55, 0.85, tall));
  }

  // ぼかしの段へ、壊れた値を流さない
  if (any(isnan(c)) || any(isinf(c))) c = vec3(0.0);
  outColor = vec4(min(c, vec3(64.0)) * cover, cover);
}`;

const COMPOSE_FRAG = `#version 300 es
precision highp float;
${FLOOR}
uniform sampler2D uScene;
uniform vec4 uRect;
uniform vec2 uCenter;
uniform float uFocal, uSUnit, uContact, uRootEnc, uMaxLod, uKnee, uBottom, uViewW;
uniform vec3 uRo, uRight, uUp, uFwd, uBgGamma;
out vec4 outColor;

vec3 enc(vec3 c) {
  vec3 lo = c * 12.92;
  vec3 hi = 1.055 * pow(max(c, vec3(1.0e-5)), vec3(1.0 / 2.4)) - 0.055;
  return mix(lo, hi, step(vec3(0.0031308), c));
}
vec3 tone(vec3 c) {
  float k = uKnee;
  vec3 over = k + (1.0 - k) * (1.0 - exp(-(c - k) / max(1.0 - k, 1.0e-4)));
  return mix(c, over, step(vec3(k), c));
}
vec4 scene(vec2 uv, float lod) {
  vec4 s = textureLod(uScene, uv, lod);
  if (uRootEnc > 0.5) s.rgb *= s.rgb;
  return s;
}
float maxc(vec3 v) { return max(v.r, max(v.g, v.b)); }
float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }

void main() {
  vec2 px = gl_FragCoord.xy;
  vec2 v = px - uCenter;
  vec3 rd = normalize(uRight * v.x + uUp * v.y + uFwd * uFocal);

  vec3 c = uBackdrop;
  if (rd.y < -1.0e-3) {
    float t = (-1.0 - uRo.y) / rd.y;
    c = floorAt(uRo.xz + rd.xz * t);
    // 磨かれた床の映り込み:触れている線で折り返し、遠いほどぼやけて薄くなる
    float below = (uContact - px.y) / uSUnit;
    if (below > 0.0) {
      vec2 m = vec2(px.x, uContact + (uContact - px.y));
      vec2 uv = (m - uRect.xy) / uRect.zw;
      float lod = min(uMaxLod, log2(1.0 + below * uSUnit * 0.09));
      vec4 r = scene(uv, lod);
      float inX = smoothstep(0.0, 0.035, uv.x) * (1.0 - smoothstep(0.965, 1.0, uv.x));
      float k = 0.34 * inX * exp(-below * 2.3) * (1.0 - smoothstep(0.6, 1.05, below));
      c = c * (1.0 - r.a * k) + r.rgb * k;
    }
  }

  vec2 uv = (px - uRect.xy) / uRect.zw;
  vec4 s = vec4(0.0);
  vec3 glow = vec3(0.0);
  if (all(greaterThan(uv, vec2(-0.05))) && all(lessThan(uv, vec2(1.05)))) {
    s = scene(uv, 0.0);
    // 強い光のにじみ
    vec3 g1 = scene(uv, 2.0).rgb;
    vec3 g2 = scene(uv, 3.6).rgb;
    glow = max(g1 - 1.0, vec3(0.0)) * 0.22 + max(g2 - 0.96, vec3(0.0)) * 0.34;
  }
  vec3 hdr = c * (1.0 - s.a) + s.rgb + glow;
  vec3 T = enc(tone(hdr));

  // ページの地の上に重ねて、同じ色になる透明度を求める。地が違うページでも、影は影のまま重なる
  vec3 B = uBgGamma;
  vec3 dark = (B - T) / max(B, vec3(1.0e-3));
  vec3 lite = (T - B) / max(vec3(1.0) - B, vec3(1.0e-3));
  float a = clamp(max(maxc(dark), maxc(lite)), 0.0, 1.0);
  float margin = uSUnit * 0.22;
  a *= smoothstep(0.0, margin, px.x) * smoothstep(0.0, margin, uViewW - px.x) * smoothstep(uBottom, uBottom + margin * 2.0, px.y);
  a = max(a, s.a);
  float noise = (ign(px) + ign(px + 17.0) - 1.0) / 255.0;
  T += noise * min(a * 60.0, 1.0);
  // 薄くした分は、地の色へ近づける
  outColor = vec4(clamp(T - (1.0 - a) * B, vec3(0.0), vec3(a)), a);
}`;

function mount(root) {
  const cleanups = [];
  const on = (target, type, fn, options) => {
    target.addEventListener(type, fn, options);
    cleanups.push(() => target.removeEventListener(type, fn, options));
  };

  const canvas = root.querySelector('.scene');
  const bar = root.querySelector('[data-bar]');
  const stateEl = root.querySelector('[data-state]');
  const valueEl = root.querySelector('[data-value]');
  const readout = root.querySelector('.readout');
  const again = root.querySelector('[data-again]');
  const probe = root.querySelector('.probe');
  const ownerDoc = root.ownerDocument;
  const motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
  const schemeQuery = matchMedia('(prefers-color-scheme: dark)');
  let calm = motionQuery.matches;

  // ---- 大きさ
  let L = 5.2;
  let texX = L + 1.02;
  let texDx = (2 * texX) / TEX;
  let cols = MAX_COLS;
  let cellX0 = 0;
  let unit = 100; // canvas の1px を単位にした、管の軸での1の長さ
  const camera = { ro: [0, 0, 0], right: [1, 0, 0], up: [0, 1, 0], fwd: [0, 0, -1], focal: 1, cx: 0, cy: 0 };
  const rect = { x: 0, y: 0, w: 1, h: 1 };
  let contactY = 0;
  let floorBottom = 0;
  let viewW = 1;
  let viewH = 1;

  // ---- 流体の状態
  let phase = 'run'; // run → gather → rest → return → run
  let clock = 0;
  let p = 0;
  let shownValue = -1;
  let finished = false;
  let wob = 0;
  let wobV = 0;
  let wobStarted = false;
  let magnet = 0;
  let xm = 0;
  let vm = 0;
  let sigma = 1;
  let blend = 0.24;
  let blobCount = 1;
  const blobs = new Float32Array(MAX_BLOBS * 4);
  const trail = { on: false, xL: 0, xR: 0, capL: 0.34, capR: 0.3, h: 0.2, swell: 0.055, head: 0, pinch: 0, phase: 0 };
  const sats = [];

  const cells = new Float32Array(MAX_COLS * ROWS * 4);
  const cellVel = new Float32Array(MAX_COLS * ROWS * 2);
  const cellSeed = new Float32Array(MAX_COLS * ROWS * 3);
  {
    const r = seeded(4111);
    for (let i = 0; i < cellSeed.length; i++) cellSeed[i] = r();
  }
  const crown = [0, 0, 0, 0];
  let crownOn = 0;
  const tall = [0, 0, 0];
  const bound = [0, 0, 0];

  const eta = new Float32Array(TEX);
  const etaV = new Float32Array(TEX);
  const heights = new Float32Array(TEX);
  const density = new Float32Array(TEX);
  const blurA = new Float32Array(TEX);
  const blurB = new Float32Array(TEX);
  const profile = new Float32Array(TEX * 4);

  // 頭の塊の形。磁石に引かれて、平たく長くなる
  const headAt = (v) => {
    const rx0 = 1.3;
    const rx = mix(rx0, 1.02, v);
    const front = mix(-L - 0.72 + 2 * rx0, L + 0.66, v);
    return { x: front - rx, rx, ry: mix(0.5, 0.31, v), rz: mix(0.68, 0.54, v) };
  };
  const frontAt = (v) => mix(-L - 0.72 + 2 * 1.3, L + 0.66, v);

  const setBlob = (i, x, rx, ry, rz) => {
    blobs[i * 4] = x;
    blobs[i * 4 + 1] = ry;
    blobs[i * 4 + 2] = rx;
    blobs[i * 4 + 3] = rz;
  };

  // ---- 状態ごとの形
  const poseRun = (v) => {
    const head = headAt(v);
    setBlob(0, head.x, head.rx, head.ry, head.rz);
    blobCount = 1;
    blend = 0.36;
    trail.on = true;
    trail.xL = -L - 0.2;
    trail.xR = head.x;
    trail.capL = 0.34;
    trail.capR = 0.3;
    trail.h = mix(0.205, 0.145, v);
    trail.swell = 0.11;
    trail.head = head.x;
    trail.pinch = 0;
    sigma = mix(1.12, 0.9, v);
    return { magnetAt: head.x + 0.12, strength: 1 };
  };

  const poseGather = (t) => {
    const end = headAt(1);
    const tau = clamp((t - GATHER) / PULL);
    const e = Math.pow(tau, 1.5);
    const grow = Math.pow(e, 0.75);
    const V = BALL * BALL * BALL;
    const rightEnd = Math.cbrt(0.6 * V);
    const leftEnd = Math.cbrt(0.4 * V);
    let rR = Math.cbrt(mix(0.125, 0.6 * V, grow));
    let rL = Math.cbrt(mix(0.0028, 0.4 * V, grow));
    let xR = mix(end.x, (rightEnd + leftEnd) * 0.4, e);
    let xL = mix(-L - 0.04, -(rightEnd + leftEnd) * 0.6, e);
    const merge = smoothstep(0, 1, (t - GATHER - PULL) / MERGE);
    rR = mix(rR, BALL, merge);
    rL = mix(rL, BALL, merge);
    xR = mix(xR, 0, merge);
    xL = mix(xL, 0, merge);
    if (merge > 0 && !wobStarted) {
      wobStarted = true;
      wobV = 1.5;
    }
    const relax = smoothstep(0, 0.55, t);
    const sx = 1 + wob;
    const syz = 1 - 0.5 * wob;
    setBlob(0, xR, mix(end.rx, rR, relax) * sx, mix(end.ry, rR, relax) * syz, mix(end.rz, rR, relax) * syz);
    setBlob(1, xL, rL * sx, rL * syz, rL * syz);
    blend = mix(0.3, 0.0012, merge);
    blobCount = merge >= 1 ? 1 : 2;

    trail.on = e < 0.94;
    trail.xL = xL;
    trail.xR = xR;
    trail.capL = 0.2;
    trail.capR = 0.2;
    trail.h = 0.145 * (1 - 0.8 * smoothstep(0.2, 0.7, e)) * (1 - smoothstep(0.7, 0.94, e));
    trail.swell = 0;
    trail.head = xR;
    trail.pinch = 0.5 * smoothstep(0.12, 0.5, e);

    // 細くなった糸がちぎれて、小さな玉が残る
    let n = 2;
    for (const s of sats) {
      const born = smoothstep(s.at, s.at + 0.1, e);
      const toward = s.x0 < 0 ? 1 : -1;
      const roll = Math.max(0, tau - s.tau);
      const x = s.x0 * (1 - e) * 0.18 + s.x0 * 0.82 * (1 - e) + toward * roll * roll * 0.5;
      const swallowed = merge > 0 || x < xL + rL * 0.8 || x > xR - rR * 0.8;
      const r = s.r * born * (swallowed ? 0 : 1);
      if (r > 0.004 && n < MAX_BLOBS) {
        setBlob(n, x, r, r, r);
        n += 1;
      }
    }
    if (merge < 1) blobCount = n;
    return { magnetAt: xR, strength: 0 };
  };

  const poseReturn = (t) => {
    const u = clamp(t / RETURN_TIME);
    const e = u * u * (3 - 2 * u);
    const head = headAt(0);
    const stretch = 1 + 0.26 * Math.sin(Math.PI * u);
    const x = mix(0, head.x, e);
    setBlob(0, x, mix(BALL, head.rx, e) * stretch, mix(BALL, head.ry, e) / Math.sqrt(stretch), mix(BALL, head.rz, e) / Math.sqrt(stretch));
    blobCount = 1;
    blend = 0.24;
    trail.on = false;
    sigma = 1;
    return { magnetAt: x + 0.12, strength: smoothstep(0.62, 1, u) };
  };

  // ---- 1コマごとの計算
  const bodyTop = (cx, cz) => {
    let top = -9;
    for (let i = 0; i < blobCount; i++) {
      const rx = blobs[i * 4 + 2];
      const ry = blobs[i * 4 + 1];
      const rz = blobs[i * 4 + 3];
      const f = 1 - sq((cx - blobs[i * 4]) / rx) - sq(cz / rz);
      if (f > 0) top = Math.max(top, -R_IN + ry + ry * Math.sqrt(f));
    }
    if (trail.on && cx > trail.xL && cx < trail.xR) {
      const h = trail.h;
      const w = Math.sqrt(WIDTH_K * h) * (1 - Math.exp(-h / 0.06));
      const f = 1 - sq(cz / w);
      if (f > 0) top = Math.max(top, -R_IN + h + h * Math.sqrt(f));
    }
    return top;
  };

  const footprint = (cx, cz) => {
    let best = 0;
    for (let i = 0; i < blobCount; i++) {
      const f = 1 - sq((Math.abs(cx - blobs[i * 4]) + SPIKE_R) / blobs[i * 4 + 2]) - sq((Math.abs(cz) + SPIKE_R) / blobs[i * 4 + 3]);
      if (f > best) best = f;
    }
    return smoothstep(0, 0.24, best);
  };
  const lowestTop = (cx, cz, top) => {
    let low = top;
    const a = bodyTop(cx - SPIKE_R, cz);
    const b = bodyTop(cx + SPIKE_R, cz);
    const c = bodyTop(cx, cz - SPIKE_R);
    const d = bodyTop(cx, cz + SPIKE_R);
    if (a < -8 || b < -8 || c < -8 || d < -8) return top - 0.08;
    low = Math.min(low, a, b, c, d);
    return low - 0.035;
  };

  const stepCells = (dt, snap, speed) => {
    const time = performance.now() * 0.001;
    for (let row = 0; row < ROWS; row++) {
      const off = row === 1 ? 0 : 0.5;
      const cz = (row - 1) * ROW_Z;
      for (let col = 0; col < cols; col++) {
        const id = row * MAX_COLS + col;
        const a = id * 4;
        const b = id * 2;
        const s1 = cellSeed[id * 3];
        const s2 = cellSeed[id * 3 + 1];
        const s3 = cellSeed[id * 3 + 2];
        const jitter = (s1 - 0.5) * 0.04;
        const cx = cellX0 + (col + off) * PITCH + jitter;
        const base = bodyTop(cx, cz);
        let target = 0;
        const dx = cx - xm;
        if (magnet > 0.001 && base > -8) {
          const envelope = Math.exp(-sq(dx / sigma)) * Math.exp(-sq(cz / 1.25));
          const ax = Math.max(Math.abs(cx) - L, 0);
          const ceiling2 = R_IN * R_IN - ax * ax - cz * cz;
          if (ceiling2 > 0) {
            const ceiling = Math.sqrt(ceiling2) - 0.24;
            const tip = -R_IN + (ceiling + R_IN) * (0.2 + 0.8 * envelope) * (0.92 + 0.08 * s2);
            const tremble = calm ? 1 : 1 + 0.012 * Math.sin(time * (2.2 + s3 * 3) + s1 * 20);
            const raw = Math.max(0, tip - base);
            target = raw * smoothstep(0.05, 0.26, raw) * magnet * footprint(cx, cz) * tremble;
          }
        }
        const leanTarget = clamp(-dx * 0.17, -0.2, 0.2) * clamp(target, 0, 1) + clamp(speed * 0.1, -0.16, 0.16) * clamp(target, 0, 1);
        if (base < -8) {
          cells[a + 1] *= Math.exp(-dt * 40);
          cellVel[b] = 0;
        }
        if (snap) {
          cells[a + 1] = target;
          cells[a + 2] = leanTarget;
          cellVel[b] = 0;
          cellVel[b + 1] = 0;
        } else {
          const stiff = 62 + s2 * 26;
          const damp = 5.4 + s3 * 2.2;
          cellVel[b] += (stiff * (target - cells[a + 1]) - damp * cellVel[b]) * dt;
          cells[a + 1] = Math.max(0, cells[a + 1] + cellVel[b] * dt);
          if (cells[a + 1] === 0 && cellVel[b] < 0) cellVel[b] = 0;
          cellVel[b + 1] += (46 * (leanTarget - cells[a + 2]) - 5.2 * cellVel[b + 1]) * dt;
          cells[a + 2] += cellVel[b + 1] * dt;
        }
        cells[a] = base > -8 ? lowestTop(cx, cz, base) : cells[a];
        cells[a + 3] = jitter;
      }
    }
  };

  const stepEta = (dt, speed) => {
    const K = sq(1.5 / texDx);
    for (let i = 1; i < TEX - 1; i++) {
      etaV[i] += (K * (eta[i - 1] + eta[i + 1] - 2 * eta[i]) - 6 * eta[i] - 1.25 * etaV[i]) * dt;
    }
    for (let i = 1; i < TEX - 1; i++) eta[i] += etaV[i] * dt;
    // 頭のそばを押して、さざ波を送る
    if (!calm && trail.on) {
      const at = Math.round((trail.head + texX) / texDx);
      const push = (Math.abs(speed) * 0.5 + 0.1 + Math.random() * 0.22) * (Math.random() < 0.5 ? -1 : 1);
      for (let i = -9; i <= 9; i++) {
        const j = at - 14 + i;
        if (j > 0 && j < TEX - 1) etaV[j] += push * 0.15 * Math.exp(-sq(i / 4.5));
      }
    }
  };

  let simLeft = 0;
  const simulate = (dt) => {
    simLeft = Math.min(simLeft + dt, 0.25);
    while (simLeft >= SIM_DT) {
      simLeft -= SIM_DT;
      clock += SIM_DT;
      let aim;
      if (phase === 'run') {
        p = progressAt(clock - HOLD);
        aim = poseRun(p);
        if (clock - HOLD >= RUN_TIME) {
          p = 1;
          phase = 'gather';
          clock = 0;
          wob = 0;
          wobV = 0;
          wobStarted = false;
          makeSats();
        }
      } else if (phase === 'gather') {
        aim = poseGather(clock);
        if (wobStarted) {
          wobV += (-260 * wob - 3.6 * wobV) * SIM_DT;
          wob += wobV * SIM_DT;
        }
        if (!finished && clock > GATHER + PULL + MERGE * 0.6) finish();
        if (clock > GATHER + PULL + MERGE && Math.abs(wob) + Math.abs(wobV) / 16 < 0.001) {
          phase = 'rest';
          clock = 0;
          wob = 0;
          wobV = 0;
        }
      } else if (phase === 'return') {
        aim = poseReturn(clock);
        if (clock >= RETURN_TIME) {
          phase = 'run';
          clock = 0;
        }
      } else {
        setBlob(0, 0, BALL, BALL, BALL);
        blobCount = 1;
        blend = 0.0012;
        trail.on = false;
        aim = { magnetAt: 0, strength: 0 };
      }
      const wasXm = xm;
      vm += (64 * (aim.magnetAt - xm) - 15 * vm) * SIM_DT;
      xm += vm * SIM_DT;
      magnet += (aim.strength - magnet) * (1 - Math.exp(-SIM_DT * (aim.strength > magnet ? 5 : 14)));
      const speed = (xm - wasXm) / SIM_DT;
      stepCells(SIM_DT, false, speed);
      stepEta(SIM_DT, speed);
    }
  };

  // 落ち着いた形へ直接置く(最初のコマと、動きを減らす設定のとき)
  const settle = () => {
    let aim;
    if (phase === 'run') {
      p = progressAt(clock - HOLD);
      aim = poseRun(p);
    } else if (phase === 'return') aim = poseReturn(clock);
    else {
      setBlob(0, 0, BALL, BALL, BALL);
      blobCount = 1;
      blend = 0.0012;
      trail.on = false;
      aim = { magnetAt: 0, strength: 0 };
    }
    xm = aim.magnetAt;
    vm = 0;
    magnet = aim.strength;
    stepCells(0, true, 0);
    eta.fill(0);
    etaV.fill(0);
  };

  function makeSats() {
    sats.length = 0;
    const r = seeded(Math.floor(Math.random() * 100000) + 7);
    const spots = [-0.62, -0.3, 0.26, 0.58];
    for (const spot of spots) {
      sats.push({
        x0: spot * L + (r() - 0.5) * 0.5,
        r: 0.045 + r() * 0.04,
        at: 0.58 + r() * 0.14,
        tau: 0.7,
      });
    }
  }

  // ---- 表へ書く
  const boxBlur = (src, dst, radius) => {
    const n = TEX;
    const size = 2 * radius + 1;
    let sum = 0;
    for (let i = -radius; i <= radius; i++) sum += src[clamp(i, 0, n - 1)];
    for (let i = 0; i < n; i++) {
      dst[i] = sum / size;
      sum += src[Math.min(n - 1, i + radius + 1)] - src[Math.max(0, i - radius)];
    }
  };

  const buildProfile = () => {
    const n = TEX;
    for (let i = 0; i < n; i++) {
      const x = -texX + (i + 0.5) * texDx;
      let h = 0;
      if (trail.on) {
        h = trail.h;
        if (trail.swell > 0) h += trail.swell * Math.exp(-sq((trail.head - x) / 1.55));
        if (trail.pinch > 0) h *= Math.max(0.02, 1 + trail.pinch * Math.sin(x * 5.1 + 0.6) * Math.sin(x * 2.3 + 1.7));
        h *= 1 + eta[i];
        h = Math.max(h, 0.004);
      }
      heights[i] = h;
      // 影の濃さ
      let d = 0;
      if (trail.on && x > trail.xL - trail.capL && x < trail.xR + trail.capR) d = h * 2.2;
      for (let b = 0; b < blobCount; b++) {
        const f = 1 - sq((x - blobs[b * 4]) / blobs[b * 4 + 2]);
        if (f > 0) d = Math.max(d, blobs[b * 4 + 1] * 1.5 * Math.sqrt(f));
      }
      density[i] = Math.min(1, d);
    }
    const radius = (units) => Math.max(1, Math.round(units / texDx));
    boxBlur(density, blurA, radius(0.14));
    boxBlur(blurA, blurB, radius(0.14));
    for (let i = 0; i < n; i++) profile[i * 4 + 2] = blurB[i];
    boxBlur(blurB, blurA, radius(0.5));
    boxBlur(blurA, blurB, radius(0.5));
    for (let i = 0; i < n; i++) {
      profile[i * 4] = heights[i];
      const a = heights[Math.max(0, i - 1)];
      const b = heights[Math.min(n - 1, i + 1)];
      profile[i * 4 + 1] = (b - a) / (2 * texDx);
      profile[i * 4 + 3] = blurB[i];
    }
  };

  const buildBounds = () => {
    // 流体の左右の端、高い場所、とげの箱。光線を追う範囲をここまで狭める
    let xa = 1e3;
    let xb = -1e3;
    let lowTop = -R_IN;
    let tallA = 1e3;
    let tallB = -1e3;
    let tallY = -R_IN;
    if (trail.on) {
      xa = trail.xL - 0.02;
      xb = trail.xR + 0.02;
      lowTop = -R_IN + 2 * (trail.h + trail.swell) * 1.25 + 0.02;
    }
    for (let i = 0; i < blobCount; i++) {
      const x = blobs[i * 4];
      const rx = blobs[i * 4 + 2];
      xa = Math.min(xa, x - rx - 0.04);
      xb = Math.max(xb, x + rx + 0.04);
      tallA = Math.min(tallA, x - rx - 0.04);
      tallB = Math.max(tallB, x + rx + 0.04);
      tallY = Math.max(tallY, -R_IN + 2 * blobs[i * 4 + 1] + 0.12);
    }
    let cA = 1e3;
    let cB = -1e3;
    let cY0 = 9;
    let cY1 = -9;
    crownOn = 0;
    for (let row = 0; row < ROWS; row++) {
      const off = row === 1 ? 0 : 0.5;
      for (let col = 0; col < cols; col++) {
        const a = (row * MAX_COLS + col) * 4;
        const H = cells[a + 1];
        if (H < 0.012) continue;
        crownOn = 1;
        const cx = cellX0 + (col + off) * PITCH + cells[a + 3];
        const lean = Math.abs(cells[a + 2]);
        cA = Math.min(cA, cx - 0.2 - lean);
        cB = Math.max(cB, cx + 0.2 + lean);
        cY0 = Math.min(cY0, cells[a] - ROOT - 0.01);
        cY1 = Math.max(cY1, cells[a] + H + 0.075);
      }
    }
    crown[0] = cA;
    crown[1] = cB;
    crown[2] = cY0;
    crown[3] = cY1;
    if (crownOn) {
      xa = Math.min(xa, cA - 0.05);
      xb = Math.max(xb, cB + 0.05);
      tallA = Math.min(tallA, cA - 0.05);
      tallB = Math.max(tallB, cB + 0.05);
      tallY = Math.max(tallY, cY1 + 0.04);
    }
    bound[0] = xa;
    bound[1] = xb;
    bound[2] = lowTop;
    tall[0] = tallA;
    tall[1] = tallB;
    tall[2] = tallY;
  };

  // ---- GL
  const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, powerPreference: 'high-performance' });
  let ready = false;
  let scene = null;
  let compose = null;
  let texProfile = null;
  let texScene = null;
  let fbo = null;
  let fboW = 0;
  let fboH = 0;
  let floatTarget = false;
  let quality = 1;
  let appliedQuality = 0;

  const compile = (type, source) => {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader) || 'shader');
    return shader;
  };
  const program = (fragSource) => {
    const prog = gl.createProgram();
    const vs = compile(gl.VERTEX_SHADER, VERT);
    const fs = compile(gl.FRAGMENT_SHADER, fragSource);
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    gl.deleteShader(vs);
    gl.deleteShader(fs);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'link');
    const u = {};
    const count = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS);
    for (let i = 0; i < count; i++) {
      const name = gl.getActiveUniform(prog, i).name.replace(/\[0\]$/, '');
      u[name] = gl.getUniformLocation(prog, name);
    }
    return { prog, u };
  };

  const initGl = () => {
    scene = program(SCENE_FRAG);
    compose = program(COMPOSE_FRAG);
    floatTarget = !!gl.getExtension('EXT_color_buffer_float');
    texProfile = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texProfile);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, TEX, 1, 0, gl.RGBA, gl.FLOAT, profile);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    texScene = gl.createTexture();
    fbo = gl.createFramebuffer();
    gl.disable(gl.BLEND);
    gl.disable(gl.DEPTH_TEST);
    fboW = 0;
    appliedQuality = 0;
    ready = true;
  };

  const ensureTarget = () => {
    const w = Math.max(2, Math.round(rect.w * quality));
    const h = Math.max(2, Math.round(rect.h * quality));
    if (w === fboW && h === fboH && quality === appliedQuality) return;
    fboW = w;
    fboH = h;
    appliedQuality = quality;
    const levels = Math.floor(Math.log2(Math.max(w, h))) + 1;
    gl.deleteTexture(texScene);
    texScene = gl.createTexture();
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, texScene);
    gl.texStorage2D(gl.TEXTURE_2D, levels, floatTarget ? gl.RGBA16F : gl.RGBA8, w, h);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texScene, 0);
    if (floatTarget && gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
      floatTarget = false;
      fboW = 0;
      ensureTarget();
    }
  };

  // ---- 色
  const colors = { bg: [1, 1, 1], bgGamma: [1, 1, 1], light: [1, 1, 1], ink: [0, 0, 0], amber: [1, 0.5, 0.2], cool: [1, 1, 1], abs: [0.2, 0.2, 0.2] };
  const paint = new OffscreenCanvas(1, 1).getContext('2d', { willReadFrequently: true });
  const readColor = (name) => {
    probe.style.color = getComputedStyle(root).getPropertyValue(`--_${name}`).trim();
    paint.clearRect(0, 0, 1, 1);
    paint.fillStyle = getComputedStyle(probe).color;
    paint.fillRect(0, 0, 1, 1);
    const d = paint.getImageData(0, 0, 1, 1).data;
    const gamma = [d[0] / 255, d[1] / 255, d[2] / 255];
    const linear = gamma.map(toLinear);
    return { gamma, linear, lab: rgbToOklab(linear[0], linear[1], linear[2]) };
  };
  const readPalette = () => {
    const bg = readColor('bg');
    const fg = readColor('fg');
    const surface = readColor('surface');
    const muted = readColor('muted');
    const accent = readColor('accent');
    colors.bg = bg.linear;
    colors.bgGamma = bg.gamma;
    colors.light = labTone(bg.lab, 0.995, 0.5);
    colors.ink = labTone(fg.lab, 0.14, 1);
    colors.amber = labTone(accent.lab, 0.72, 0.9);
    colors.cool = labTone(muted.lab, 0.97, 1.4);
    const peak = Math.max(...surface.linear, 1e-3);
    colors.abs = surface.linear.map((v) => 0.1 - 0.42 * Math.log(Math.max(v / peak, 0.05)));
  };

  // ---- 大きさを測る
  const measure = () => {
    const box = root.getBoundingClientRect();
    const cssW = Math.max(1, box.width);
    const cssH = Math.max(1, box.height);
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    viewW = Math.max(2, Math.round(cssW * dpr));
    viewH = Math.max(2, Math.round(cssH * dpr));
    if (canvas.width !== viewW) canvas.width = viewW;
    if (canvas.height !== viewH) canvas.height = viewH;

    // 狭い枠では管を短くして、太さを保つ
    const prevL = L;
    L = mix(3, 5.2, smoothstep(360, 1000, cssW));
    texX = L + 1.02;
    texDx = (2 * texX) / TEX;
    cols = Math.min(MAX_COLS, Math.ceil((2 * (L + 1)) / PITCH) + 2);
    cellX0 = -((cols - 1) / 2) * PITCH;

    const stageH = readout.offsetTop;
    const span = 1.92 + 0.2 + 1.0; // 管の高さ、上の余白、床
    const cssUnit = Math.min((cssW * 0.964) / (2 * (L + 1)), stageH / span);
    unit = cssUnit * dpr;
    const c = Math.cos(CAMERA_TILT);
    const s = Math.sin(CAMERA_TILT);
    camera.right = [1, 0, 0];
    camera.up = [0, c, -s];
    camera.fwd = [0, -s, -c];
    camera.ro = [0, CAMERA_DIST * s, CAMERA_DIST * c];
    camera.focal = unit * CAMERA_DIST;
    camera.cx = viewW / 2;
    // canvas の座標は下が 0
    const stageTopCss = Math.max(0, (stageH - span * cssUnit) * 0.5);
    camera.cy = viewH - (stageTopCss + (0.2 + 0.98) * cssUnit) * dpr;

    // 管の絵の範囲
    const halfW = (L + 1) * unit * 1.045 + 3;
    const halfH = 1.07 * unit + 3;
    rect.x = Math.max(0, Math.floor(camera.cx - halfW));
    rect.y = Math.max(0, Math.floor(camera.cy - halfH));
    rect.w = Math.min(viewW - rect.x, Math.ceil(halfW * 2));
    rect.h = Math.min(viewH - rect.y, Math.ceil(halfH * 2));

    // 触れている線
    const contact = project(0, -1, 0);
    contactY = contact[1];
    floorBottom = Math.max(0, contactY - 1.55 * unit);

    // 本物の進み具合の要素を、管の場所に置く
    const tubeLeft = (camera.cx - (L + 1) * unit) / dpr;
    const tubeTop = (viewH - (camera.cy + unit)) / dpr;
    bar.style.left = `${tubeLeft}px`;
    bar.style.top = `${tubeTop}px`;
    bar.style.width = `${(2 * (L + 1) * unit) / dpr}px`;
    bar.style.height = `${(2 * unit) / dpr}px`;

    if (prevL !== L) settle();
  };

  const project = (x, y, z) => {
    const d = [x - camera.ro[0], y - camera.ro[1], z - camera.ro[2]];
    const zv = d[0] * camera.fwd[0] + d[1] * camera.fwd[1] + d[2] * camera.fwd[2];
    const xv = d[0] * camera.right[0] + d[1] * camera.right[1] + d[2] * camera.right[2];
    const yv = d[0] * camera.up[0] + d[1] * camera.up[1] + d[2] * camera.up[2];
    return [camera.cx + (xv / zv) * camera.focal, camera.cy + (yv / zv) * camera.focal];
  };

  // ---- 描く
  const setFloorUniforms = (u) => {
    gl.uniform1f(u.uTexX, texX);
    gl.uniform1f(u.uTexK, 1 / (2 * texX));
    gl.uniform1f(u.uL, L);
    gl.uniform3fv(u.uBackdrop, colors.bg);
    gl.uniform3fv(u.uLight, colors.light);
    gl.uniform1i(u.uProfile, 0);
  };
  const setCamera = (u) => {
    gl.uniform3fv(u.uRo, camera.ro);
    gl.uniform3fv(u.uRight, camera.right);
    gl.uniform3fv(u.uUp, camera.up);
    gl.uniform3fv(u.uFwd, camera.fwd);
    gl.uniform1f(u.uFocal, camera.focal);
    gl.uniform2f(u.uCenter, camera.cx, camera.cy);
  };

  const render = () => {
    if (!ready) return;
    buildProfile();
    buildBounds();
    ensureTarget();

    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, null);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, texProfile);
    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, TEX, 1, gl.RGBA, gl.FLOAT, profile);

    // ---- 管の絵
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.viewport(0, 0, fboW, fboH);
    gl.disable(gl.SCISSOR_TEST);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.useProgram(scene.prog);
    const us = scene.u;
    setFloorUniforms(us);
    setCamera(us);
    gl.uniform2f(us.uOrigin, rect.x, rect.y);
    gl.uniform1f(us.uScale, fboW / rect.w);
    gl.uniform1f(us.uPixel, rect.w / fboW / unit);
    const xA = trail.xL + trail.capL;
    const xB = Math.max(xA, trail.xR - trail.capR);
    gl.uniform4f(us.uTrail, xA, xB, trail.capL, trail.capR);
    gl.uniform1f(us.uTrailOn, trail.on ? 1 : 0);
    gl.uniform1f(us.uBlend, Math.max(blend, 0.0012));
    gl.uniform1f(us.uCrownOn, crownOn);
    gl.uniform1f(us.uCellX0, cellX0);
    gl.uniform4fv(us.uBlob, blobs);
    gl.uniform1i(us.uBlobs, blobCount);
    gl.uniform4fv(us.uCell, cells);
    gl.uniform4fv(us.uCrown, crown);
    gl.uniform3fv(us.uTall, tall);
    gl.uniform3fv(us.uBound, bound);
    gl.uniform3fv(us.uInk, colors.ink);
    gl.uniform3fv(us.uAmber, colors.amber);
    gl.uniform3fv(us.uCool, colors.cool);
    gl.uniform3fv(us.uGlassAbs, colors.abs);
    const f0 = frontAt(0);
    const stepX = (frontAt(1) - f0) / 50;
    gl.uniform3f(us.uTick, f0, stepX, smoothstep(0.42, 0.9, (stepX * unit) / (window.devicePixelRatio > 1 ? 18 : 14)));
    gl.drawArrays(gl.TRIANGLES, 0, 3);

    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, texScene);
    gl.generateMipmap(gl.TEXTURE_2D);

    // ---- 床と合成
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, viewW, viewH);
    gl.disable(gl.SCISSOR_TEST);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.SCISSOR_TEST);
    const sy = Math.floor(floorBottom);
    gl.scissor(0, sy, viewW, Math.min(viewH, Math.ceil(rect.y + rect.h + 2)) - sy);
    gl.useProgram(compose.prog);
    const uc = compose.u;
    setFloorUniforms(uc);
    setCamera(uc);
    gl.uniform1i(uc.uScene, 1);
    gl.uniform4f(uc.uRect, rect.x, rect.y, rect.w, rect.h);
    gl.uniform1f(uc.uSUnit, unit);
    gl.uniform1f(uc.uContact, contactY);
    gl.uniform1f(uc.uRootEnc, floatTarget ? 0 : 1);
    gl.uniform1f(uc.uMaxLod, 6);
    gl.uniform1f(uc.uKnee, Math.min(1, Math.max(0.94, ...colors.bg)));
    gl.uniform1f(uc.uBottom, floorBottom);
    gl.uniform1f(uc.uViewW, viewW);
    gl.uniform3fv(uc.uBgGamma, colors.bgGamma);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    gl.disable(gl.SCISSOR_TEST);
  };

  // ---- 文字
  const showValue = () => {
    const n = phase === 'run' || phase === 'return' ? Math.floor(p * 100) : 100;
    if (n === shownValue) return;
    shownValue = n;
    valueEl.textContent = String(n);
    bar.setAttribute('aria-valuenow', String(n));
    bar.setAttribute('aria-valuetext', `${n}%`);
    bar.style.setProperty('--p', String(n / 100));
  };

  function finish() {
    finished = true;
    stateEl.textContent = '完了しました';
    root.setAttribute('data-done', '');
    if (root.getRootNode().activeElement === bar) again.focus({ preventScroll: true });
  }

  const restart = () => {
    if (!finished) return;
    finished = false;
    stateEl.textContent = 'アップロード中';
    root.removeAttribute('data-done');
    const moveFocus = root.getRootNode().activeElement === again;
    if (moveFocus) bar.focus({ preventScroll: true });
    p = 0;
    phase = 'return';
    clock = 0;
    showValue();
    if (calm) {
      phase = 'run';
      clock = 0;
      settle();
      stepCalm();
      paint1();
    } else {
      wake();
    }
  };

  // ---- ループ
  let raf = 0;
  let last = 0;
  let visible = true;
  let calmTimer = 0;
  let perfT = 0;
  let perfN = 0;
  let perfGood = 0;

  const isOn = () => visible && !ownerDoc.hidden;

  const paint1 = () => {
    showValue();
    render();
  };

  const frame = (now) => {
    raf = 0;
    if (!isOn()) return;
    const ms = last ? now - last : 16.7;
    last = now;
    const dt = Math.min(ms / 1000, 0.25);
    simulate(dt);
    if (phase === 'rest') {
      // 動きが止まった:一番きれいな1枚を描いて、休む
      quality = 1;
      paint1();
      last = 0;
      return;
    }
    // 重いときは、管の絵だけ解像度を下げて 60fps を守る
    perfT += ms;
    perfN += 1;
    if (perfN >= 30) {
      const avg = perfT / perfN;
      perfT = 0;
      perfN = 0;
      if (avg > 21 && quality > 0.5) {
        quality = Math.max(0.5, quality * 0.86);
        perfGood = 0;
      } else if (avg < 17.6 && quality < 1) {
        perfGood += 1;
        if (perfGood >= 4) {
          quality = Math.min(1, quality * 1.08);
          perfGood = 0;
        }
      }
    }
    paint1();
    raf = requestAnimationFrame(frame);
  };

  function wake() {
    if (raf || !ready || calm || !isOn() || phase === 'rest') return;
    last = 0;
    raf = requestAnimationFrame(frame);
  }

  // 動きを減らす設定:動かさず、進み具合の段ごとに形を置き直すだけ
  function stepCalm() {
    clearTimeout(calmTimer);
    if (!calm || phase !== 'run' || !isOn()) return;
    calmTimer = setTimeout(() => {
      clock += 1;
      if (clock - HOLD >= RUN_TIME) {
        p = 1;
        phase = 'rest';
        clock = 0;
        finish();
      }
      settle();
      paint1();
      stepCalm();
    }, 1000);
  }

  const redraw = () => {
    if (!isOn() && !ready) return;
    if (calm || phase === 'rest') paint1();
  };

  const applyCalm = () => {
    calm = motionQuery.matches;
    if (calm) {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      if (phase === 'gather') {
        phase = 'rest';
        clock = 0;
        if (!finished) finish();
      } else if (phase === 'return') {
        phase = 'run';
        clock = 0;
      }
      settle();
      paint1();
      stepCalm();
    } else {
      clearTimeout(calmTimer);
      wake();
    }
  };

  // ---- つなぐ
  on(again, 'click', restart);
  on(bar, 'click', restart);
  on(motionQuery, 'change', applyCalm);
  on(schemeQuery, 'change', () => {
    readPalette();
    redraw();
  });
  on(ownerDoc, 'visibilitychange', () => {
    if (isOn()) {
      if (calm) stepCalm();
      else wake();
    } else {
      clearTimeout(calmTimer);
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    }
  });
  const io = new IntersectionObserver((entries) => {
    visible = entries.some((e) => e.isIntersecting);
    if (isOn()) {
      if (calm) {
        stepCalm();
        redraw();
      } else wake();
    } else {
      clearTimeout(calmTimer);
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    }
  });
  io.observe(root);
  const ro = new ResizeObserver(() => {
    measure();
    redraw();
  });
  ro.observe(root);

  on(canvas, 'webglcontextlost', (event) => {
    event.preventDefault();
    ready = false;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
  });
  on(canvas, 'webglcontextrestored', () => {
    try {
      initGl();
      paint1();
      wake();
    } catch {
      root.setAttribute('data-flat', '');
    }
  });

  // ---- 組み立て
  measure();
  try {
    if (!gl) throw new Error('webgl2');
    initGl();
    readPalette();
    settle();
    paint1();
    if (calm) stepCalm();
    else wake();
  } catch (error) {
    // WebGL が使えなくても、進み具合と完了は伝える
    root.setAttribute('data-flat', '');
    ready = false;
    let t = 0;
    const flatStep = () => {
      t += 0.4;
      p = progressAt(t);
      showValue();
      if (t < RUN_TIME) calmTimer = setTimeout(flatStep, 400);
      else finish();
    };
    calmTimer = setTimeout(flatStep, 400);
    on(again, 'click', () => {
      t = 0;
      calmTimer = setTimeout(flatStep, 400);
    });
    if (gl) console.warn(error);
  }

  return () => {
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    clearTimeout(calmTimer);
    visible = false;
    io.disconnect();
    ro.disconnect();
    for (const off of cleanups) off();
    if (gl && ready) {
      gl.deleteProgram(scene.prog);
      gl.deleteProgram(compose.prog);
      gl.deleteTexture(texProfile);
      gl.deleteTexture(texScene);
      gl.deleteFramebuffer(fbo);
      const lose = gl.getExtension('WEBGL_lose_context');
      if (lose) lose.loseContext();
    }
    ready = false;
  };
}
mount(document.querySelector('[data-ld-part="progress-ferro"]'));
</script>

@Lattice_Node のほかの作品