水面のいいね

部品 · いいねボタン

見られた数 716コピーされた数 4

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

<!-- UI by @Lattice_Node — ludion.ai/p/like-pond (MIT) -->
<div data-ld-part="like-pond" lang="ja">
  <canvas class="pond" aria-hidden="true"></canvas>
  <button class="hit" type="button" aria-pressed="false" aria-label="いいね 128件">
    <svg class="still" viewBox="0 0 120 110" aria-hidden="true">
      <path d="M60 104C24 76 8 58 8 35 8 18 21 6 37 6c10 0 18 5 23 14C65 11 73 6 83 6c16 0 29 12 29 29 0 23-16 41-52 69Z"></path>
    </svg>
    <span class="tally" aria-hidden="true">
      <span class="word">いいね</span>
      <span class="num" data-num>128</span>
    </span>
  </button>
  <span class="vh" role="status" aria-live="polite" data-status></span>
  <span class="probe" aria-hidden="true"></span>
</div>
<style>
/* UI by @Lattice_Node — ludion.ai/p/like-pond (MIT) */
[data-ld-part="like-pond"] {
  /* 固定しないトークン。水の色、底の石、光は、この5色の色相と明るさから計算する */
  --_bg: var(--ld-bg, #ebe6dc);
  --_surface: var(--ld-surface, #272b29);
  --_fg: var(--ld-fg, #15201f);
  --_muted: var(--ld-muted, #3f6a67);
  --_accent: var(--ld-accent, #ff5d73);
  --_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 10px 30px rgb(38 30 22 / .32));
  --_motion: var(--ld-motion, 240ms);

  position: relative;
  display: block;
  box-sizing: border-box;
  container-type: inline-size;
  /* どの枠にも収まる。池の正方形の下に、数の行を足した比率(1 : 1.14) */
  width: min(100%, calc((100vh - 64px) * .8772));
  aspect-ratio: .8772;
  font-family: var(--_font);
  color: var(--_fg);
}

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

[data-ld-part="like-pond"] .vh {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

[data-ld-part="like-pond"] .pond {
  position: absolute;
  top: 0;
  left: 0;
  display: block;
  width: 100cqw;
  height: 100cqw;
  pointer-events: none;
}

/* 本物のボタン。絵は canvas が描き、ここは押す場所と数だけ */
[data-ld-part="like-pond"] .hit {
  position: absolute;
  top: 7cqw;
  left: 7cqw;
  width: 86cqw;
  height: 86cqw;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}

[data-ld-part="like-pond"] .hit:focus {
  outline: none;
}

[data-ld-part="like-pond"] .hit:focus-visible {
  outline: max(2px, .35cqw) solid var(--_accent);
  outline-offset: max(3px, .7cqw);
}

[data-ld-part="like-pond"] .tally {
  position: absolute;
  top: calc(100% + 5.2cqw);
  left: 50%;
  display: inline-flex;
  align-items: baseline;
  gap: calc(var(--_space) * 1.25);
  transform: translateX(-50%);
  white-space: nowrap;
  line-height: 1;
}

[data-ld-part="like-pond"] .word {
  color: var(--_muted);
  font-size: max(13px, 2.7cqw);
  font-weight: 500;
  letter-spacing: .08em;
}

[data-ld-part="like-pond"] .num {
  display: inline-flex;
  color: var(--_fg);
  font-size: max(22px, 6cqw);
  font-weight: 300;
  font-variant-numeric: tabular-nums;
  letter-spacing: .01em;
  transition: color calc(var(--_motion) * 3) ease;
}

[data-ld-part="like-pond"] .hit[aria-pressed="true"] .num {
  color: color-mix(in oklab, var(--_accent) 58%, var(--_fg));
}

[data-ld-part="like-pond"] .d {
  position: relative;
  display: inline-block;
}

[data-ld-part="like-pond"] .d .o {
  position: absolute;
  top: 0;
  left: 0;
}

[data-ld-part="like-pond"] .d .v {
  display: inline-block;
}

/* WebGL を使えないときの絵:トークンの色だけの、静かな池 */
[data-ld-part="like-pond"] .still {
  display: none;
}

[data-ld-part="like-pond"][data-gl="off"] .hit {
  background: radial-gradient(circle at 42% 36%, color-mix(in oklab, var(--_muted) 72%, var(--_fg)), color-mix(in oklab, var(--_muted) 30%, var(--_fg)) 78%);
  box-shadow: inset 0 0 0 7cqw var(--_surface), var(--_shadow);
}

[data-ld-part="like-pond"][data-gl="off"] .still {
  position: absolute;
  top: 31%;
  left: 31%;
  display: block;
  width: 38%;
  height: 38%;
  fill: color-mix(in oklab, var(--_accent) 20%, transparent);
  transition: fill calc(var(--_motion) * 2) ease;
}

[data-ld-part="like-pond"][data-gl="off"] .hit[aria-pressed="true"] .still {
  fill: var(--_accent);
}

@media (prefers-reduced-motion: reduce) {
  [data-ld-part="like-pond"] .num,
  [data-ld-part="like-pond"][data-gl="off"] .still {
    transition: none;
  }
}
</style>
<script type="module">
/* UI by @Lattice_Node — ludion.ai/p/like-pond (MIT) */
// part.js — ESモジュール。rootの中だけを触る
// 水面のいいね:丸い小さな池の水面が、そのままボタンになっている。
//
// 水面:波動方程式を GPU で解く。波紋は縁で跳ね返り、連打すれば本当に干渉する。
// 光:水面の傾きで光線を屈折させ、底に落ちる光の密度を網で測る(コースティクス)。
// ハート:底に落ちる光を、距離場で折り畳んで輪郭へ集める。ほどければ、元の揺らぎへ戻る。
// カーソル:水面をわずかに持ち上げる。盛り上がりはレンズになり、底に淡い光を集める。
// 開いた直後:一瞬だけハートが灯っていて、すぐにほどける。何が起きるボタンかを、押す前に見せる。
// 色は JS に書かない。--_bg、--_surface、--_fg、--_muted、--_accent の色相から、明るさを決めて作る。

const SIM_N = 384; // 水面の格子
const DOMAIN = 1.02; // 格子の半幅(水面の半径が 1)
const FLOOR_EXT = 1.16; // 底の光の板の半幅
const BAKE_EXT = 1.25; // 石の模様の板の半幅
const R_OUT = 1.17; // 石の輪の外の半径
const HALF = R_OUT / 0.86; // canvas の半幅(影の余白を含む)
const CAM = 3.4; // 目の高さ
const STEP = 1 / 360; // 波の計算の1歩(秒)
const MAX_STEPS = 9;
const WAVE_SPEED = 1.02; // 波の速さ(半径 / 秒)
const FALL = 0.27; // 滴が落ちるまでの秒数
const DROP_Z = 2.3; // 滴の出発の高さ
const GRAVITY = (2 * DROP_Z) / (FALL * FALL);
const BURST_MS = 420; // これより短い間隔の押しは、連打として数える
const MAX_DROPS = 12;
const MAX_SOURCES = 8;
const WAVES = 16;

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

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(r8, g8, b8) {
  const r = toLinear(r8 / 255);
  const g = toLinear(g8 / 255);
  const b = toLinear(b8 / 255);
  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];
}
// 明るさを決め、色域に入るまで鮮やかさを落として、線形の RGB を返す
function tone(L, a, b) {
  let k = 1;
  for (let i = 0; i < 24; i++) {
    const rgb = oklabToLinear(L, a * k, b * k);
    if (rgb.every((v) => v >= -0.0005 && v <= 1.0005)) return rgb.map((v) => clamp(v));
    k *= 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) & 2), float(gl_VertexID & 2));
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;

const INIT_FRAG = `#version 300 es
precision highp float;
uniform float uDx;
out vec4 o;
void main() {
  vec2 p = gl_FragCoord.xy * uDx - ${DOMAIN.toFixed(3)};
  o = vec4(0.0, 0.0, 0.0, clamp((1.0 - length(p)) / uDx + 0.5, 0.0, 1.0));
}`;

// 波の1歩。r が高さ、g が速さ、a が水のある割合
const simFrag = (sources) => `#version 300 es
precision highp float;
precision highp sampler2D;
${sources ? '#define SOURCES' : ''}
uniform sampler2D uState;
uniform float uC2, uNu, uDamp, uDx;
#ifdef SOURCES
uniform int uSrcN;
uniform vec4 uSrc[${MAX_SOURCES}];
uniform vec4 uBumpNow, uBumpOld;
#endif
out vec4 o;
#define AT(i, j) texelFetch(uState, q + ivec2(i, j), 0)
void main() {
  ivec2 q = ivec2(gl_FragCoord.xy);
  vec4 s = AT(0, 0);
  if (s.a <= 0.0) { o = vec4(0.0); return; }
  vec2 p = (vec2(q) + 0.5) * uDx - ${DOMAIN.toFixed(3)};
  float r = length(p);
  vec4 e = AT(1, 0), w = AT(-1, 0), n = AT(0, 1), d = AT(0, -1);
  vec4 ne = AT(1, 1), nw = AT(-1, 1), se = AT(1, -1), sw = AT(-1, -1);
  float h0 = s.r;
  float v0 = s.g;
  // 外の隣は、自分と同じ高さとして読む。波はそこで跳ね返る
  float lap = (4.0 * (mix(h0, e.r, e.a) + mix(h0, w.r, w.a) + mix(h0, n.r, n.a) + mix(h0, d.r, d.a))
    + mix(h0, ne.r, ne.a) + mix(h0, nw.r, nw.a) + mix(h0, se.r, se.a) + mix(h0, sw.r, sw.a) - 20.0 * h0) / 6.0;
  float vlap = mix(v0, e.g, e.a) + mix(v0, w.g, w.a) + mix(v0, n.g, n.a) + mix(v0, d.g, d.a) - 4.0 * v0;
  // 岸に近づくと浅くなり、波は遅くなって、少しだけ吸われる
  float shoal = 1.0 - 0.42 * smoothstep(0.7, 1.0, r);
  float sponge = 1.0 - 0.05 * smoothstep(0.84, 1.0, r);
  float v = (v0 + uC2 * shoal * lap + uNu * vlap - 0.00003 * h0) * uDamp * sponge;
#ifdef SOURCES
  for (int i = 0; i < uSrcN; i++) {
    vec2 dd = p - uSrc[i].xy;
    float e2 = dot(dd, dd) / (uSrc[i].w * uSrc[i].w);
    if (e2 < 14.0) v += uSrc[i].z * (1.0 - e2) * exp(-e2); // 全体で水の量が変わらない形
  }
  // カーソルが動くと、持ち上げられた水の分だけ、まわりがわずかに揺れる
  {
    vec2 a = p - uBumpNow.xy;
    vec2 b = p - uBumpOld.xy;
    float s2 = uBumpNow.w * uBumpNow.w;
    v -= 0.16 * (uBumpNow.z * exp(-dot(a, a) / s2) - uBumpOld.z * exp(-dot(b, b) / s2));
  }
#endif
  v /= 1.0 + abs(v) * 18.0;
  o = vec4(h0 + v, v, 0.0, s.a);
}`;

// 見せる水面:波+風のさざ波+カーソルの盛り上がり。r が高さ、gb が傾き
const COMPOSE_FRAG = `#version 300 es
precision highp float;
precision highp sampler2D;
uniform sampler2D uState;
uniform float uDx, uTime, uBreeze;
uniform vec4 uBump;
uniform vec4 uWave[${WAVES}];
out vec4 o;
#define AT(i, j) texelFetch(uState, q + ivec2(i, j), 0)
void main() {
  ivec2 q = ivec2(gl_FragCoord.xy);
  vec4 s = AT(0, 0);
  vec2 p = (vec2(q) + 0.5) * uDx - ${DOMAIN.toFixed(3)};
  float h0 = s.r;
  vec4 e = AT(1, 0), w = AT(-1, 0), n = AT(0, 1), d = AT(0, -1);
  vec4 ne = AT(1, 1), nw = AT(-1, 1), se = AT(1, -1), sw = AT(-1, -1);
  float he = mix(h0, e.r, e.a), hw = mix(h0, w.r, w.a), hn = mix(h0, n.r, n.a), hd = mix(h0, d.r, d.a);
  float hne = mix(h0, ne.r, ne.a), hnw = mix(h0, nw.r, nw.a), hse = mix(h0, se.r, se.a), hsw = mix(h0, sw.r, sw.a);
  vec2 g = vec2(3.0 * (hne - hnw + hse - hsw) + 10.0 * (he - hw), 3.0 * (hne + hnw - hse - hsw) + 10.0 * (hn - hd)) / (32.0 * uDx);
  float h = h0;
  if (s.a <= 0.0) {
    h = 0.0;
    g = vec2(0.0);
  }
  // 風のさざ波。線が格子に揃わないよう、座標をゆるく曲げてから重ねる
  float t = uTime;
  vec2 w1 = vec2(3.1 * p.y + t * 0.21 + 1.0, 2.7 * p.x - t * 0.17 + 4.0);
  vec2 w2 = vec2(6.7 * p.y + 1.9 * p.x - t * 0.13 + 2.0, 5.3 * p.x - 2.3 * p.y + t * 0.11 + 5.0);
  vec2 P = p + 0.06 * sin(w1) + 0.024 * sin(w2);
  float j01 = 0.06 * 3.1 * cos(w1.x) + 0.024 * 6.7 * cos(w2.x);
  float j10 = 0.06 * 2.7 * cos(w1.y) + 0.024 * 5.3 * cos(w2.y);
  vec3 br = vec3(0.0);
  for (int i = 0; i < ${WAVES}; i++) {
    vec4 k = uWave[i];
    float a = 0.46 / dot(k.xy, k.xy);
    a *= 0.62 + 0.38 * sin(dot(k.yx, P) * 0.11 + k.w * 3.0 + t * 0.09);
    float ph = dot(k.xy, P) + k.z * t + k.w;
    br += a * vec3(sin(ph), k.xy * cos(ph));
  }
  br.yz = vec2(br.y + br.z * j10, br.z + br.y * j01);
  float shelter = smoothstep(1.02, 0.8, length(p));
  h += br.x * uBreeze * shelter;
  g += br.yz * uBreeze * shelter;
  // カーソルの盛り上がり
  vec2 c = p - uBump.xy;
  float s2 = uBump.w * uBump.w;
  float bump = uBump.z * exp(-dot(c, c) / s2);
  h += bump;
  g += -2.0 * c / s2 * bump;
  o = vec4(h, g, 1.0);
}`;

// 底に落ちる光の網。頂点ごとに、光線の束がどれだけ縮むかを測る(三角形ごとに測ると、面が見えてしまう)
const CAUSTIC_VERT = `#version 300 es
precision highp float;
precision highp sampler2D;
uniform sampler2D uH;
uniform int uGrid;
uniform float uOptic;
out vec2 vSrc;
out float vFlux;
vec2 slope(vec2 p) {
  vec2 s = textureLod(uH, clamp(p / ${(2 * DOMAIN).toFixed(3)} + 0.5, 0.0, 1.0), 0.0).yz;
  // 水のないところでは、光は曲がらない
  return s * smoothstep(1.0, 0.9, length(p));
}
void main() {
  int col = gl_VertexID % (uGrid + 1);
  int row = gl_VertexID / (uGrid + 1);
  float cell = 2.16 / float(uGrid);
  vec2 p = (vec2(float(col), float(row)) / float(uGrid) * 2.0 - 1.0) * 1.08;
  vec2 g = slope(p);
  // 水に入った光は、傾きのぶんだけ曲がる。盛り上がりは光を集める
  float depth = uOptic * 0.248 * (0.72 + 0.28 * max(1.0 - dot(p, p), 0.0));
  vec2 land = p + g * depth + vec2(0.022, -0.03);
  vec2 gx = (slope(p + vec2(cell, 0.0)) - slope(p - vec2(cell, 0.0))) / (2.0 * cell);
  vec2 gy = (slope(p + vec2(0.0, cell)) - slope(p - vec2(0.0, cell))) / (2.0 * cell);
  float det = (1.0 + depth * gx.x) * (1.0 + depth * gy.y) - depth * gx.y * depth * gy.x;
  vFlux = 1.0 / max(abs(det), 0.03);
  vSrc = p;
  gl_Position = vec4(land / ${FLOOR_EXT.toFixed(3)}, 0.0, 1.0);
}`;

const CAUSTIC_FRAG = `#version 300 es
precision highp float;
in vec2 vSrc;
in float vFlux;
out vec4 o;
void main() {
  // 石の輪が、太陽の側に影を落とす
  float inside = smoothstep(1.06, 0.98, length(vSrc));
  float shade = mix(0.42, 1.0, smoothstep(1.05, 0.72, length(vSrc + vec2(-0.05, 0.075))));
  o = vec4(vec3(vFlux * inside * shade), 1.0);
}`;

// 細い線がとぎれないよう、ごくわずかにならす。隣との中間を読むので、9回で 5×5 ぶんになる
const SOFTEN_FRAG = `#version 300 es
precision highp float;
precision highp sampler2D;
uniform sampler2D uRaw;
uniform float uStep;
out vec4 o;
void main() {
  vec2 uv = gl_FragCoord.xy * uStep;
  float d = uStep * 1.15;
  float v = 4.0 * texture(uRaw, uv).r;
  v += 2.0 * (texture(uRaw, uv + vec2(d, 0.0)).r + texture(uRaw, uv - vec2(d, 0.0)).r + texture(uRaw, uv + vec2(0.0, d)).r + texture(uRaw, uv - vec2(0.0, d)).r);
  v += texture(uRaw, uv + vec2(d, d)).r + texture(uRaw, uv + vec2(-d, d)).r + texture(uRaw, uv + vec2(d, -d)).r + texture(uRaw, uv - vec2(d, d)).r;
  o = vec4(vec3(v / 16.0), 1.0);
}`;

// 底の砂と、輪の石の模様を1枚に焼く。r:底の明るさ、g:底の陰影、b:石の筋、a:石の粒
const BAKE_FRAG = `#version 300 es
precision highp float;
uniform float uSize;
out vec4 o;
float h21(vec2 p) {
  vec3 q = fract(vec3(p.xyx) * 0.1031);
  q += dot(q, q.yzx + 33.33);
  return fract((q.x + q.y) * q.z);
}
vec2 h22(vec2 p) {
  vec3 q = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
  q += dot(q, q.yzx + 33.33);
  return fract((q.xx + q.yz) * q.zy);
}
float vn(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
  return mix(mix(h21(i), h21(i + vec2(1, 0)), u.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), u.x), u.y);
}
float fbm(vec2 p, int n) {
  float s = 0.0;
  float a = 0.5;
  for (int i = 0; i < n; i++) {
    s += a * vn(p);
    p = mat2(1.62, 1.18, -1.18, 1.62) * p + 17.3;
    a *= 0.5;
  }
  return s;
}
// 砂の中の小さな石:格子ごとに1つ、向きと大きさの違う楕円。x:陰影、y:明るさ、z:覆う割合、w:石のそばの陰
vec4 stones(vec2 x, float seed, float keep) {
  vec2 n = floor(x);
  vec2 f = fract(x);
  float best = -1.0;
  vec4 out0 = vec4(0.0, 0.5, 0.0, 9.0);
  for (int j = -1; j <= 1; j++) {
    for (int i = -1; i <= 1; i++) {
      vec2 g = vec2(float(i), float(j));
      vec2 id = n + g + seed;
      if (h21(id + 5.1) > keep) continue;
      vec2 h = h22(id);
      vec2 c = g + 0.5 + (h - 0.5) * 0.74 - f;
      float a = h21(id + 2.3) * 6.2831;
      float ca = cos(a);
      float sa = sin(a);
      vec2 e = vec2(ca * c.x + sa * c.y, -sa * c.x + ca * c.y);
      float size = mix(0.26, 0.56, h21(id + 1.7));
      e /= size * vec2(1.0, mix(0.62, 0.92, h21(id + 8.4)));
      float d = length(e);
      out0.w = min(out0.w, d);
      if (d >= 1.0) continue;
      float dome = sqrt(1.0 - d * d);
      float z = dome * size + h.x * 0.2;
      if (z <= best) continue;
      best = z;
      vec2 nr = vec2(ca * e.x - sa * e.y, sa * e.x + ca * e.y);
      float lit = 0.62 + 0.46 * dot(nr, vec2(-0.55, 0.83));
      out0.x = lit * mix(0.5, 1.0, smoothstep(0.0, 0.42, dome));
      out0.y = h21(id + 3.3);
      out0.z = smoothstep(0.0, 0.18, dome);
    }
  }
  return out0;
}
void main() {
  vec2 p = (gl_FragCoord.xy / uSize * 2.0 - 1.0) * ${BAKE_EXT.toFixed(3)};
  // 底:細かい砂。泥の濃淡、かすかな風紋、砂粒、ときどき小さな石
  vec2 warp = vec2(fbm(p * 2.6, 3), fbm(p * 2.6 + 9.1, 3)) * 0.16;
  float silt = fbm(p * 4.6 + 3.0, 5);
  float grain = fbm(p * 62.0, 3);
  float speck = vn(p * 300.0) * 0.5 + vn(p * 130.0 + 9.0) * 0.5;
  float marks = sin((p.x * 0.55 + p.y * 0.83 + fbm(p * 1.5, 3) * 1.3) * 42.0) * 0.5 + 0.5;
  vec4 tiny = stones((p + warp) * 38.0, 13.0, 0.5 * smoothstep(0.3, 0.62, fbm(p * 3.4 + 2.0, 3)));
  float shade = 0.58 + (silt - 0.5) * 0.5 + (grain - 0.5) * 0.15 + (marks - 0.5) * 0.08 + (speck - 0.5) * 0.07;
  float toneV = silt * 0.8 + (grain - 0.5) * 0.16 + (speck - 0.5) * 0.1;
  shade *= mix(0.9, 1.0, smoothstep(1.0, 1.3, tiny.w));
  shade = mix(shade, tiny.x * 0.8, tiny.z * 0.3);
  toneV = mix(toneV, tiny.y, tiny.z * 0.18);
  // 石の輪:雲のような濃淡と、髪の毛ほどの細い筋。筋の太さは、傾きで割って揃える
  vec2 flow = p * 2.1 + vec2(fbm(p * 1.4, 4), fbm(p * 1.4 + 5.2, 4)) * 2.6;
  float f1 = fbm(flow, 5) - 0.5;
  float f2 = fbm(flow * 2.1 + 4.0, 5) - 0.5;
  float px = uSize / 2048.0;
  float line1 = smoothstep(2.6, 0.5, abs(f1) / max(fwidth(f1) * px, 1.0e-5));
  float line2 = smoothstep(1.8, 0.4, abs(f2) / max(fwidth(f2) * px, 1.0e-5));
  float where1 = smoothstep(0.42, 0.62, fbm(p * 1.6 + 31.0, 3));
  float where2 = smoothstep(0.46, 0.64, fbm(p * 2.3 + 47.0, 3));
  float cloud = fbm(p * 2.2 + 21.0, 5) * 0.72 + fbm(p * 9.0 + 3.0, 4) * 0.28;
  float strata = clamp(cloud * 0.9 + line1 * where1 * 0.3 + line2 * where2 * 0.17, 0.0, 1.0);
  float fine = fbm(p * 150.0, 2);
  float fleck = smoothstep(0.66, 0.74, vn(p * 210.0) * 0.6 + vn(p * 96.0 + 3.0) * 0.4);
  o = vec4(clamp(toneV, 0.0, 1.0), clamp(shade, 0.0, 1.0), strata, clamp(fine * 0.55 + fleck * 0.45, 0.0, 1.0));
}`;

const VIEW_FRAG = `#version 300 es
precision highp float;
precision highp sampler2D;
uniform sampler2D uH, uCaustic, uBake;
uniform vec2 uRes, uHSize;
uniform float uUnit, uTime, uHeart, uFlare, uLoose;
uniform vec3 uDeep, uShallow, uTint, uFloorA, uFloorB, uStone, uVein, uFleck, uLight, uEnvLow, uPage, uGlow, uHot, uCausticC, uShadowC;
uniform float uShadowA;
uniform int uDropN;
uniform vec4 uDrop[${MAX_DROPS}];
out vec4 fragOut;

const float CAM = ${CAM.toFixed(2)};
const float ROUT = ${R_OUT.toFixed(3)};
const float HALF = ${HALF.toFixed(4)};
const float DOMAIN = ${DOMAIN.toFixed(3)};
const float FE = ${FLOOR_EXT.toFixed(3)};
const float BE = ${BAKE_EXT.toFixed(3)};
const vec2 KEY = vec2(-0.55, 0.835);
const vec3 SUN = vec3(-0.215, 0.326, 0.92);

// B スプラインで読む。4K に広げても、傾きが角ばらない
vec4 cubic(sampler2D t, vec2 uv, vec2 size) {
  vec2 st = uv * size - 0.5;
  vec2 i = floor(st);
  vec2 f = st - i;
  vec2 f2 = f * f;
  vec2 f3 = f2 * f;
  vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0;
  vec2 w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
  vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0;
  vec2 w3 = f3 / 6.0;
  vec2 g0 = w0 + w1;
  vec2 g1 = w2 + w3;
  vec2 h0 = (i - 0.5 + w1 / g0 + 0.5) / size;
  vec2 h1 = (i + 1.5 + w3 / g1 + 0.5) / size;
  return g0.y * (g0.x * texture(t, vec2(h0.x, h0.y)) + g1.x * texture(t, vec2(h1.x, h0.y)))
       + g1.y * (g0.x * texture(t, vec2(h0.x, h1.y)) + g1.x * texture(t, vec2(h1.x, h1.y)));
}

// 空:なだらかな明るさと、やわらかい大きな光。tight が大きいほど、光の縁がくっきりする
vec3 env(vec3 R, float width, float tight, float power) {
  float g = smoothstep(-0.7, 1.0, R.z * 0.3 + dot(R.xy, KEY) * 0.84);
  vec3 c = mix(uEnvLow * 0.55, uLight * 0.3, g * g);
  vec3 toKey = R - normalize(vec3(KEY * 0.8, 0.6));
  float k2 = dot(toKey, toKey) / (width * width);
  c += uLight * power * exp(-pow(k2, tight));
  // 反対側の、弱い照り返し
  vec3 toFill = R - normalize(vec3(0.64, -0.7, 0.32));
  c += uLight * power * 0.1 * exp(-pow(dot(toFill, toFill) / (width * width * 0.42), tight));
  return c;
}

// ハートの距離場。下は先 (0, 0) から接点 (0.5, 0.5) への辺、上は2つの円。切れ込みは (0, 1)
float sdHeart(vec2 p) {
  p.x = abs(p.x);
  float along = p.x + p.y;
  if (along > 1.0) return length(p - vec2(0.25, 0.75)) - 0.35355;
  float toSide = length(p - vec2(0.5) * max(along, 0.0));
  float toNotch = length(p - vec2(0.0, 1.0));
  return min(toSide, toNotch) * sign(p.x - p.y);
}

// 底の光を集める形。m=0 で池いっぱいの円、m=1 でハート
// x:折り畳まれた輪郭の光、y:内を満たす光、z:外へにじむ光
vec4 heartField(vec2 q, float m) {
  vec2 c = q - vec2(0.0, -0.035);
  vec2 hp = c / 0.9 + vec2(0.0, 0.56);
  // 辺を、わずかにふくらませる。直線のままだと、記号に見えてしまう
  hp.x /= 1.0 + 0.085 * sin(3.14159 * clamp(hp.y, 0.0, 1.0));
  float dH = sdHeart(hp) * 0.9;
  float dC = length(c) - 1.3;
  float th = atan(c.y, c.x);
  float t = uTime;
  float wave = sin(th * 3.0 + t * 0.7) * 0.5 + sin(th * 5.0 - t * 0.43 + 1.3) * 0.3 + sin(th * 9.0 + t * 0.31 + 4.0) * 0.2;
  float d = mix(dC, dH, m);
  float loose = 2.0 * m * (1.0 - m) * 0.1 + uLoose * m * 0.075;
  d += wave * loose;
  float inside = -d;
  float aa = max(1.3 / uUnit, 0.0028);
  float edge = smoothstep(-aa, aa, inside);
  float dIn = max(inside, 0.0);
  // 外の縁は鋭く、内へなだらかに落ちる。本物の光の折り目の形
  float fold = edge * exp(-dIn / 0.085) / sqrt(dIn / 0.0075 + 0.34);
  // 輪郭に沿って編まれる、細い光の筋
  float strands = 0.0;
  for (int i = 1; i <= 3; i++) {
    float fi = float(i);
    float off = 0.017 * fi + 0.0105 * fi * sin(th * (4.0 + fi * 3.0) + t * (0.5 - 0.17 * fi) + fi * 2.1) + 0.006 * sin(th * (17.0 + fi * 5.0) - t * 0.9 + fi);
    strands += exp(-abs(dIn - off) / (0.0034 + 0.0017 * fi)) * (0.62 / fi) * (0.62 + 0.38 * sin(th * 7.0 + fi * 4.0 + t * 0.36));
  }
  fold += strands * edge;
  // ほどけるとき:輪郭がところどころ切れて、細い筋に分かれる
  if (uLoose > 0.001) {
    float brk = sin(th * 6.0 + t * 0.8 + sin(th * 2.0) * 2.0) * 0.5 + sin(th * 13.0 - t * 0.6 + 1.0) * 0.3 + sin(th * 23.0 + t * 0.4) * 0.2;
    fold *= mix(1.0, smoothstep(-0.25, 0.55, brk - (1.0 - m) * 0.5), uLoose);
  }
  float fill = edge * (0.006 + 0.06 * exp(-dIn / 0.07) + 0.022 * exp(-dIn / 0.24));
  float gap = min(inside, 0.0);
  float outer = (1.0 - edge) * (exp(gap / 0.01) * 0.62 + exp(gap / 0.055) * 0.16 + exp(gap / 0.2) * 0.03);
  return vec4(fold, fill, outer, edge);
}

float depthAt(float r) {
  return 0.14 + 0.42 * (1.0 - smoothstep(0.42, 1.04, r));
}

vec3 waterShade(vec2 p, float r, vec4 H) {
  vec2 grad = H.yz;
  vec2 rh = p / max(r, 1.0e-4);
  float e = max(1.0 - r, 0.0);
  grad += rh * 0.6 * exp(-e / 0.014); // 岸で、水がわずかに這い上がる
  vec3 N = normalize(vec3(-grad, 1.0));
  vec3 V = normalize(vec3(-p, CAM));
  vec3 R = reflect(-V, N);
  vec3 T = refract(-V, N, 0.7519);
  float D = depthAt(r);
  vec2 q = p + T.xy / -T.z * D;
  D = depthAt(length(q));
  q = p + T.xy / -T.z * D;
  float path = D / -T.z;
  float qr = length(q);

  // 底
  vec4 b = texture(uBake, q / (2.0 * BE) + 0.5);
  vec3 albedo = mix(uFloorA, uFloorB, smoothstep(0.22, 0.82, b.r)) * mix(0.3, 1.12, b.g);
  float wall = smoothstep(0.97, 1.05, qr);
  albedo = mix(albedo, uStone * 0.7 + uFloorA * 0.2, wall);

  // 底に落ちた光。傾きに応じて色がわずかに分かれる
  vec2 cuv = q / (2.0 * FE) + 0.5;
  vec2 sp = grad * 0.0075;
  float lit = textureLod(uCaustic, cuv, 0.25).r;
  vec3 cau = vec3(textureLod(uCaustic, cuv + sp, 0.25).r, lit, textureLod(uCaustic, cuv - sp, 0.25).r);
  // 明るいところは、線のまま細く。暗いところは、ほんのり残す
  cau = 0.06 * cau + 0.94 * cau * cau * 0.6;

  float m = uHeart;
  float w = smoothstep(0.0, 0.3, m) * mix(1.0, smoothstep(0.16, 0.86, m), uLoose);
  vec4 hf = heartField(q, m);
  // 輪郭の光は、本物の揺らぎの濃淡をそのまま運ぶ
  vec2 pull = q * 0.82 + vec2(0.0, 0.03);
  float shim = textureLod(uCaustic, pull / (2.0 * FE) + 0.5, 1.2).r;
  float strand = clamp(0.42 + 0.72 * shim, 0.3, 2.0);
  float flare = 1.0 + uFlare * 0.85;
  float core = hf.x * mix(1.0, strand, 0.5) * flare * w;
  float inner = hf.y * strand * flare * w;
  float halo = hf.z * flare * w;
  float within = hf.w * w;

  // 光が集まるぶん、まわりは静かに暗くなる
  // 輪郭の外から暗くなり、内は形ができてから色が変わる
  float keep = 1.0 - 0.84 * w * ((1.0 - hf.w) + hf.w * smoothstep(0.45, 0.95, m));
  vec3 floorLit = albedo * (uLight * 0.055 + uCausticC * cau * keep);
  float grainy = mix(0.55, 1.1, b.g);
  // ハートの中では、残った揺らぎも同じ色に染まる
  floorLit += uGlow * cau * within * smoothstep(0.35, 0.9, m) * 0.34 * grainy * flare;
  floorLit += uGlow * (core * 2.1 + inner * 1.5) * grainy;
  floorLit += uHot * core * core * core * 0.62 * grainy;
  floorLit += uGlow * halo * 0.9;

  // 滴の影
  for (int i = 0; i < uDropN; i++) {
    vec4 dp = uDrop[i];
    vec2 ds = q - (dp.xy + KEY * dp.z * 0.32);
    float sg = dp.w * (1.6 + dp.z * 1.5);
    float sa = 0.58 / (1.0 + dp.z * dp.z * 2.4);
    floorLit *= 1.0 - sa * exp(-dot(ds, ds) / (sg * sg));
  }

  // 水の色:通った長さのぶん吸われ、そのぶん水の色が足される
  vec3 trans = pow(uTint, vec3(path * 3.0));
  vec3 body = mix(uDeep, uShallow, smoothstep(0.36, 0.14, D));
  vec3 c = floorLit * trans + body * (1.0 - trans) * (0.38 + 0.26 * textureLod(uCaustic, cuv, 4.4).r * keep);
  // 水の中に漂う光
  c += uGlow * (inner * 0.3 + halo * 0.26);

  // 水面の反射。岸のそばでは石が映る
  float ndv = clamp(dot(N, V), 0.0, 1.0);
  float F = 0.021 + 0.979 * pow(1.0 - ndv, 5.0);
  vec3 refl = env(R, 0.42, 1.2, 0.56);
  float toRim = length(p + R.xy * (0.12 / max(R.z, 0.1)));
  refl = mix(refl, uStone * 0.5 + uEnvLow * 0.25, smoothstep(0.985, 1.03, toRim));
  c = c * (1.0 - F) + refl * F;
  // 波の斜面:光のほうを向いた面は明るく、背を向けた面は暗い
  float relief = dot(-H.yz, KEY);
  c += uLight * max(relief, 0.0) * 0.065;
  c *= 1.0 - clamp(-relief * 0.75, 0.0, 0.5);
  // 太陽のきらめき:波の斜面で、一瞬だけ
  float sunDot = clamp(dot(R, SUN), 0.0, 1.0);
  c += uLight * pow(sunDot, 520.0) * 0.16;
  // 岸の際の細い陰
  c *= 1.0 - 0.45 * exp(-e / 0.006);
  return c;
}

vec3 stoneShade(vec2 p, float r) {
  vec4 b = texture(uBake, p / (2.0 * BE) + 0.5);
  float th = atan(p.y, p.x);
  vec2 rh = p / r;
  vec2 tang = vec2(-rh.y, rh.x);
  float t = clamp((r - 1.0) / (ROUT - 1.0), 0.0, 1.0);
  const float tc = 0.42;
  float u = t < tc ? (t - tc) / tc : (t - tc) / (1.0 - tc);
  float au = abs(u);
  float a = sign(u) * pow(au, 1.5) * 0.992;
  // 手で磨いた、ゆるいうねり。映り込みが生きて見える
  float roll = sin(th * 2.0 + 0.7) * 0.5 + sin(th * 5.0 + 2.1) * 0.32 + sin(th * 11.0 + 4.0) * 0.14;
  float sway = cos(th * 3.0 + 1.0) * 0.6 + cos(th * 7.0 + 3.0) * 0.3;
  vec2 tilt = rh * (a + roll * 0.03 * (1.0 - au)) + tang * sway * 0.034 * (1.0 - au);
  vec3 N = normalize(vec3(tilt, sqrt(max(1.0 - dot(tilt, tilt), 0.0004))));
  vec3 V = normalize(vec3(-p, CAM));
  vec3 R = reflect(-V, N);

  float wet = smoothstep(0.16, 0.0, t);
  vec3 albedo = uStone * mix(0.72, 1.28, smoothstep(0.3, 0.68, b.b));
  albedo = mix(albedo, uVein, smoothstep(0.74, 1.0, b.b) * 0.5);
  albedo = mix(albedo, uFleck, smoothstep(0.5, 0.66, b.a) * 0.34);
  albedo = mix(albedo, uVein, smoothstep(0.6, 0.72, b.a) * 0.13);
  albedo *= mix(1.0, 0.58, wet);

  float ao = 0.56 + 0.44 * smoothstep(0.0, 0.26, t);
  ao *= mix(1.0, 0.8, smoothstep(0.9, 1.0, t));
  vec3 Ld = vec3(KEY * 0.58, 0.81);
  float ndl = max(dot(N, Ld), 0.0);
  vec3 c = albedo * (uLight * 0.2 + uLight * 0.6 * ndl) * ao;

  // 磨いた面の映り込み
  float ndv = clamp(dot(N, V), 0.0, 1.0);
  float f0 = mix(0.05, 0.1, wet);
  float F = f0 + (1.0 - f0) * pow(1.0 - ndv, 5.0);
  // 上塗り:鏡のような映り込み。下塗り:少しだけ荒れた、広い光
  vec3 glossy = env(R, 0.17, 2.4, 17.0) + env(R, 0.5, 1.0, 1.5) * 0.75;
  // 外の斜面の低い向きには、まわりの地が映る
  glossy = mix(glossy, uPage * 0.34, smoothstep(0.34, -0.1, R.z) * smoothstep(0.0, 0.3, dot(R.xy, rh)));
  c = c * (1.0 - F) + glossy * F * ao;
  // 雲母の粒が、光の向きでだけ光る
  vec3 Hv = normalize(Ld + V);
  float mica = smoothstep(0.84, 0.97, b.a) * pow(max(dot(N, Hv), 0.0), 90.0);
  c += uLight * mica * 0.5;

  // 水の揺らぎが、内側の斜面へ映って踊る
  float toWater = smoothstep(tc + 0.14, 0.0, t);
  float dance = textureLod(uCaustic, (p * (1.0 - 0.14 * (1.0 + t))) / (2.0 * FE) + 0.5, 3.4).r;
  c += uCausticC * max(dance - 0.4, 0.0) * toWater * toWater * 0.06 * ao;
  // 灯ったハートの光が、斜面にほのかに届く
  float rise = max(dot(N, normalize(vec3(-p, 0.7))), 0.0);
  float reach = (uHeart * uHeart) * (1.0 + uFlare * 0.8);
  c += uGlow * albedo * rise * exp(-t / 0.3) * reach * 0.34;
  return c;
}

// 滴。色と覆う割合を返す
vec4 bead(vec2 p, vec4 d, float aa) {
  float k = CAM / (CAM - d.z);
  vec2 ctr = d.xy * k;
  float R = d.w * k;
  float l = length(p - ctr);
  // 目の近くでは、ぼけて見える
  float blur = clamp(d.z * 0.1, 0.0, 0.5) * R + aa;
  if (l > R + blur) return vec4(0.0);
  float cov = 1.0 - smoothstep(R - blur, R + blur, l);
  cov *= smoothstep(${DROP_Z.toFixed(2)}, ${(DROP_Z - 0.65).toFixed(2)}, d.z);
  vec2 q = (p - ctr) / R;
  float l2 = min(dot(q, q), 1.0);
  vec3 n = vec3(q, sqrt(1.0 - l2));
  vec3 V = normalize(vec3(-p, CAM - d.z));
  vec3 Rf = reflect(-V, n);
  // レンズ:下の景色が逆さに、小さく映る
  vec2 under = ctr - q * R * 1.8;
  float ur = length(under);
  float clit = textureLod(uCaustic, clamp(under / (2.0 * FE) + 0.5, 0.0, 1.0), 1.6).r;
  vec3 scene = uDeep * 0.8 + uFloorB * clit * clit * 0.16 + uGlow * uHeart * 0.05;
  scene = mix(scene, uStone * 0.7, smoothstep(0.96, 1.04, ur));
  // 球を通った光が、太陽と反対の内側に集まる
  float across = dot(q, -KEY);
  float pool = smoothstep(0.05, 0.95, across) * smoothstep(1.0, 0.72, sqrt(l2));
  scene += uLight * pool * pool * 0.36;
  // 縁は、まわりの暗さを曲げて映すので暗い
  scene *= mix(1.0, 0.42, smoothstep(0.62, 1.0, sqrt(l2)));
  float F = 0.02 + 0.98 * pow(1.0 - n.z, 5.0);
  vec3 c = scene * (1.0 - F) + env(Rf, 0.3, 1.4, 6.0) * F;
  float spec = pow(max(dot(n, normalize(vec3(KEY * 0.62, 0.78))), 0.0), 340.0);
  c += uLight * spec * 5.0;
  return vec4(c, cov);
}

vec3 knee(vec3 x) {
  const float a = 0.72;
  return mix(x, a + (1.0 - a) * (1.0 - exp(-(x - a) / (1.0 - a))), step(a, x));
}
vec3 srgbOf(vec3 x) {
  x = clamp(x, 0.0, 1.0);
  return mix(x * 12.92, 1.055 * pow(x, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, x));
}
float ign(vec2 v) {
  return fract(52.9829189 * fract(dot(v, vec2(0.06711056, 0.00583715))));
}

void main() {
  vec2 frag = gl_FragCoord.xy;
  vec2 p = (frag - 0.5 * uRes) / uUnit;
  float r = length(p);
  float aa = 1.0 / uUnit;

  // 石の輪の外:地に落ちる影
  float sa = 0.0;
  if (r > ROUT - 0.02) {
    float wide = length(p - KEY * vec2(-0.075, -0.075)) - ROUT;
    float mid = length(p - KEY * vec2(-0.03, -0.03)) - ROUT;
    float contact = r - ROUT;
    float sh = 0.46 * exp(-max(wide, 0.0) / 0.085) + 0.5 * exp(-max(mid, 0.0) / 0.028) + 0.65 * exp(-max(contact, 0.0) / 0.009);
    vec2 toEdge = vec2(HALF) - abs(p);
    float fade = smoothstep(0.0, 0.14, min(toEdge.x, toEdge.y));
    sa = clamp(sh, 0.0, 1.0) * fade * uShadowA;
  }

  vec3 col = vec3(0.0);
  float cover = 0.0;
  if (r < ROUT + 2.0 * aa) {
    cover = 1.0 - smoothstep(ROUT - aa, ROUT + aa, r);
    if (r > 1.0 - 2.0 * aa) col = stoneShade(p, max(r, 1.0));
    if (r < 1.0 + 2.0 * aa) {
      vec4 H = cubic(uH, p / (2.0 * DOMAIN) + 0.5, uHSize);
      vec3 w = waterShade(p, min(r, 1.0), H);
      col = r < 1.0 - 2.0 * aa ? w : mix(w, col, smoothstep(1.0 - aa, 1.0 + aa, r));
    }
  }

  for (int i = 0; i < uDropN; i++) {
    vec4 bd = bead(p, uDrop[i], aa);
    if (bd.a > 0.0) {
      col = mix(col, bd.rgb, bd.a);
      cover = max(cover, bd.a);
    }
  }

  vec3 enc = srgbOf(knee(col));
  vec3 pm = mix(srgbOf(uShadowC) * sa, enc, cover);
  float alpha = mix(sa, 1.0, cover);
  float n = ign(frag) + ign(frag + 37.0) - 1.0;
  pm += n * 0.75 / 255.0 * clamp(alpha * 4.0, 0.0, 1.0);
  fragOut = vec4(clamp(pm, 0.0, alpha), alpha);
}`;

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('.pond');
  const button = root.querySelector('.hit');
  const numEl = root.querySelector('[data-num]');
  const statusEl = root.querySelector('[data-status]');
  const probe = root.querySelector('.probe');
  const motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
  const schemeQuery = matchMedia('(prefers-color-scheme: dark)');
  let still = motionQuery.matches;

  // ---- 状態
  let pressed = button.getAttribute('aria-pressed') === 'true';
  let count = parseInt(numEl.textContent, 10) || 0;
  const heart = { raw: pressed ? 1 : 0, value: pressed ? 1 : 0, flare: 0, loose: 0, gatherAt: 0, want: pressed ? 1 : 0, wasFull: pressed };
  const pointer = { x: 0, y: 0, tx: 0, ty: 0, amp: 0, ampT: 0, oldX: 0, oldY: 0, oldAmp: 0, seen: false };
  let focusBump = 0;
  const flights = [];
  const sources = [];
  let calm = 0;
  let glassy = 0;
  let lastClick = -1e9;
  let burstAngle = 0;
  let burstLevel = 0;
  let clock = 0;
  let injectDt = 0;
  let accum = 0;
  // ページを開いた最初の一瞬だけ、ハートが灯っている。すぐにほどけて、ふつうの水面になる
  let introUntil = still || pressed ? 0 : 1.2;
  if (introUntil) {
    heart.raw = 1;
    heart.value = 1;
    heart.want = 1;
    heart.wasFull = true;
  }

  // ---- トークンを読む
  let motionMs = 240;
  const paint = new OffscreenCanvas(1, 1).getContext('2d', { willReadFrequently: true });
  const pal = {};
  const readPixel = (css) => {
    paint.clearRect(0, 0, 1, 1);
    paint.fillStyle = css;
    paint.fillRect(0, 0, 1, 1);
    return paint.getImageData(0, 0, 1, 1).data;
  };
  const labOf = (style, name) => {
    probe.style.color = style.getPropertyValue(name).trim();
    const d = readPixel(getComputedStyle(probe).color);
    return rgbToOklab(d[0], d[1], d[2]);
  };
  const readPalette = () => {
    const style = getComputedStyle(root);
    const bg = labOf(style, '--_bg');
    const surface = labOf(style, '--_surface');
    const fg = labOf(style, '--_fg');
    const muted = labOf(style, '--_muted');
    const accent = labOf(style, '--_accent');
    const lighter = bg[0] >= fg[0] ? bg : fg;
    const darker = bg[0] >= fg[0] ? fg : bg;

    pal.light = tone(Math.max(lighter[0], 0.97), lighter[1] * 0.3, lighter[2] * 0.3);
    pal.page = oklabToLinear(bg[0], bg[1], bg[2]).map((v) => clamp(v));
    pal.envLow = tone(0.34, darker[1] * 0.4, darker[2] * 0.4);

    // 水:--_muted の色相。灰色に近ければ、少しだけ鮮やかさを足す
    const mc = Math.hypot(muted[1], muted[2]);
    const dir = mc > 0.004 ? [muted[1] / mc, muted[2] / mc] : [-0.5, -0.5];
    const chroma = Math.max(mc * 2.1, 0.07);
    const wa = dir[0] * chroma;
    const wb = dir[1] * chroma;
    pal.deep = tone(0.21, wa, wb);
    pal.shallow = tone(0.35, wa * 1.15, wb * 1.15);
    pal.tint = tone(0.86, wa * 0.42, wb * 0.42);

    // 底の砂:水の色相に、--_surface を少しだけ
    const fa = surface[1] * 0.2 + wa * 0.72;
    const fb = surface[2] * 0.2 + wb * 0.72;
    pal.floorA = tone(0.4, fa, fb);
    pal.floorB = tone(0.62, fa * 0.82, fb * 0.82);

    // 輪の石:--_surface そのもの
    const sc = oklabToLinear(surface[0], surface[1], surface[2]).map((v) => clamp(v));
    pal.stone = sc;
    pal.vein = tone(Math.min(surface[0] + 0.3, 0.96), surface[1] * 0.7, surface[2] * 0.7);
    pal.fleck = tone(Math.max(surface[0] - 0.16, 0.06), surface[1], surface[2]);

    // 光:--_accent の色相
    pal.glow = tone(clamp(accent[0], 0.6, 0.72), accent[1] * 1.12, accent[2] * 1.12);
    pal.hot = tone(0.96, accent[1] * 0.3, accent[2] * 0.3);
    const lightMix = 0.1;
    pal.caustic = pal.light.map((v, i) => mix(v, pal.glow[i], lightMix));

    // 影
    probe.style.boxShadow = style.getPropertyValue('--_shadow').trim();
    const shadow = getComputedStyle(probe).boxShadow;
    probe.style.boxShadow = '';
    if (shadow && shadow !== 'none') {
      const d = readPixel(`${shadow.split(')')[0]})`);
      pal.shadowC = [d[0], d[1], d[2]].map((v) => toLinear(v / 255));
      pal.shadowA = clamp((d[3] / 255) * 1.9, 0, 0.95);
    } else {
      pal.shadowC = pal.envLow;
      pal.shadowA = 0;
    }

    // 動きの速さ
    probe.style.transitionDuration = style.getPropertyValue('--_motion').trim();
    const motion = parseFloat(getComputedStyle(probe).transitionDuration);
    probe.style.transitionDuration = '';
    motionMs = Number.isFinite(motion) && motion > 0 ? motion * 1000 : 240;
  };

  // ---- WebGL
  let gl = null;
  let ready = false;
  let softwareGl = false;
  try {
    gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, powerPreference: 'high-performance' });
  } catch {
    gl = null;
  }
  let vao = null;
  let progInit = null;
  let progSimA = null;
  let progSimB = null;
  let progCompose = null;
  let progCaustic = null;
  let progBake = null;
  let progSoften = null;
  let progView = null;
  const state = [null, null];
  const stateFb = [null, null];
  let cur = 0;
  let texH = null;
  let fbH = null;
  let texC = null;
  let fbC = null;
  let texRaw = null;
  let fbRaw = null;
  let fbMs = null;
  let rbMs = null;
  let texBake = null;
  let meshIdx = null;
  let meshCount = 0;
  let meshGrid = 0;
  let causticSize = 0;
  let bakeSize = 0;
  let waveTable = null;

  const compile = (type, source) => {
    const s = gl.createShader(type);
    gl.shaderSource(s, source);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || 'shader');
    return s;
  };
  const link = (vs, fs) => {
    const p = gl.createProgram();
    const v = compile(gl.VERTEX_SHADER, vs);
    const f = compile(gl.FRAGMENT_SHADER, fs);
    gl.attachShader(p, v);
    gl.attachShader(p, f);
    gl.linkProgram(p);
    gl.deleteShader(v);
    gl.deleteShader(f);
    if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) || 'link');
    const u = {};
    const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
    for (let i = 0; i < n; i++) {
      const info = gl.getActiveUniform(p, i);
      u[info.name.replace('[0]', '')] = gl.getUniformLocation(p, info.name);
    }
    return { p, u };
  };
  const makeTarget = (internal, size, filter, mips = false) => {
    const t = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, t);
    gl.texStorage2D(gl.TEXTURE_2D, mips ? Math.floor(Math.log2(size)) + 1 : 1, internal, size, size);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mips ? gl.LINEAR_MIPMAP_LINEAR : filter);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
    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);
    const fb = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
    const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
    return { t, fb, ok };
  };
  const bindTex = (unit, t) => {
    gl.activeTexture(gl.TEXTURE0 + unit);
    gl.bindTexture(gl.TEXTURE_2D, t);
  };

  // 風のさざ波の表:波数 x, y、角速度、位相
  const buildWaves = () => {
    const r = seeded(4211);
    const data = new Float32Array(WAVES * 4);
    for (let i = 0; i < WAVES; i++) {
      const a = i * 2.39996 + (r() - 0.5) * 0.5;
      const k = i < 2 ? mix(8, 11, r()) : mix(21, 54, Math.pow(r(), 1.3));
      data[i * 4] = Math.cos(a) * k;
      data[i * 4 + 1] = Math.sin(a) * k;
      data[i * 4 + 2] = Math.sqrt(0.62 * k) * (r() < 0.5 ? 1 : -1) * mix(0.8, 1.2, r());
      data[i * 4 + 3] = r() * 6.2831;
    }
    return data;
  };

  const setupGl = () => {
    if (!gl) return false;
    try {
      const fast = gl.getExtension('EXT_color_buffer_float');
      const half = gl.getExtension('EXT_color_buffer_half_float');
      if (!fast && !half) return false;
      const info = gl.getExtension('WEBGL_debug_renderer_info');
      const name = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : '';
      softwareGl = /swiftshader|llvmpipe|software/i.test(name);

      progInit = link(VERT, INIT_FRAG);
      progSimA = link(VERT, simFrag(false));
      progSimB = link(VERT, simFrag(true));
      progCompose = link(VERT, COMPOSE_FRAG);
      progCaustic = link(CAUSTIC_VERT, CAUSTIC_FRAG);
      progBake = link(VERT, BAKE_FRAG);
      progSoften = link(VERT, SOFTEN_FRAG);
      progView = link(VERT, VIEW_FRAG);

      vao = gl.createVertexArray();
      gl.bindVertexArray(vao);
      gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
      gl.disable(gl.DEPTH_TEST);
      gl.disable(gl.BLEND);

      // 波の計算は 32 ビットが望ましい。描けない機械では 16 ビットにする
      let internal = fast ? gl.RGBA32F : gl.RGBA16F;
      for (let i = 0; i < 2; i++) {
        let made = makeTarget(internal, SIM_N, gl.NEAREST);
        if (!made.ok && internal !== gl.RGBA16F) {
          gl.deleteTexture(made.t);
          gl.deleteFramebuffer(made.fb);
          internal = gl.RGBA16F;
          made = makeTarget(internal, SIM_N, gl.NEAREST);
        }
        if (!made.ok) return false;
        state[i] = made.t;
        stateFb[i] = made.fb;
      }
      const hTarget = makeTarget(gl.RGBA16F, SIM_N, gl.LINEAR);
      if (!hTarget.ok) return false;
      texH = hTarget.t;
      fbH = hTarget.fb;
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);

      // 水のある範囲を書き込む
      gl.useProgram(progInit.p);
      gl.uniform1f(progInit.u.uDx, (2 * DOMAIN) / SIM_N);
      gl.viewport(0, 0, SIM_N, SIM_N);
      for (let i = 0; i < 2; i++) {
        gl.bindFramebuffer(gl.FRAMEBUFFER, stateFb[i]);
        gl.drawArrays(gl.TRIANGLES, 0, 3);
      }
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);

      waveTable = waveTable || buildWaves();
      meshGrid = 0;
      causticSize = 0;
      bakeSize = 0;
      return true;
    } catch (error) {
      console.warn('like-pond: WebGL の用意ができないので、静かな絵にする', error);
      return false;
    }
  };

  // 網:大きく見せるほど細かくする
  const setupMesh = (grid) => {
    if (grid === meshGrid) return;
    if (meshIdx) gl.deleteBuffer(meshIdx);
    const row = grid + 1;
    const data = new Uint32Array(grid * grid * 6);
    let at = 0;
    for (let j = 0; j < grid; j++) {
      for (let i = 0; i < grid; i++) {
        const a = j * row + i;
        const b = a + 1;
        const c = a + row;
        const d = c + 1;
        // 向きを交互にして、斜めの向きの癖を消す
        if ((i + j) & 1) {
          data[at++] = a;
          data[at++] = b;
          data[at++] = d;
          data[at++] = a;
          data[at++] = d;
          data[at++] = c;
        } else {
          data[at++] = a;
          data[at++] = b;
          data[at++] = c;
          data[at++] = b;
          data[at++] = d;
          data[at++] = c;
        }
      }
    }
    meshIdx = gl.createBuffer();
    gl.bindVertexArray(vao);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, meshIdx);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, data, gl.STATIC_DRAW);
    meshCount = data.length;
    meshGrid = grid;
  };

  const setupCaustic = (size) => {
    if (size === causticSize) return;
    for (const t of [texC, texRaw]) if (t) gl.deleteTexture(t);
    for (const f of [fbC, fbRaw, fbMs]) if (f) gl.deleteFramebuffer(f);
    if (rbMs) gl.deleteRenderbuffer(rbMs);
    fbMs = null;
    rbMs = null;
    const raw = makeTarget(gl.RGBA16F, size, gl.LINEAR);
    texRaw = raw.t;
    fbRaw = raw.fb;
    const made = makeTarget(gl.RGBA16F, size, gl.LINEAR, true);
    texC = made.t;
    fbC = made.fb;
    // 細い光の線がとぎれないよう、できれば複数の点で測る
    const samples = gl.getInternalformatParameter(gl.RENDERBUFFER, gl.RGBA16F, gl.SAMPLES);
    const most = samples && samples.length ? samples[0] : 0;
    const want = Math.min(most, 4);
    if (want > 1 && !softwareGl) {
      rbMs = gl.createRenderbuffer();
      gl.bindRenderbuffer(gl.RENDERBUFFER, rbMs);
      gl.renderbufferStorageMultisample(gl.RENDERBUFFER, want, gl.RGBA16F, size, size);
      fbMs = gl.createFramebuffer();
      gl.bindFramebuffer(gl.FRAMEBUFFER, fbMs);
      gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, rbMs);
      if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
        gl.deleteFramebuffer(fbMs);
        gl.deleteRenderbuffer(rbMs);
        fbMs = null;
        rbMs = null;
      }
    }
    causticSize = size;
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  };

  const setupBake = (size) => {
    if (size <= bakeSize) return;
    if (texBake) gl.deleteTexture(texBake);
    const made = makeTarget(gl.RGBA8, size, gl.LINEAR, true);
    texBake = made.t;
    gl.useProgram(progBake.p);
    gl.uniform1f(progBake.u.uSize, size);
    gl.viewport(0, 0, size, size);
    gl.bindFramebuffer(gl.FRAMEBUFFER, made.fb);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.deleteFramebuffer(made.fb);
    gl.bindTexture(gl.TEXTURE_2D, texBake);
    gl.generateMipmap(gl.TEXTURE_2D);
    const aniso = gl.getExtension('EXT_texture_filter_anisotropic');
    if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, 4);
    bakeSize = size;
  };

  ready = setupGl();
  if (!ready) root.setAttribute('data-gl', 'off');

  // ---- 大きさ
  const shape = { size: 1, unit: 1, cssUnit: 1 };
  let renderScale = 1;
  const measure = () => {
    const box = canvas.getBoundingClientRect();
    const css = Math.max(box.width, 1);
    const dpr = Math.min(2, devicePixelRatio || 1) * renderScale;
    const size = Math.max(2, Math.min(3840, Math.round(css * dpr)));
    shape.cssUnit = css / 2 / HALF;
    shape.unit = size / 2 / HALF;
    if (size !== shape.size || canvas.width !== size) {
      shape.size = size;
      canvas.width = size;
      canvas.height = size;
    }
    if (ready) {
      const tier = size > 2000 ? 3 : size > 1200 ? 2 : size > 760 ? 1 : 0;
      setupMesh([200, 256, 384, 480][tier]);
      setupCaustic([768, 1024, 1536, 2048][tier]);
      setupBake([1024, 1536, 2048, 2048][tier]);
    }
  };

  // ---- 波を起こす
  const addSource = (x, y, amp, sigma, f0, f1, tau) => {
    if (sources.length >= MAX_SOURCES) sources.shift();
    sources.push({ x, y, amp, sigma, f0, f1, tau, t: 0, phase: 0 });
  };

  const launchDrop = (x, y) => {
    if (flights.length >= MAX_DROPS) {
      const small = flights.findIndex((d) => d.kind !== 0);
      flights.splice(small >= 0 ? small : 0, 1);
    }
    flights.push({ x, y, z: DROP_Z, vx: 0, vy: 0, vz: 0, r: 0.052, kind: 0 });
  };

  const impact = (d) => {
    if (d.kind === 0) {
      addSource(d.x, d.y, 0.00125, 0.052, 8.2, 4.8, 0.24);
      // はね返る水の柱と、小さな粒
      const r = seeded(Math.floor(d.x * 9973 + d.y * 7919 + clock * 131));
      const n = 3 + Math.floor(r() * 2);
      if (flights.length + n + 1 > MAX_DROPS) return;
      flights.push({ x: d.x, y: d.y, z: 0.001, vx: 0, vy: 0, vz: 6.3, r: 0.02, kind: 1, wait: 0.12 });
      for (let i = 0; i < n; i++) {
        const a = r() * Math.PI * 2;
        const s = mix(0.8, 1.5, r());
        flights.push({ x: d.x, y: d.y, z: 0.001, vx: Math.cos(a) * s, vy: Math.sin(a) * s, vz: mix(3.0, 4.4, r()), r: mix(0.006, 0.0105, r()), kind: 2, wait: 0.012 });
      }
    } else if (d.kind === 1) {
      addSource(d.x, d.y, 0.00062, 0.03, 11, 7, 0.13);
    } else {
      addSource(d.x, d.y, 0.00024, 0.016, 15, 11, 0.07);
    }
  };

  // ---- 数の表示。動きは毎フレームの計算で進める(止まっている間は進まない)
  const digitMoves = [];
  const makeDigit = (ch) => {
    const d = document.createElement('span');
    d.className = 'd';
    const v = document.createElement('span');
    v.className = 'v';
    v.textContent = ch;
    d.append(v);
    return d;
  };
  const moveDigit = (el, from, to, delay, drop) => {
    digitMoves.push({ el, from, to, t: -delay, dur: (motionMs * 2.4) / 1000, drop });
    el.style.transform = `translateY(${from}em)`;
    el.style.opacity = from === 0 ? '1' : '0';
  };
  const runDigits = (dt) => {
    for (let i = digitMoves.length - 1; i >= 0; i--) {
      const m = digitMoves[i];
      m.t += dt;
      const u = clamp(m.t / m.dur);
      const e = 1 - Math.pow(1 - u, 3);
      const y = mix(m.from, m.to, e);
      m.el.style.transform = `translateY(${y.toFixed(4)}em)`;
      m.el.style.opacity = (m.to === 0 ? e : 1 - e).toFixed(3);
      if (u >= 1) {
        digitMoves.splice(i, 1);
        if (m.drop) m.el.remove();
        else {
          m.el.style.transform = '';
          m.el.style.opacity = '';
        }
      }
    }
  };
  const setDigits = (value, delay) => {
    const next = String(value);
    const cells = [...numEl.querySelectorAll('.d')];
    if (cells.length !== next.length) {
      digitMoves.length = 0;
      numEl.replaceChildren(...[...next].map(makeDigit));
      return;
    }
    cells.forEach((cell, i) => {
      const v = cell.querySelector('.v');
      if (v.textContent === next[i]) return;
      const old = v.textContent;
      v.textContent = next[i];
      if (still || !ready) return;
      for (let j = digitMoves.length - 1; j >= 0; j--) {
        if (digitMoves[j].el.parentNode === cell) digitMoves.splice(j, 1);
      }
      cell.querySelector('.o')?.remove();
      const out = document.createElement('span');
      out.className = 'o';
      out.textContent = old;
      cell.append(out);
      const up = pressed ? -1 : 1;
      moveDigit(out, 0, up * 0.62, delay / 1000, true);
      moveDigit(v, -up * 0.62, 0, delay / 1000, false);
    });
  };
  numEl.replaceChildren(...[...String(count)].map(makeDigit));

  const reflectState = (delay) => {
    button.setAttribute('aria-pressed', pressed ? 'true' : 'false');
    const label = `いいね ${count}件`;
    button.setAttribute('aria-label', label);
    statusEl.textContent = label;
    setDigits(count, delay);
  };

  // ---- 押す
  const toPond = (clientX, clientY) => {
    const b = canvas.getBoundingClientRect();
    return [(clientX - (b.left + b.width / 2)) / shape.cssUnit, -(clientY - (b.top + b.height / 2)) / shape.cssUnit];
  };

  on(button, 'click', (event) => {
    const now = performance.now();
    const burst = now - lastClick < BURST_MS;
    lastClick = now;
    pressed = !pressed;
    count += pressed ? 1 : -1;
    if (still || !ready) {
      heart.raw = pressed ? 1 : 0;
      heart.value = heart.raw;
      heart.want = heart.raw;
      reflectState(0);
      wake();
      return;
    }
    reflectState(pressed ? FALL * 1000 : 0);

    if (pressed || burst) {
      // 落ちる場所:カーソルの下。連打では、少しずつ場所を散らす
      burstLevel = burst ? Math.min(burstLevel + 1, 6) : 0;
      let x = 0;
      let y = 0.02;
      if (event.detail > 0 && (event.clientX || event.clientY)) {
        const at = toPond(event.clientX, event.clientY);
        const len = Math.hypot(at[0], at[1]);
        const k = len > 0.82 ? 0.82 / len : 1;
        x = at[0] * k * 0.72;
        y = at[1] * k * 0.72;
      }
      burstAngle += 2.39996;
      const spread = burstLevel > 0 ? 0.2 + burstLevel * 0.075 : 0;
      x += Math.cos(burstAngle) * spread;
      y += Math.sin(burstAngle) * spread;
      const len = Math.hypot(x, y);
      if (len > 0.84) {
        x *= 0.84 / len;
        y *= 0.84 / len;
      }
      launchDrop(x, y);
      calm = 0;
    }
    if (pressed) {
      heart.gatherAt = now + FALL * 1000;
    } else {
      heart.want = 0;
      heart.wasFull = false;
      if (!burst) {
        calm = 1;
        glassy = 1;
      }
    }
    wake();
  });

  // カーソル:近づくと、水面がわずかに盛り上がる
  const BUMP_AMP = 0.026;
  const BUMP_SIG = 0.3;
  const trackPointer = (event) => {
    const at = toPond(event.clientX, event.clientY);
    const r = Math.hypot(at[0], at[1]);
    const k = r > 0.84 ? 0.84 / r : 1;
    pointer.tx = at[0] * k;
    pointer.ty = at[1] * k;
    if (!pointer.seen) {
      pointer.x = pointer.tx;
      pointer.y = pointer.ty;
      pointer.oldX = pointer.x;
      pointer.oldY = pointer.y;
      pointer.seen = true;
    }
    pointer.ampT = BUMP_AMP * smoothstep(1.66, 1.02, r);
    if (still) return;
    wake();
  };
  on(root, 'pointermove', trackPointer);
  on(root, 'pointerdown', trackPointer);
  on(root, 'pointerleave', () => {
    pointer.ampT = 0;
    pointer.seen = pointer.amp > 0.0004;
  });
  on(button, 'focus', () => {
    focusBump = button.matches(':focus-visible') ? 1 : 0;
    wake();
  });
  on(button, 'blur', () => {
    focusBump = 0;
    wake();
  });

  // ---- 1コマの計算
  const advance = (dt, now) => {
    clock += dt;
    runDigits(dt);

    // ハート
    if (introUntil && clock >= introUntil) {
      introUntil = 0;
      if (!pressed) {
        heart.want = 0;
        heart.wasFull = false;
      }
    }
    if (pressed && heart.gatherAt && now >= heart.gatherAt) {
      heart.want = 1;
      heart.gatherAt = 0;
    }
    const gather = clamp((motionMs * 5.6) / 1000, 0.7, 2.2);
    const undo = clamp((motionMs * 6.2) / 1000, 0.8, 2.4);
    if (heart.raw < heart.want) heart.raw = Math.min(heart.want, heart.raw + dt / gather);
    else if (heart.raw > heart.want) heart.raw = Math.max(heart.want, heart.raw - dt / undo);
    heart.value = heart.want > 0.5 ? smootherstep(heart.raw) : 1 - Math.pow(1 - heart.raw, 1.7) * (1 + 0.7 * heart.raw);
    heart.value = clamp(heart.value);
    if (heart.raw >= 1 && !heart.wasFull) {
      heart.wasFull = true;
      heart.flare = 1;
    }
    heart.flare *= Math.exp(-dt / 0.55);
    const looseWant = heart.want < 0.5 && heart.raw > 0 ? 1 : 0;
    heart.loose += (looseWant - heart.loose) * (1 - Math.exp(-dt * 5));

    // 静まる
    calm = Math.max(0, calm - dt / 1.5);
    glassy = Math.max(0, glassy - dt / 4);

    // カーソル
    pointer.oldX = pointer.x;
    pointer.oldY = pointer.y;
    pointer.oldAmp = pointer.amp;
    const followed = 1 - Math.exp(-dt * 8.5);
    const wantAmp = Math.max(pointer.ampT, focusBump ? BUMP_AMP * 0.6 : 0);
    const wantX = pointer.ampT > 0 ? pointer.tx : focusBump ? 0 : pointer.x;
    const wantY = pointer.ampT > 0 ? pointer.ty : focusBump ? 0 : pointer.y;
    pointer.x += (wantX - pointer.x) * followed;
    pointer.y += (wantY - pointer.y) * followed;
    pointer.amp += (wantAmp - pointer.amp) * (1 - Math.exp(-dt * 4.5));

    // 宙の滴
    for (let i = flights.length - 1; i >= 0; i--) {
      const d = flights[i];
      if (d.wait > 0) {
        d.wait -= dt;
        continue;
      }
      d.vz -= GRAVITY * dt;
      d.z += d.vz * dt;
      d.x += d.vx * dt;
      d.y += d.vy * dt;
      if (d.z <= 0 && d.vz < 0) {
        flights.splice(i, 1);
        impact(d);
      }
    }
  };

  const dropData = new Float32Array(MAX_DROPS * 4);
  const srcData = new Float32Array(MAX_SOURCES * 4);
  const packSources = (dt) => {
    let n = 0;
    for (let i = sources.length - 1; i >= 0; i--) {
      const s = sources[i];
      s.t += dt;
      if (s.t > s.tau * 5) {
        sources.splice(i, 1);
        continue;
      }
    }
    for (const s of sources) {
      const u = clamp(s.t / (s.tau * 2.5));
      const f = mix(s.f0, s.f1, u);
      s.phase += 2 * Math.PI * f * dt;
      const a = -Math.sin(s.phase + 0.5) * Math.exp(-s.t / s.tau) * s.amp * (dt * 60);
      srcData[n * 4] = s.x;
      srcData[n * 4 + 1] = s.y;
      srcData[n * 4 + 2] = a;
      srcData[n * 4 + 3] = s.sigma;
      n += 1;
    }
    return n;
  };

  const simulate = (dt) => {
    accum += dt;
    injectDt += dt;
    let n = Math.floor(accum / STEP);
    accum -= n * STEP;
    if (n > MAX_STEPS) {
      n = MAX_STEPS;
      accum = 0;
    }
    if (n === 0) return;
    const dx = (2 * DOMAIN) / SIM_N;
    const c = (WAVE_SPEED * STEP) / dx;
    const c2 = c * c;
    const damp = mix(0.9993, 0.986, calm * calm);
    gl.viewport(0, 0, SIM_N, SIM_N);
    gl.disable(gl.BLEND);
    for (let i = 0; i < n; i++) {
      const prog = i === 0 ? progSimB : progSimA;
      gl.useProgram(prog.p);
      gl.uniform1i(prog.u.uState, 0);
      gl.uniform1f(prog.u.uC2, c2);
      gl.uniform1f(prog.u.uNu, 0.014);
      gl.uniform1f(prog.u.uDamp, damp);
      gl.uniform1f(prog.u.uDx, dx);
      if (i === 0) {
        const count2 = packSources(injectDt);
        injectDt = 0;
        gl.uniform1i(prog.u.uSrcN, count2);
        if (count2 > 0) gl.uniform4fv(prog.u.uSrc, srcData);
        gl.uniform4f(prog.u.uBumpNow, pointer.x, pointer.y, pointer.amp, BUMP_SIG);
        gl.uniform4f(prog.u.uBumpOld, pointer.oldX, pointer.oldY, pointer.oldAmp, BUMP_SIG);
      }
      bindTex(0, state[cur]);
      gl.bindFramebuffer(gl.FRAMEBUFFER, stateFb[1 - cur]);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      cur = 1 - cur;
    }
  };

  const compose = () => {
    gl.useProgram(progCompose.p);
    gl.uniform1i(progCompose.u.uState, 0);
    gl.uniform1f(progCompose.u.uDx, (2 * DOMAIN) / SIM_N);
    gl.uniform1f(progCompose.u.uTime, clock);
    gl.uniform1f(progCompose.u.uBreeze, mix(1, 0.62, smoothstep(0, 0.6, glassy)));
    gl.uniform4f(progCompose.u.uBump, pointer.x, pointer.y, pointer.amp, BUMP_SIG);
    gl.uniform4fv(progCompose.u.uWave, waveTable);
    bindTex(0, state[cur]);
    gl.viewport(0, 0, SIM_N, SIM_N);
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbH);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  };

  const causticPass = () => {
    gl.useProgram(progCaustic.p);
    gl.uniform1i(progCaustic.u.uH, 0);
    gl.uniform1i(progCaustic.u.uGrid, meshGrid);
    gl.uniform1f(progCaustic.u.uOptic, 2.2);
    bindTex(0, texH);
    gl.viewport(0, 0, causticSize, causticSize);
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbMs || fbRaw);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE);
    gl.bindVertexArray(vao);
    gl.drawElements(gl.TRIANGLES, meshCount, gl.UNSIGNED_INT, 0);
    gl.disable(gl.BLEND);
    if (fbMs) {
      gl.bindFramebuffer(gl.READ_FRAMEBUFFER, fbMs);
      gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, fbRaw);
      gl.blitFramebuffer(0, 0, causticSize, causticSize, 0, 0, causticSize, causticSize, gl.COLOR_BUFFER_BIT, gl.NEAREST);
      gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
      gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
    }
    gl.useProgram(progSoften.p);
    gl.uniform1i(progSoften.u.uRaw, 0);
    gl.uniform1f(progSoften.u.uStep, 1 / causticSize);
    bindTex(0, texRaw);
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbC);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    bindTex(0, texC);
    gl.generateMipmap(gl.TEXTURE_2D);
  };

  const setVec3 = (u, name, v) => gl.uniform3f(u[name], v[0], v[1], v[2]);
  const viewPass = () => {
    const { p, u } = progView;
    gl.useProgram(p);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, shape.size, shape.size);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    bindTex(0, texH);
    bindTex(1, texC);
    bindTex(2, texBake);
    gl.uniform1i(u.uH, 0);
    gl.uniform1i(u.uCaustic, 1);
    gl.uniform1i(u.uBake, 2);
    gl.uniform2f(u.uRes, shape.size, shape.size);
    gl.uniform2f(u.uHSize, SIM_N, SIM_N);
    gl.uniform1f(u.uUnit, shape.unit);
    gl.uniform1f(u.uTime, clock);
    gl.uniform1f(u.uHeart, heart.value);
    gl.uniform1f(u.uFlare, heart.flare);
    gl.uniform1f(u.uLoose, heart.loose);
    setVec3(u, 'uDeep', pal.deep);
    setVec3(u, 'uShallow', pal.shallow);
    setVec3(u, 'uTint', pal.tint);
    setVec3(u, 'uFloorA', pal.floorA);
    setVec3(u, 'uFloorB', pal.floorB);
    setVec3(u, 'uStone', pal.stone);
    setVec3(u, 'uVein', pal.vein);
    setVec3(u, 'uFleck', pal.fleck);
    setVec3(u, 'uLight', pal.light);
    setVec3(u, 'uEnvLow', pal.envLow);
    setVec3(u, 'uPage', pal.page);
    setVec3(u, 'uGlow', pal.glow);
    setVec3(u, 'uHot', pal.hot);
    setVec3(u, 'uCausticC', pal.caustic);
    setVec3(u, 'uShadowC', pal.shadowC);
    gl.uniform1f(u.uShadowA, pal.shadowA);
    let n = 0;
    for (const d of flights) {
      if (d.wait > 0 || n >= MAX_DROPS) continue;
      dropData[n * 4] = d.x;
      dropData[n * 4 + 1] = d.y;
      dropData[n * 4 + 2] = Math.max(d.z, 0);
      dropData[n * 4 + 3] = d.r;
      n += 1;
    }
    gl.uniform1i(u.uDropN, n);
    if (n > 0) gl.uniform4fv(u.uDrop, dropData);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  };

  // ---- 描く
  let raf = 0;
  let visible = true;
  let pageShown = !root.ownerDocument.hidden;
  let last = performance.now();
  let slow = 0;

  const paintAll = () => {
    gl.bindVertexArray(vao);
    compose();
    causticPass();
    viewPass();
  };

  const tick = () => {
    raf = 0;
    if (!visible || !pageShown || !ready) return;
    const now = performance.now();
    const wall = now - last;
    const dt = Math.min(0.05, wall / 1000);
    last = now;
    if (still) {
      paintAll();
      return;
    }
    advance(dt, now);
    gl.bindVertexArray(vao);
    simulate(dt);
    paintAll();
    // 重い機械では、絵の細かさを下げて 60fps を守る
    if (!softwareGl && wall < 200) {
      slow = wall > 21 ? slow + 1 : Math.max(0, slow - 1);
      if (slow > 36 && renderScale > 0.55) {
        renderScale = Math.max(0.55, renderScale - 0.15);
        slow = 0;
        measure();
      }
    }
    raf = requestAnimationFrame(tick);
  };
  const wake = () => {
    if (raf || !visible || !pageShown || !ready) return;
    last = performance.now();
    raf = requestAnimationFrame(tick);
  };

  // ---- 見えていないときは止める
  const io = new IntersectionObserver((entries) => {
    visible = entries.some((e) => e.isIntersecting);
    if (visible) wake();
    else if (raf) {
      cancelAnimationFrame(raf);
      raf = 0;
    }
  });
  io.observe(root);
  on(root.ownerDocument, 'visibilitychange', () => {
    pageShown = !root.ownerDocument.hidden;
    if (pageShown) wake();
    else if (raf) {
      cancelAnimationFrame(raf);
      raf = 0;
    }
  });

  const ro = new ResizeObserver(() => {
    measure();
    wake();
  });
  ro.observe(root);

  on(motionQuery, 'change', () => {
    still = motionQuery.matches;
    if (still) {
      flights.length = 0;
      sources.length = 0;
      introUntil = 0;
      heart.raw = pressed ? 1 : 0;
      heart.want = heart.raw;
      heart.value = heart.raw;
      heart.flare = 0;
      heart.loose = 0;
      pointer.amp = 0;
      pointer.ampT = 0;
      if (ready) {
        // 波を消して、凪の絵にする
        gl.useProgram(progInit.p);
        gl.uniform1f(progInit.u.uDx, (2 * DOMAIN) / SIM_N);
        gl.viewport(0, 0, SIM_N, SIM_N);
        gl.bindVertexArray(vao);
        for (let i = 0; i < 2; i++) {
          gl.bindFramebuffer(gl.FRAMEBUFFER, stateFb[i]);
          gl.drawArrays(gl.TRIANGLES, 0, 3);
        }
      }
    }
    wake();
  });
  on(schemeQuery, 'change', () => {
    readPalette();
    wake();
  });

  const releaseGl = () => {
    if (!gl) return;
    for (const prog of [progInit, progSimA, progSimB, progCompose, progCaustic, progBake, progSoften, progView]) {
      if (prog) gl.deleteProgram(prog.p);
    }
    for (const t of [state[0], state[1], texH, texC, texRaw, texBake]) if (t) gl.deleteTexture(t);
    for (const f of [stateFb[0], stateFb[1], fbH, fbC, fbRaw, fbMs]) if (f) gl.deleteFramebuffer(f);
    if (rbMs) gl.deleteRenderbuffer(rbMs);
    if (meshIdx) gl.deleteBuffer(meshIdx);
    if (vao) gl.deleteVertexArray(vao);
    state[0] = state[1] = null;
    stateFb[0] = stateFb[1] = null;
    texH = fbH = texC = fbC = texRaw = fbRaw = fbMs = rbMs = texBake = meshIdx = vao = null;
  };

  on(canvas, 'webglcontextlost', (event) => {
    event.preventDefault();
    ready = false;
    if (raf) {
      cancelAnimationFrame(raf);
      raf = 0;
    }
  });
  on(canvas, 'webglcontextrestored', () => {
    ready = setupGl();
    if (ready) {
      measure();
      root.removeAttribute('data-gl');
      wake();
    } else {
      root.setAttribute('data-gl', 'off');
    }
  });

  // ---- 組み立て
  readPalette();
  measure();
  if (ready) {
    // 最初の絵を、はじめのフレームまでに用意する
    gl.bindVertexArray(vao);
    compose();
    causticPass();
    viewPass();
  }
  wake();

  return () => {
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    visible = false;
    ready = false;
    io.disconnect();
    ro.disconnect();
    for (const off of cleanups) off();
    digitMoves.length = 0;
    if (gl) {
      releaseGl();
      const lose = gl.getExtension('WEBGL_lose_context');
      if (lose) lose.loseContext();
    }
  };
}
mount(document.querySelector('[data-ld-part="like-pond"]'));
</script>

@Lattice_Node のほかの作品