プリズムのタブ

部品 · タブ

見られた数 1,039コピーされた数 25

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

<!-- UI by @Lattice_Node — ludion.ai/p/tabs-prism (MIT) -->
<div data-ld-part="tabs-prism" lang="ja">
  <canvas class="scene" aria-hidden="true"></canvas>
  <div class="tabs" role="tablist" aria-label="期間">
    <button class="tab" type="button" role="tab" id="tabs-prism-day" aria-selected="false" aria-controls="tabs-prism-panel" tabindex="-1"><span class="word">日</span></button>
    <button class="tab" type="button" role="tab" id="tabs-prism-week" aria-selected="true" aria-controls="tabs-prism-panel"><span class="word">週</span></button>
    <button class="tab" type="button" role="tab" id="tabs-prism-month" aria-selected="false" aria-controls="tabs-prism-panel" tabindex="-1"><span class="word">月</span></button>
    <button class="tab" type="button" role="tab" id="tabs-prism-year" aria-selected="false" aria-controls="tabs-prism-panel" tabindex="-1"><span class="word">年</span></button>
  </div>
  <div class="panel" id="tabs-prism-panel" role="tabpanel" aria-labelledby="tabs-prism-week">週ごとの表示</div>
  <span class="probe" aria-hidden="true"></span>
</div>
<style>
/* UI by @Lattice_Node — ludion.ai/p/tabs-prism (MIT) */
[data-ld-part="tabs-prism"] {
  /* 固定しないトークン。部屋、机、文字、虹は、この5色の明るさと色相の計算で作る */
  --_bg: var(--ld-bg, #0b0c10);
  --_surface: var(--ld-surface, #20222b);
  --_fg: var(--ld-fg, #f6f3ec);
  --_muted: var(--ld-muted, #8e8f9c);
  --_accent: var(--ld-accent, #ff4a38);
  --_radius: var(--ld-radius, 14px);
  --_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 24px 64px rgb(0 0 0 / .45));
  --_motion: var(--ld-motion, 240ms);

  position: relative;
  display: block;
  box-sizing: border-box;
  /* どの枠にも収まる */
  width: min(100%, calc((100vh - 64px) * 1.7778));
  aspect-ratio: 16 / 9;
  font-family: var(--_font);
  color: var(--_fg);
}

@media (max-aspect-ratio: 1 / 1) {
  [data-ld-part="tabs-prism"] {
    width: min(100%, calc((100vh - 64px) * 1.25));
    aspect-ratio: 5 / 4;
  }
}

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

[data-ld-part="tabs-prism"] .scene {
  position: absolute;
  top: 0;
  left: 0;
  display: block;
  width: 100%;
  height: 100%;
  border-radius: var(--_radius);
  pointer-events: none;
}

/* 本物のタブ。絵は canvas が描き、ここは押す場所と読み上げ */
[data-ld-part="tabs-prism"] .tabs {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--gutter, 8%);
  right: var(--gutter, 8%);
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

[data-ld-part="tabs-prism"] .tab {
  position: relative;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--_muted);
  font: 400 clamp(20px, 4vw, 44px) / 1 var(--_font);
  cursor: pointer;
  appearance: none;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}

[data-ld-part="tabs-prism"] .tab[aria-selected="true"] {
  color: var(--_fg);
}

[data-ld-part="tabs-prism"] .word {
  display: inline-block;
  padding-bottom: var(--_space);
  border-bottom: 2px solid transparent;
}

[data-ld-part="tabs-prism"] .tab[aria-selected="true"] .word {
  border-bottom-color: var(--_accent);
}

[data-ld-part="tabs-prism"] .tab:focus {
  outline: none;
}

[data-ld-part="tabs-prism"] .tab:focus-visible {
  outline: 2px solid var(--_accent);
  outline-offset: calc(var(--_space) * -.5);
  border-radius: var(--_radius);
}

/* WebGL が動くときは、文字を絵に任せる。読み上げのために要素は残す */
[data-ld-part="tabs-prism"][data-gl] .word,
[data-ld-part="tabs-prism"] .panel {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* フォーカスの輪は、文字のまわりに置く。場所は part.js が絵に合わせて渡す */
[data-ld-part="tabs-prism"][data-gl] .tab:focus-visible {
  outline: none;
}

[data-ld-part="tabs-prism"][data-gl] .tab:focus-visible::after {
  content: "";
  position: absolute;
  top: var(--label-y, 42%);
  left: 50%;
  width: var(--label-size, 60%);
  aspect-ratio: 1;
  border-radius: var(--_radius);
  outline: 2px solid var(--_accent);
  outline-offset: calc(var(--_space) * .25);
  transform: translate(-50%, -50%);
}

@media (forced-colors: active) {
  [data-ld-part="tabs-prism"] .tab[aria-selected="true"] {
    outline: 2px solid currentColor;
  }
}

@media (prefers-reduced-motion: reduce) {
  [data-ld-part="tabs-prism"] .tab {
    transition: none;
  }
}
</style>
<script type="module">
/* UI by @Lattice_Node — ludion.ai/p/tabs-prism (MIT) */
// part.js — ESモジュール。rootの中だけを触る
// プリズムのタブ:4つの切り替え。選ばれた項目の下に、逆三角のガラスが置いてある。
//
// 光学:断面は頂角70°の逆三角で、足だけ平らに切ってある。横から来た光は腹の面で曲がり、
//       反対の腹で全反射して、上面からほぼ真上へ出る。左右どちらから来ても同じ道を鏡に写して通る。
//       屈折率を波長ごとに変えて道を計算するので、ガラスの中で白い線が少しずつ分かれる。
// 描画:HDR の面に部屋(壁、机、文字、虹、光の筋)を解析的に描き、次にガラスだけを別の短いパスで光線追跡する。
//       ガラスから出た光線は、部屋を描いた面を引く。最後に光のにじみを足して、8bit へ落とす。
// 色  :JS に色を書かない。--_bg、--_surface、--_fg、--_muted は線形の値にして使う。
//       虹は --_accent の色相を回して作り、白はその虹を全部足して作る。

const CELLS = 4;

// ---- 世界。単位は「1項目の幅」。壁は z=0、机は y=0、手前が +z
const PA = 0.355; // 上面の半分の幅
const PF = 0.045; // 足の半分の幅
const APEX = (70 * Math.PI) / 180;
const PH = (PA - PF) / Math.tan(APEX / 2); // 高さ
const ZB = 0.06; // 奥の面
const ZF = 0.44; // 手前の面(上の辺)
const TILT = (16 * Math.PI) / 180; // 手前の面は宝石のように上を向く
const ZM = 0.25; // 光の通る面
const BEAM_Y = 0.095; // 光の高さ
const LABEL_Y = 0.98; // 文字の中心の高さ
const GLYPH_S = 0.62; // 文字の枠の大きさ
const CAM = [0, 1.7, 6];
const TURN = (275 * Math.PI) / 180; // 虹の色相の幅
const REST_BEAM = 0.2; // 残光の強さ
const REST_FAN = 0.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 smooth = (a, b, x) => {
  const t = clamp((x - a) / (b - a));
  return t * t * (3 - 2 * t);
};
const f = (v) => v.toFixed(6);

// ---- 色の計算
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
function linearToOklab([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 hueTone(h, C, L) {
  let chroma = C;
  for (let i = 0; i < 24; i++) {
    const rgb = oklabToLinear(L, Math.cos(h) * chroma, Math.sin(h) * chroma);
    if (rgb.every((v) => v >= 0 && v <= 1)) return rgb;
    chroma *= 0.93;
  }
  return oklabToLinear(L, 0, 0).map((v) => clamp(v));
}
const luminance = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;

// ---- 光の道:左から来た水平な光を、屈折率 n で追う(断面の2次元)
function refract2(i, n, eta) {
  const c = i[0] * n[0] + i[1] * n[1];
  const k = 1 - eta * eta * (1 - c * c);
  if (k < 0) return null;
  const t = eta * c + Math.sqrt(k);
  return [eta * i[0] - t * n[0], eta * i[1] - t * n[1]];
}
const BELLY_LEN = Math.hypot(PH, PA - PF);
const N_RIGHT = [PH / BELLY_LEN, -(PA - PF) / BELLY_LEN];
const N_LEFT = [-N_RIGHT[0], N_RIGHT[1]];
function tracePath(n) {
  const E = [-(PF + ((PA - PF) * BEAM_Y) / PH), BEAM_Y];
  const d1 = refract2([1, 0], N_LEFT, 1 / n);
  const plane = PF * N_RIGHT[0];
  const s = (plane - (E[0] * N_RIGHT[0] + E[1] * N_RIGHT[1])) / (d1[0] * N_RIGHT[0] + d1[1] * N_RIGHT[1]);
  const T = [E[0] + d1[0] * s, E[1] + d1[1] * s];
  const dn = d1[0] * N_RIGHT[0] + d1[1] * N_RIGHT[1];
  const d2 = [d1[0] - 2 * dn * N_RIGHT[0], d1[1] - 2 * dn * N_RIGHT[1]];
  const s2 = (PH - T[1]) / d2[1];
  const X = [T[0] + d2[0] * s2, PH];
  const d3 = refract2(d2, [0, -1], n) ?? d2;
  // 上面の内側で弱く跳ね返り、腹まで届く光
  const d4 = [d2[0], -d2[1]];
  let reach = 1e6;
  for (const normal of [N_LEFT, N_RIGHT]) {
    const dn4 = d4[0] * normal[0] + d4[1] * normal[1];
    if (dn4 > 1e-5) reach = Math.min(reach, (plane - (X[0] * normal[0] + X[1] * normal[1])) / dn4);
  }
  const G = [X[0] + d4[0] * reach, X[1] + d4[1] * reach];
  return { E, T, X, G, angle: Math.atan2(d3[0], d3[1]) };
}
const IOR = [0, 1, 2, 3, 4].map((k) => 1.47 + 0.3 * ((k + 0.5) / 5));
const ENV_IOR = [0, 1, 2, 3, 4].map((k) => 1.585 + 0.05 * ((k + 0.5) / 5));
const PATHS = IOR.map(tracePath);
const MEAN_PATH = tracePath(IOR[2]);
const LEAN = MEAN_PATH.angle; // 左から来たとき、虹が傾く角(真上が 0)
const XOUT = MEAN_PATH.X[0]; // 上面から出る場所
const BEAM_ENTRY = MEAN_PATH.E[0];
// ---- シェーダー
const VERT = `#version 300 es
in vec2 aPos;
out vec2 vUv;
void main(){
  vUv=aPos*.5+.5;
  gl_Position=vec4(aPos,0.,1.);
}`;

// 1枚の面に、壁の細かな凹凸を焼いておく。繰り返しても継ぎ目が出ない周期のノイズ
const BAKE = `#version 300 es
precision highp float;
precision highp int;
in vec2 vUv;
out vec4 O;
float hash(ivec2 p,int s){
  uint x=uint(p.x)*1597334677u^uint(p.y)*3812015801u^uint(s)*2798796415u;
  x^=x>>16;x*=2246822519u;x^=x>>13;x*=3266489917u;x^=x>>16;
  return float(x)/4294967295.;
}
vec2 grad(ivec2 p,int per,int s){
  p=ivec2(mod(vec2(p),float(per)));
  float a=6.2831853*hash(p,s);
  return vec2(cos(a),sin(a));
}
float pn(vec2 x,int per,int s){
  vec2 fl=floor(x);
  ivec2 i=ivec2(fl);
  vec2 g=x-fl;
  vec2 u=g*g*g*(g*(g*6.-15.)+10.);
  float a=dot(grad(i,per,s),g);
  float b=dot(grad(i+ivec2(1,0),per,s),g-vec2(1.,0.));
  float c=dot(grad(i+ivec2(0,1),per,s),g-vec2(0.,1.));
  float d=dot(grad(i+ivec2(1,1),per,s),g-vec2(1.,1.));
  return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);
}
float relief(vec2 uv){
  float h=0.,a=.5;
  int per=64;
  for(int i=0;i<4;i++){
    h+=a*pn(uv*float(per),per,i+1);
    per*=2;
    a*=.72;
  }
  return h;
}
void main(){
  float e=1./1024.;
  float h=relief(vUv);
  vec2 n=vec2(h-relief(vUv+vec2(e,0.)),h-relief(vUv+vec2(0.,e)))*14.;
  float low=0.,a=.6;
  int per=3;
  for(int i=0;i<4;i++){
    low+=a*pn(vUv*float(per),per,i+17);
    per*=2;
    a*=.55;
  }
  O=vec4(clamp(n*.5+.5,0.,1.),clamp(low*.9+.5,0.,1.),clamp(h*1.1+.5,0.,1.));
}`;

const headFor = (floatOK) => `#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
const float PA=${f(PA)},PF=${f(PF)},PH=${f(PH)},ZB=${f(ZB)},ZF=${f(ZF)},ZM=${f(ZM)};
const float BEAM_Y=${f(BEAM_Y)},LABEL_Y=${f(LABEL_Y)},GLYPH_S=${f(GLYPH_S)};
float sat(float x){return clamp(x,0.,1.);}
float dot2(vec2 v){return dot(v,v);}
float lumOf(vec3 c){return dot(c,vec3(.2126,.7152,.0722));}
float softBox(vec2 p,vec2 hs,float e){
  vec2 d=abs(p)-hs;
  return (1.-smoothstep(-e,e,d.x))*(1.-smoothstep(-e,e,d.y));
}
vec3 enc(vec3 c){return ${floatOK ? 'c' : 'sqrt(max(c,vec3(0.))/8.)'};}
vec3 dec(vec3 c){return ${floatOK ? 'c' : 'c*c*8.'};}
`;

// 虹:壁にも空気にも、ガラスの手前にも同じ式で描く
const FAN_GLSL = `uniform vec4 uFanA[2],uFanB[2];
uniform vec3 uFanC[2];
uniform vec4 uVig;
uniform float uTime;
// 虹:扇の面の上の場所から、届く光を求める
vec3 fanField(vec2 p,vec4 A,vec4 B,vec3 C,float soft){
  if(B.x<=.0005) return vec3(0.);
  vec2 d=p-A.xy;
  if(d.y<=0.) return vec3(0.);
  float r=length(d);
  float ang=atan(d.x,d.y)-A.z;
  float tt=ang/A.w;
  float aa=soft/(r*A.w)+.015;
  float at=abs(tt);
  if(at>1.+aa*2.) return vec3(0.);
  float win=1.-smoothstep(.62-aa,1.+aa,at);
  win*=1.-.28*at*at;
  float rr=r-C.x;
  float rev=1.-smoothstep(B.y-.55,B.y,rr);
  if(rev<=0.) return vec3(0.);
  float start=smoothstep(PH-soft,PH+.01+soft,p.y)*(1.-smoothstep(uVig.y+uVig.w-.95,uVig.y+uVig.w-.12,p.y));
  float t=clamp(.5+.5*tt*B.z,0.,1.);
  vec3 c=textureLod(uSpec,vec2(t,.5),0.).rgb;
  // 空気とガラスの揺らぎが作る、放射状の細い筋
  float st=textureLod(uTex,vec2(ang*2.3+B.w,uTime*.004+B.w),1.).b;
  float st2=textureLod(uTex,vec2(ang*9.1-B.w,.37-uTime*.003),0.).a;
  float streak=.84+.3*st+.2*(st2-.5);
  float fall=1./(1.+rr*.62);
  return c*win*rev*start*fall*streak*B.x;
}`;

// 部屋:壁、机、文字、虹、光の筋
const sceneFrag = (head) => `${head}
in vec2 vUv;
out vec4 O;
uniform sampler2D uTex,uSdf,uSpec;
uniform vec3 uCam,uBg,uSurf,uFg,uMuted,uBeamCol;
uniform vec4 uView,uAct,uHov;
uniform vec3 uLamp;
uniform float uPx,uPxW,uLod,uBgLum,uBeamEdge;
uniform vec4 uBeam[2];
uniform float uTip[2];
${FAN_GLSL}

uint hashU(ivec2 p){
  uint x=uint(p.x)*1597334677u^uint(p.y)*3812015801u;
  x^=x>>16;x*=2246822519u;x^=x>>13;x*=3266489917u;x^=x>>16;
  return x;
}
vec3 hash3(ivec2 p){
  uint x=hashU(p);
  return vec3(float(x&1023u),float((x>>10)&1023u),float((x>>20)&1023u))/1023.;
}

// 枠の縁で、必ず地の色に戻る
float vigAt(vec2 w){
  return 1.-smoothstep(.2,1.,length((w-uVig.xy)/uVig.zw));
}

// 文字の面からの距離(世界の単位。内側が負)
float glyphD(vec2 p,out float cell){
  float c=clamp(floor(p.x+2.),0.,3.);
  cell=c;
  vec2 q=(p-vec2(c-1.5,LABEL_Y))/GLYPH_S+.5;
  vec2 cq=clamp(q,.004,.996);
  float d=textureLod(uSdf,vec2((c+cq.x)/4.,cq.y),0.).r;
  return (d+length(q-cq))*GLYPH_S;
}

// 白い光の筋
vec3 beamField(vec2 p,vec4 B,float tip,float soft){
  if(B.z<=.0005) return vec3(0.);
  float side=B.y;
  float xs=-side*uBeamEdge;
  float u=(p.x-xs)*side;
  float uh=(B.x-xs)*side;
  if(u<0.) return vec3(0.);
  float end=1.-smoothstep(uh-.002-soft*9.-tip*.3,uh+.002,u);
  float endH=1.-smoothstep(uh-.03,uh+.2,u);
  if(endH<=0.) return vec3(0.);
  float dy=p.y-BEAM_Y;
  float w=max(B.w,uPxW*.7)+soft;
  float core=exp(-dy*dy/(w*w));
  float halo=exp(-abs(dy)/(w*4.))*.1+exp(-abs(dy)/(w*16.))*.012;
  float air=.88+.24*textureLod(uTex,vec2(p.x*.21+uTime*.009,.41),0.).b;
  return uBeamCol*B.z*(core*end*2.4*(1.+tip*1.6*exp(-max(uh-u,0.)/.16))+halo*endH*1.5)*air;
}

// 塵:虹の中でだけ光る
float motes(vec2 p){
  float acc=0.;
  float px=uPxW;
  for(int i=0;i<2;i++){
    float sc=i==0?7.:13.;
    vec2 q=p*sc+vec2(sin(uTime*.05+float(i)*2.1)*.7,-uTime*(.09+.05*float(i)));
    vec2 fl=floor(q);
    vec2 g=q-fl-.5;
    vec3 h=hash3(ivec2(fl)+ivec2(i*37,i*11));
    if(h.z<.5) continue;
    vec2 c=(h.xy-.5)*.72;
    float r=length(g-c)/sc;
    float sz=max(.0011+.0022*h.x*h.x,px*.7);
    float tw=.45+.55*sin(uTime*(.5+h.y*1.1)+h.x*6.283);
    acc+=exp(-r*r/(sz*sz))*max(tw,0.)*(.5+.5*h.y);
  }
  return acc;
}

vec3 wallShade(vec2 p,float soft){
  vec4 a=textureLod(uTex,p*.714,uLod);
  vec4 b=textureLod(uTex,vec2(p.y,-p.x)*.263+.37,uLod+.5);
  vec2 nr=(a.rg-.5)*2.;
  float pool=exp(-dot2((p-vec2(uPx*.35,.95))/vec2(2.4,1.5)));
  float vg=vigAt(p);
  float vs=sat(vg*2.2);
  vec3 base=mix(uBg,uSurf,vg*(.45+.55*pool));
  float quiet=1.-.72*uBgLum;
  base*=1.+(b.b-.5)*.34*vs*quiet;
  base*=1.+dot(nr,vec2(.14,.62))*.3*vs*quiet;
  base*=mix(1.,.7+.3*smoothstep(0.,.55,p.y),vs*(1.-.5*uBgLum));
  base*=1.-.34*vs*quiet*softBox(p-vec2(uPx,.2),vec2(.24,.2),.22);
  base+=uSurf*uLamp.z*.75*exp(-dot2(p-uLamp.xy)/.42);

  // 光の筋が壁をかすめる
  for(int i=0;i<2;i++){
    if(uBeam[i].z>.0005){
      float side=uBeam[i].y;
      float u=(p.x+side*uBeamEdge)*side;
      float uh=(uBeam[i].x+side*uBeamEdge)*side;
      float ax=1.-smoothstep(uh-.06,uh+.12,u);
      base+=uBeamCol*uBeam[i].z*ax*.0045*exp(-abs(p.y-BEAM_Y)/.16);
    }
  }

  float cell;
  float gd=glyphD(p,cell);
  float cellDummy;
  float gdS=glyphD(p+vec2(.0,.018),cellDummy);
  base*=1.-(.5-.3*uBgLum)*(1.-smoothstep(-.004,.05+soft,gdS));

  // 虹が壁をなめる。細かな凹凸が、斜めの光で浮かぶ
  vec3 E=vec3(0.);
  vec2 lightDir=vec2(0.,1.);
  float lw=0.;
  for(int i=0;i<2;i++){
    vec3 e=fanField(p,uFanA[i],uFanB[i],uFanC[i],soft);
    float l=lumOf(e);
    if(l>.00002){
      vec2 to=normalize(p-uFanA[i].xy);
      float rake=max(1.+dot(nr,-to)*.5,.4);
      float sh=1.;
      float s=.006;
      for(int k=0;k<7;k++){
        float cd;
        float dq=glyphD(p-to*s,cd);
        sh=min(sh,sat(.5+dq/(s*.16+.004)));
        s+=clamp(dq,.012,.12);
        if(s>.62||sh<.01) break;
      }
      e*=rake*(.3+.7*sh);
      E+=e;
      lightDir=mix(lightDir,to,l/(l+lw+1e-5));
      lw+=l;
    }
  }
  vec3 col=base*(1.-.82*uBgLum*sat(lumOf(E)*2.2))+E*.72;

  // 文字:壁から少し浮いた、薄い板
  float aa=uPxW*.75+soft;
  float cov=1.-smoothstep(-aa,aa,gd);
  if(cov>0.){
    int ci=int(cell);
    float act=max(uAct[ci],uHov[ci]*.42);
    vec3 face=mix(uMuted,uFg,act);
    vec3 lit=face*(.94+.06*a.a)+face*E*.5;
    if(gd>-.014&&soft<.006){
      vec2 e2=vec2(.0028,0.);
      float cd;
      vec2 g=vec2(glyphD(p+e2.xy,cd)-glyphD(p-e2.xy,cd),glyphD(p+e2.yx,cd)-glyphD(p-e2.yx,cd));
      g=g/max(length(g),1e-5);
      float rim=1.-smoothstep(0.,.011,abs(gd));
      lit+=face*rim*max(dot(g,vec2(-.28,.96)),0.)*.16;
      lit*=1.-rim*max(dot(g,vec2(.2,-.98)),0.)*.2;
      lit+=E*rim*max(dot(g,-lightDir),0.)*2.6;
    }
    col=mix(col,lit,cov);
  }
  return col;
}

// 手前の空間に見える虹と光の筋
vec3 airAt(vec3 ro,vec3 rd,float soft,float limit){
  vec3 c=vec3(0.);
  for(int l=0;l<3;l++){
    float z=ZM+(float(l)-1.)*.1;
    if(z<=limit) continue;
    float s=(z-ro.z)/rd.z;
    if(s<=0.) continue;
    vec3 q=ro+rd*s;
    if(q.y<=0.) continue;
    for(int i=0;i<2;i++){
      vec3 e=fanField(q.xy,uFanA[i],uFanB[i],uFanC[i],soft);
      if(lumOf(e)>.00002){
        float k=.2;
        if(l==1) k=.2+motes(q.xy)*3.;
        c+=e*k;
      }
    }
  }
  if(ZM>limit){
    float s=(ZM-ro.z)/rd.z;
    if(s>0.){
      vec3 q=ro+rd*s;
      if(q.y>0.){
        c+=beamField(q.xy,uBeam[0],uTip[0],soft)+beamField(q.xy,uBeam[1],uTip[1],soft);
      }
    }
  }
  return c;
}

vec3 tableShade(vec3 T,vec3 rd,float fx,float fz){
  float D0=uCam.z;
  vec4 a=textureLod(uTex,T.xz*.52,uLod+.7);
  vec4 b=textureLod(uTex,T.zx*vec2(.19,.23)+.11,uLod+.4);
  vec2 nr=(a.rg-.5)*2.;
  float pk=D0/(D0-T.z);
  float tv=vigAt(vec2(T.x*pk,uCam.y-uCam.y*pk));
  float pool=exp(-dot2((T.xz-vec2(uPx*.3,.7))/vec2(2.6,1.3)));
  vec3 alb=mix(uBg,uSurf,tv*(.3+.7*pool));
  float ts=sat(tv*2.2);
  float qt=1.-.72*uBgLum;
  alb*=mix(1.,.84+.32*b.b+.22*(a.a-.5),ts*qt);
  alb*=mix(1.,.6+.4*smoothstep(0.,.55,T.z),ts*(1.-.5*uBgLum));

  // ガラスの影と、ガラスが集めた光
  vec2 c=T.xz-vec2(uPx,(ZB+ZF)*.5+.04);
  alb*=1.-(.5-.32*uBgLum)*softBox(c,vec2(.22,.2),.2);
  alb*=1.-(.8-.4*uBgLum)*softBox(c,vec2(PF+.008,(ZF-ZB)*.5+.03),.018);
  alb+=uSurf*softBox(c-vec2(0.,.02),vec2(.1,.14),.09)*(.45+.55*a.b)*.3*qt;

  // 机の磨かれた面に、部屋が映る
  vec3 N=normalize(vec3(nr.x*.0055,1.,nr.y*.0055));
  vec3 r=reflect(rd,N);
  float ci=sat(-dot(rd,N));
  float F=.04+.96*pow(1.-ci,5.);
  F=min(F*1.75,.92);
  float s=-T.z/min(r.z,-1e-3);
  vec3 W=T+r*s;
  float soft=.003+s*.012;
  vec3 refl=wallShade(W.xy,soft);
  refl+=airAt(T,r,soft*.6,-1.)*smoothstep(ZM-.06,ZM+.14,T.z);
  vec3 col=alb*(1.-F)+mix(uBg,refl,sat(tv*1.6))*F;

  // 光の筋が机を照らす
  for(int i=0;i<2;i++){
    if(uBeam[i].z>.0005){
      float side=uBeam[i].y;
      float u=(T.x+side*uBeamEdge)*side;
      float uh=(uBeam[i].x+side*uBeamEdge)*side;
      float ax=1.-smoothstep(uh-.05,uh+.03,u);
      col+=uBeamCol*uBeam[i].z*ax*.007*exp(-dot2(vec2((T.z-ZM)/.2,0.)))*(.7+.6*a.b);
    }
  }

  // 目盛り:机に彫った細い線
  float zs=.74;
  float xw=T.x;
  float guideW=.0016;
  float guide=(1.-smoothstep(guideW,guideW+fz,abs(T.z-zs)))*(1.-smoothstep(2.04,2.08,abs(xw)));
  float minor=(1.-smoothstep(.0014,.0014+fx,abs(fract(xw*10.+.5)-.5)/10.))*(1.-smoothstep(.028,.028+fz,abs(T.z-zs-.03)))*(1.-smoothstep(2.0,2.02,abs(xw)));
  float major=(1.-smoothstep(.0026,.0026+fx,abs(fract(xw-.5+.5)-.5)))*(1.-smoothstep(.058,.058+fz,abs(T.z-zs-.06)))*(1.-smoothstep(2.0,2.02,abs(xw)));
  float ci2=clamp(floor(xw+2.),0.,3.);
  float act=uAct[int(ci2)];
  vec3 ink=mix(uMuted,uFg,act*.8)*(.26+.5*act);
  float mark=max(guide*.5,max(minor*.55,major));
  float mfade=smoothstep(2.3,1.6,abs(xw));
  col=mix(col,ink,mark*mfade*.75);
  return col;
}

void main(){
  vec2 wp=mix(uView.xy,uView.zw,vUv);
  vec3 ro=uCam;
  vec3 rd=normalize(vec3(wp,0.)-ro);
  vec3 col;
  float limit=-1.;
  float H=uCam.y,D=uCam.z;
  if(wp.y>=-uPxW){
    col=wallShade(wp,uPxW*.25);
    if(wp.y<uPxW){
      vec3 T0=ro+rd*(ro.y/max(-rd.y,1e-3));
      col=mix(tableShade(T0,rd,uPxW,uPxW),col,smoothstep(-uPxW,uPxW,wp.y));
    }
  }else{
    float tt=ro.y/max(-rd.y,1e-3);
    vec3 T=ro+rd*tt;
    float dd=H-wp.y;
    float fz=H*D/(dd*dd)*uPxW;
    float fx=uPxW*(D-T.z)/D;
    col=tableShade(T,rd,fx,fz);
    limit=T.z;
  }
  vec3 air=airAt(ro,rd,uPxW*.25,limit);
  col=col*(1.-.8*uBgLum*sat(lumOf(air)*4.))+air;
  O=vec4(enc(col),1.);
}`;

// ガラス:部屋を描いた面を環境にして、光線を追う
const glassFrag = (head) => {
  const len = Math.hypot(PH, PA - PF);
  const nx = PH / len;
  const ny = -(PA - PF) / len;
  const bellyD = nx * PF;
  const sT = Math.sin(TILT);
  const cT = Math.cos(TILT);
  const frontD = sT * PH + cT * ZF;
  return `${head}
in vec2 vUv;
out vec4 O;
uniform sampler2D uScene,uSpec,uTex;
uniform vec3 uCam,uBg,uSurf,uMuted,uWhite,uLamp;
uniform vec4 uView,uBox;
uniform float uPx,uPxW,uBev,uSide,uLit,uFlash,uFanI,uFanW,uAbsorb,uBeamW;
uniform float uIor[5];
uniform vec3 uKw[5];
uniform vec2 uPE,uPT[5],uPX[5],uPG[5];
const vec3 PN[6]=vec3[6](vec3(0.,1.,0.),vec3(0.,-1.,0.),vec3(0.,${f(sT)},${f(cT)}),vec3(0.,0.,-1.),vec3(${f(nx)},${f(ny)},0.),vec3(${f(-nx)},${f(ny)},0.));
const float PDIST[6]=float[6](PH,0.,${f(frontD)},-ZB,${f(bellyD)},${f(bellyD)});

${FAN_GLSL}

vec2 toUv(vec3 q){
  float k=uCam.z/(uCam.z-q.z);
  vec2 w=uCam.xy+(q.xy-uCam.xy)*k;
  return (w-uView.xy)/(uView.zw-uView.xy);
}
vec3 sceneAt(vec2 uv){return dec(textureLod(uScene,uv,0.).rgb);}
bool inFrame(vec2 uv){return uv.x>0.002&&uv.x<.998&&uv.y>0.002&&uv.y<.998;}

float gLamp=1.;

// ガラスの外に広がる世界。部屋の面、天井の灯り、手前の大きな窓
vec3 envAt(vec3 p,vec3 d){
  float sW=d.z<-1e-4?-p.z/d.z:1e9;
  float sT=d.y<-1e-4?-p.y/d.y:1e9;
  float sC=d.y>1e-4?(3.3-p.y)/d.y:1e9;
  float sF=d.z>1e-4?(3.6-p.z)/d.z:1e9;
  float m=min(sW,min(sT,min(sC,sF)));
  vec3 q=p+d*m;
  if(m==sW){
    vec2 uv=toUv(q);
    vec3 c=inFrame(uv)?sceneAt(uv):mix(uBg,uSurf,.5);
    c+=uWhite*uLamp.z*gLamp*4.5*exp(-dot2(q.xy-uLamp.xy)/.05);
    return c;
  }
  if(m==sT){
    vec2 uv=toUv(q);
    return inFrame(uv)?sceneAt(uv):uSurf*.5;
  }
  if(m==sC){
    // 天井の細い灯りが3本
    vec2 z=q.xz-vec2(uBox.x*.12,1.3);
    float bars=softBox(vec2(z.x,z.y),vec2(2.4,.05),.012)+softBox(vec2(z.x,z.y-.34),vec2(2.4,.05),.012)+softBox(vec2(z.x,z.y+.34),vec2(2.4,.05),.012);
    float wash=exp(-dot2(z/vec2(3.,1.6)));
    float panel=softBox(z-vec2(-.5,.1),vec2(1.15,.62),.035);
    return mix(uBg,uMuted,.06)+uWhite*(bars*9.+wash*.05+panel*.42);
  }
  // 手前:カーソルで動く窓と、両脇の細い灯り
  vec2 w=q.xy-uBox.xy;
  float pane=softBox(w,vec2(.2,.8),.1)*mix(.12,1.,smoothstep(-.8,.9,w.y))+softBox(w-vec2(-.14,0.),vec2(.014,.7),.008)*5.;
  float spot=exp(-dot2(w-vec2(0.,.08))/.009)*uBox.w*gLamp;
  float strips=softBox(vec2(abs(q.x)-3.1,q.y-1.6),vec2(.045,1.5),.012);
  float room=exp(-dot2((q.xy-vec2(0.,1.7))/vec2(4.5,2.6)));
  return mix(uBg,uSurf,room)*.7+uWhite*(pane*uBox.z*(.25+.75*gLamp)+spot*14.+strips*5.*step(.001,gLamp));
}

void prismT(vec3 ro,vec3 rd,out float t0,out float t1,out int idx){
  t0=-1e6;t1=1e6;idx=0;
  for(int i=0;i<6;i++){
    float dn=dot(rd,PN[i]);
    float dist=PDIST[i]-dot(ro,PN[i]);
    if(abs(dn)<1e-6){
      if(dist<0.) t1=-1e6;
      continue;
    }
    float t=dist/dn;
    if(dn<0.){
      if(t>t0){t0=t;idx=i;}
    }else{
      t1=min(t1,t);
    }
  }
}

// 角は少し丸い。髪の毛ほどの幅で、向きが隣の面へ移る
vec3 bevN(vec3 lp,int j){
  vec3 n=PN[j];
  for(int i=0;i<6;i++){
    if(i==j) continue;
    float d=PDIST[i]-dot(lp,PN[i]);
    float k=1.-sat(d/uBev);
    n+=PN[i]*k*k*.8;
  }
  return normalize(n);
}

float fres(float c,float eta){
  float s2=(1.-c*c)/(eta*eta);
  float ct=sqrt(max(1.-s2,0.));
  float rs=(c-eta*ct)/(c+eta*ct);
  float rp=(eta*c-ct)/(eta*c+ct);
  return .5*(rs*rs+rp*rp);
}

vec3 shade(vec3 ro,vec3 rd,int idx,float tn,int kStep,int bMax,float norm,float outerLamp){
  vec3 lp=ro+rd*tn;
  vec3 off=vec3(uPx,0.,0.);
  vec3 n=bevN(lp,idx);
  float c0=sat(-dot(rd,n));
  float F=fres(c0,1.61);
  gLamp=outerLamp;
  vec3 col=F*envAt(lp+off,reflect(rd,PN[idx]));
  // 磨いた角の細い光
  float rim=0.;
  for(int i=0;i<6;i++){
    if(i!=idx){
      float k=1.-sat((PDIST[i]-dot(lp,PN[i]))/uBev);
      rim=max(rim,k*k);
    }
  }
  col+=uWhite*rim*outerLamp*(.03+.2*sat(dot(n,vec3(-.28,.82,.5))));
  gLamp=outerLamp*.12;
  for(int k=0;k<5;k+=kStep){
    float eta=uIor[k];
    vec3 d=refract(rd,PN[idx],1./eta);
    vec3 q=lp-PN[idx]*3e-4;
    float w=1.-F;
    vec3 acc=vec3(0.);
    float path=0.;
    for(int b=0;b<bMax;b++){
      float s=1e6;
      int j=0;
      for(int i=0;i<6;i++){
        float dn=dot(d,PN[i]);
        if(dn>1e-5){
          float t=(PDIST[i]-dot(q,PN[i]))/dn;
          if(t<s){s=t;j=i;}
        }
      }
      s=max(s,0.);
      q+=d*s;
      path+=s;
      vec3 N=PN[j];
      float c=max(dot(d,N),.0005);
      float ee=1e6;
      for(int i=0;i<6;i++){
        if(i!=j) ee=min(ee,PDIST[i]-dot(q,PN[i]));
      }
      float edge=exp(-max(ee,0.)/(uBev*.8));
      acc+=uWhite*w*edge*.018;
      w*=1.-.55*edge;
      float s2=eta*eta*(1.-c*c);
      float Fi=1.;
      if(s2<1.){
        float ct=sqrt(1.-s2);
        float rs=(eta*c-ct)/(eta*c+ct);
        float rp=(c-eta*ct)/(c+eta*ct);
        Fi=.5*(rs*rs+rp*rp);
        vec3 Td=normalize(eta*d-(eta*c-ct)*N);
        acc+=w*(1.-Fi)*exp(-uAbsorb*path)*envAt(q+off,Td);
      }
      w*=Fi;
      if(w<.02) break;
      d=d-2.*dot(d,N)*N;
      q-=N*3e-4;
    }
    if(w>=.02) acc+=w*exp(-uAbsorb*path)*mix(uBg,uSurf,.55)*.8;
    col+=acc*uKw[k]*norm;
  }
  return col;
}

float segD(vec2 p,vec2 a,vec2 b){
  vec2 pa=p-a,ba=b-a;
  float h=sat(dot(pa,ba)/dot(ba,ba));
  return length(pa-ba*h);
}

// ガラスの中を通る光と、光に照らされた縁
vec3 innerLight(vec3 lp,vec3 rd,int idx){
  vec3 res=vec3(0.);
  vec3 d=rd;
  float glow=uLit+uFlash;
  if(glow>.001&&abs(d.z)>1e-4){
    float s=(ZM-lp.z)/d.z;
    if(s>0.){
      vec2 Q=(lp+d*s).xy;
      float wb=max(uBeamW,uPxW*.7);
      float dO=segD(Q,vec2(uPE.x-6.,uPE.y),uPE);
      res+=uWhite*uLit*(exp(-dO*dO/(wb*wb))*2.4+exp(-dO/(wb*4.))*.15);
      Q.x*=uSide;
      float wd=.0024+uPxW*.55;
      for(int k=0;k<5;k++){
        float d1=min(segD(Q,uPE,uPT[k]),segD(Q,uPT[k],uPX[k]));
        float g=exp(-d1*d1/(wd*wd));
        float h=exp(-d1/(wd*7.));
        res+=uKw[k]*(g*3.6+h*.08)*glow;
        float dg=segD(Q,uPX[k],uPG[k]);
        res+=uKw[k]*exp(-dg*dg/(wd*wd))*.34*glow;
      }
      float dE=length(Q-uPE);
      float dT=length(Q-uPT[2]);
      res+=uWhite*uFlash*(exp(-dE*dE/.0009)*5.+exp(-dT*dT/.0016)*2.5);
      res+=uWhite*uFlash*.1*exp(-dot2((Q-vec2(0.,PH*.45))/vec2(.3,.22)));
    }
  }
  // 縁が、中の光を拾って光る
  float e=1e6;
  for(int i=0;i<6;i++){
    if(i==idx) continue;
    e=min(e,PDIST[i]-dot(lp,PN[i]));
  }
  float ed=exp(-max(e,0.)/(.009+uPxW));
  float tint=fract(lp.x*.9+lp.y*1.3+lp.z*.8+.5);
  res+=textureLod(uSpec,vec2(tint,.5),0.).rgb*ed*(uLit*.7+uFlash*3.);
  // 上面から虹が出ていく場所
  if(idx==0){
    float x=lp.x*uSide;
    float xc=uPX[2].x;
    float t=sat((x-xc)/max(uFanW,.01)+.5);
    float bandX=exp(-dot2(vec2((x-xc)/max(uFanW*.62,.008),0.)));
    float bandZ=exp(-dot2(vec2((lp.z-ZM)/.05,0.)));
    res+=textureLod(uSpec,vec2(t,.5),0.).rgb*bandX*bandZ*uFanI*2.6;
  }
  return res;
}

void main(){
  vec2 wp=mix(uView.xy,uView.zw,vUv);
  vec3 ro=uCam-vec3(uPx,0.,0.);
  vec3 rd=normalize(vec3(wp.x-uPx,wp.y,0.)-ro);
  float t0,t1;
  int idx;
  prismT(ro,rd,t0,t1,idx);
  // 弦の長さは、輪郭で 0 を通る。1画素ぶんの変化で割れば、輪郭のなめらかな被覆になる
  float f0=t1-t0;
  float cov=sat(f0/clamp(fwidth(f0),1e-5,.25)+.5);

  float ty=-ro.y/min(rd.y,-1e-3);
  vec3 T=ro+rd*ty;
  vec3 r=vec3(rd.x,-rd.y,rd.z);
  float m0,m1;
  int midx;
  prismT(T,r,m0,m1,midx);
  float fm=m1-m0;
  float covM=sat(fm/clamp(fwidth(fm),1e-5,.25)+.5);

  vec3 col=sceneAt(vUv);
  if(covM>0.&&wp.y<0.&&cov<1.){
    float ci=sat(-rd.y);
    float F=min((.04+.96*pow(1.-ci,5.))*1.75,.92);
    vec3 g=shade(T,r,midx,m0,2,2,1.667,0.);
    col=mix(col,g,covM*F*(1.-sat(wp.y/-.9)));
  }
  if(cov>0.){
    vec3 lp=ro+rd*t0;
    vec3 g=shade(ro,rd,idx,t0,1,4,1.,1.);
    g+=innerLight(lp,rd,idx);
    // 虹がガラスより手前を通るところ
    for(int l=0;l<3;l++){
      float z=ZM+(float(l)-1.)*.1;
      if(z<=lp.z) continue;
      vec3 q=uCam+rd*((z-uCam.z)/rd.z);
      if(q.y<=0.) continue;
      g+=(fanField(q.xy,uFanA[0],uFanB[0],uFanC[0],uPxW*.25)+fanField(q.xy,uFanA[1],uFanB[1],uFanC[1],uPxW*.25))*.42;
    }
    col=mix(col,g,cov);
  }
  O=vec4(enc(col),1.);
}`;
};

// 光のにじみ:縮めては広げる
const downFrag = (head) => `${head}
in vec2 vUv;
out vec4 O;
uniform sampler2D uT;
uniform vec2 uHp;
uniform float uK;
vec3 t(vec2 uv){return dec(textureLod(uT,uv,0.).rgb);}
void main(){
  vec3 c=t(vUv)*4.+t(vUv-uHp)+t(vUv+uHp)+t(vUv+vec2(uHp.x,-uHp.y))+t(vUv-vec2(uHp.x,-uHp.y));
  c/=8.;
  if(uK>0.){
    float l=max(c.r,max(c.g,c.b));
    if(!(l>=0.)){c=vec3(0.);l=0.;}
    c=min(c,vec3(40.))*smoothstep(uK,uK*2.4,l);
  }
  O=vec4(enc(c),1.);
}`;
const upFrag = (head) => `${head}
in vec2 vUv;
out vec4 O;
uniform sampler2D uT;
uniform vec2 uHp;
vec3 t(vec2 uv){return dec(textureLod(uT,uv,0.).rgb);}
void main(){
  vec3 c=t(vUv+vec2(-2.*uHp.x,0.))+t(vUv+vec2(2.*uHp.x,0.))+t(vUv+vec2(0.,2.*uHp.y))+t(vUv+vec2(0.,-2.*uHp.y));
  c+=2.*(t(vUv+uHp)+t(vUv-uHp)+t(vUv+vec2(uHp.x,-uHp.y))+t(vUv+vec2(-uHp.x,uHp.y)));
  O=vec4(enc(c/12.),1.);
}`;
// 仕上げ:明るすぎる所を白へ寄せ、sRGB にして、揺らして階調の段を消す。縁は地へ溶かす
const compFrag = (head) => `${head}
in vec2 vUv;
out vec4 O;
uniform sampler2D uHdr,uBloom;
uniform float uBloomK,uFade,uS;
vec3 toneMap(vec3 c){
  float S=uS;
  float p=max(c.r,max(c.g,c.b));
  if(p<=S) return c;
  if(S>.995) return min(c,vec3(1.));
  float m=S+(1.-S)*(1.-exp(-(p-S)/(1.-S)));
  vec3 o=c*(m/p);
  float hot=1.-exp(-max(p-1.1,0.)*.42);
  return mix(o,vec3(m),hot);
}
vec3 toGamma(vec3 c){
  c=clamp(c,0.,1.);
  return mix(c*12.92,1.055*pow(c,vec3(1./2.4))-.055,step(.0031308,c));
}
float rnd(vec2 p){
  vec3 q=fract(vec3(p.xyx)*.1031);
  q+=dot(q,q.yzx+33.33);
  return fract((q.x+q.y)*q.z);
}
void main(){
  vec3 c=dec(textureLod(uHdr,vUv,0.).rgb)+dec(textureLod(uBloom,vUv,0.).rgb)*uBloomK;
  c=toGamma(toneMap(c));
  float n=rnd(gl_FragCoord.xy)+rnd(gl_FragCoord.xy+17.3)-1.;
  c+=n/255.;
  float a=smoothstep(0.,.05,vUv.x)*smoothstep(0.,.05,1.-vUv.x)*smoothstep(0.,.13,vUv.y)*smoothstep(0.,.07,1.-vUv.y)*uFade;
  O=vec4(c*a,a);
}`;

// ---- 文字の距離場(Felzenszwalb の距離変換)
const INF = 1e20;
function edt1d(grid, offset, stride, n, fv, v, z) {
  v[0] = 0;
  z[0] = -INF;
  z[1] = INF;
  fv[0] = grid[offset];
  for (let q = 1, k = 0, s = 0; q < n; q++) {
    fv[q] = grid[offset + q * stride];
    const q2 = q * q;
    do {
      const r = v[k];
      s = (fv[q] - fv[r] + q2 - r * r) / (q - r) * 0.5;
    } while (s <= z[k] && --k > -1);
    k++;
    v[k] = q;
    z[k] = s;
    z[k + 1] = INF;
  }
  for (let q = 0, k = 0; q < n; q++) {
    while (z[k + 1] < q) k++;
    const r = v[k];
    const qr = q - r;
    grid[offset + q * stride] = fv[r] + qr * qr;
  }
}
function edt(grid, S, fv, v, z) {
  for (let x = 0; x < S; x++) edt1d(grid, x, S, S, fv, v, z);
  for (let y = 0; y < S; y++) edt1d(grid, y * S, 1, S, fv, v, z);
}
function buildGlyphAtlas(labels, family, S) {
  const canvas = document.createElement('canvas');
  canvas.width = S;
  canvas.height = S;
  const ctx = canvas.getContext('2d', { willReadFrequently: true });
  const atlas = new Float32Array(S * labels.length * S);
  const outer = new Float64Array(S * S);
  const inner = new Float64Array(S * S);
  const fv = new Float64Array(S);
  const v = new Uint16Array(S);
  const z = new Float64Array(S + 1);
  labels.forEach((label, c) => {
    ctx.clearRect(0, 0, S, S);
    ctx.font = `400 ${Math.round(S * 0.7)}px ${family}`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(label, S / 2, S * 0.51);
    const d = ctx.getImageData(0, 0, S, S).data;
    for (let i = 0; i < S * S; i++) {
      const a = d[i * 4 + 3] / 255;
      outer[i] = a >= 1 ? 0 : a <= 0 ? INF : Math.pow(Math.max(0, 0.5 - a), 2);
      inner[i] = a >= 1 ? INF : a <= 0 ? 0 : Math.pow(Math.max(0, a - 0.5), 2);
    }
    edt(outer, S, fv, v, z);
    edt(inner, S, fv, v, z);
    for (let y = 0; y < S; y++) {
      for (let x = 0; x < S; x++) {
        const i = y * S + x;
        const value = (Math.sqrt(outer[i]) - Math.sqrt(inner[i])) / S;
        // 下から上へ並べる(世界の y と同じ向き)
        atlas[(S - 1 - y) * S * labels.length + c * S + x] = value;
      }
    }
  });
  return atlas;
}

// なめらかな追従(臨界減衰)
function follow(state, goal, w, dt) {
  const e = Math.exp(-w * dt);
  const j = (state.v + w * (state.x - goal)) * dt;
  state.x = goal + (state.x - goal + j) * e;
  state.v = (state.v - w * j) * e;
}

function mount(root) {
  const canvas = root.querySelector('.scene');
  const tablist = root.querySelector('[role="tablist"]');
  const tabs = [...root.querySelectorAll('[role="tab"]')];
  const panel = root.querySelector('[role="tabpanel"]');
  const probe = root.querySelector('.probe');
  const labels = tabs.map((tab) => tab.textContent.trim());
  const motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
  const schemeQuery = matchMedia('(prefers-color-scheme: dark)');
  const automated = navigator.webdriver === true;
  let calm = motionQuery.matches;

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

  // ---- 選択:描けなくても、タブとして動く
  let current = Math.max(0, tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true'));
  const cellX = (i) => i - (CELLS - 1) / 2;
  const slide = { x: cellX(current), v: 0 };
  const lead = { x: cellX(current), v: 0 };
  let goal = cellX(current);
  let travelSide = 1;
  let wake = () => {};
  let onSelect = () => {};
  let hoverTarget = -1;

  const applySelection = (i) => {
    tabs.forEach((tab, k) => {
      tab.setAttribute('aria-selected', k === i ? 'true' : 'false');
      if (k === i) tab.removeAttribute('tabindex');
      else tab.setAttribute('tabindex', '-1');
    });
    panel.setAttribute('aria-labelledby', tabs[i].id);
    panel.textContent = `${labels[i]}ごとの表示`;
  };
  const select = (i, pulse) => {
    if (i === current) {
      if (pulse) onSelect(i, true);
      return;
    }
    const before = current;
    current = i;
    applySelection(i);
    goal = cellX(i);
    travelSide = i > before ? 1 : -1;
    onSelect(i, false);
  };
  tabs.forEach((tab, i) => {
    on(tab, 'click', () => select(i, true));
    on(tab, 'pointerenter', () => {
      hoverTarget = i;
      wake();
    });
    on(tab, 'pointerleave', () => {
      if (hoverTarget === i) hoverTarget = -1;
      wake();
    });
  });
  on(tablist, 'keydown', (event) => {
    const last = tabs.length - 1;
    let next = -1;
    if (event.key === 'ArrowRight') next = current === last ? 0 : current + 1;
    else if (event.key === 'ArrowLeft') next = current === 0 ? last : current - 1;
    else if (event.key === 'Home') next = 0;
    else if (event.key === 'End') next = last;
    if (next < 0) return;
    event.preventDefault();
    tabs[next].focus();
    select(next, false);
  });

  // ---- WebGL
  let gl = null;
  try {
    gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, powerPreference: 'high-performance' });
  } catch {
    gl = null;
  }
  if (!gl) return () => cleanups.forEach((off) => off());

  const floatOK = !!(gl.getExtension('EXT_color_buffer_float') || gl.getExtension('EXT_color_buffer_half_float'));
  const parallel = automated ? null : gl.getExtension('KHR_parallel_shader_compile');
  const head = headFor(floatOK);

  const makeProgram = (vs, fs) => {
    const p = gl.createProgram();
    for (const [type, source] of [
      [gl.VERTEX_SHADER, vs],
      [gl.FRAGMENT_SHADER, fs],
    ]) {
      const s = gl.createShader(type);
      gl.shaderSource(s, source);
      gl.compileShader(s);
      gl.attachShader(p, s);
      gl.deleteShader(s);
    }
    gl.bindAttribLocation(p, 0, 'aPos');
    gl.linkProgram(p);
    return { p, u: {}, ready: false };
  };
  const finishProgram = (prog) => {
    if (!gl.getProgramParameter(prog.p, gl.LINK_STATUS)) {
      const logs = gl.getAttachedShaders(prog.p).map((s) => gl.getShaderInfoLog(s)).join(' ');
      throw new Error(logs.trim() || gl.getProgramInfoLog(prog.p) || 'link');
    }
    const count = gl.getProgramParameter(prog.p, gl.ACTIVE_UNIFORMS);
    for (let i = 0; i < count; i++) {
      const info = gl.getActiveUniform(prog.p, i);
      prog.u[info.name.replace(/\[0\]$/, '')] = gl.getUniformLocation(prog.p, info.name);
    }
    prog.ready = true;
  };

  const programs = {
    bake: makeProgram(VERT, BAKE),
    scene: makeProgram(VERT, sceneFrag(head)),
    glass: makeProgram(VERT, glassFrag(head)),
    down: makeProgram(VERT, downFrag(head)),
    up: makeProgram(VERT, upFrag(head)),
    comp: makeProgram(VERT, compFrag(head)),
  };
  const programList = Object.values(programs);

  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);
  const quad = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, quad);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
  gl.disable(gl.DEPTH_TEST);
  gl.disable(gl.BLEND);

  const makeTexture = (wrap, minFilter) => {
    const t = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, t);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, minFilter);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
    return t;
  };
  const texRelief = makeTexture(gl.REPEAT, gl.LINEAR_MIPMAP_LINEAR);
  const texSdf = makeTexture(gl.CLAMP_TO_EDGE, gl.LINEAR);
  const texSpec = makeTexture(gl.CLAMP_TO_EDGE, gl.LINEAR);
  let glyphSize = 0;

  const makeTarget = (w, h) => {
    const tex = makeTexture(gl.CLAMP_TO_EDGE, gl.LINEAR);
    if (floatOK) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null);
    else gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
    const fb = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
    return { tex, fb, w, h };
  };
  const freeTarget = (t) => {
    gl.deleteTexture(t.tex);
    gl.deleteFramebuffer(t.fb);
  };

  const bakeRelief = () => {
    const size = 1024;
    gl.bindTexture(gl.TEXTURE_2D, texRelief);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, size, size, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
    const fb = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texRelief, 0);
    gl.viewport(0, 0, size, size);
    gl.useProgram(programs.bake.p);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.deleteFramebuffer(fb);
    gl.bindTexture(gl.TEXTURE_2D, texRelief);
    gl.generateMipmap(gl.TEXTURE_2D);
  };

  // ---- トークンを読む
  const paint = new OffscreenCanvas(1, 1).getContext('2d', { willReadFrequently: true });
  const colors = { bg: [0, 0, 0], surface: [0, 0, 0], fg: [0, 0, 0], muted: [0, 0, 0], accent: [0, 0, 0], white: [1, 1, 1], beam: [1, 1, 1] };
  const weights = new Float32Array(15);
  let timeScale = 1;
  let bevel = 0.006;
  let bevelToken = 1;
  const readColor = (style, name) => {
    probe.style.color = style.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;
    return [toLinear(d[0] / 255), toLinear(d[1] / 255), toLinear(d[2] / 255)];
  };
  const readTokens = () => {
    const style = getComputedStyle(root);
    for (const name of ['bg', 'surface', 'fg', 'muted', 'accent']) colors[name] = readColor(style, name);
    const motion = style.getPropertyValue('--_motion').trim();
    const ms = motion.endsWith('ms') ? parseFloat(motion) : parseFloat(motion) * 1000;
    timeScale = clamp((Number.isFinite(ms) ? ms : 240) / 240, 0.4, 2.5);
    const radius = parseFloat(style.getPropertyValue('--_radius'));
    bevelToken = clamp((Number.isFinite(radius) ? radius : 14) / 14, 0.6, 2);

    // 虹:accent の色相を回す
    const lab = linearToOklab(colors.accent);
    const h0 = Math.atan2(lab[2], lab[1]);
    const C = Math.max(Math.hypot(lab[1], lab[2]), 0.19);
    const toneAt = (h) => hueTone(h, C, 0.67 + 0.1 * Math.cos(h - 1.92));
    const spec = new Float32Array(256 * 4);
    for (let i = 0; i < 256; i++) {
      const rgb = toneAt(h0 + (i / 255) * TURN);
      spec.set([rgb[0], rgb[1], rgb[2], 1], i * 4);
    }
    gl.bindTexture(gl.TEXTURE_2D, texSpec);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, 256, 1, 0, gl.RGBA, gl.FLOAT, spec);

    // 白:色相を一周ぶん足す
    const sum = [0, 0, 0];
    for (let i = 0; i < 72; i++) {
      const rgb = toneAt((i / 72) * Math.PI * 2);
      sum[0] += rgb[0];
      sum[1] += rgb[1];
      sum[2] += rgb[2];
    }
    const peak = Math.max(...sum);
    colors.white = sum.map((v) => mix(v / peak, 1, 0.8));
    // 明るい部屋では白が見えないので、光の筋を少し沈める
    const bgLum = luminance(colors.bg);
    colors.beam = colors.white.map((v, i) => mix(v, colors.muted[i] * 0.55, smooth(0.2, 0.75, bgLum)));

    // 波長ごとの重み。全部足すと、ちょうど透明になる
    const total = [0, 0, 0];
    const rows = [0, 1, 2, 3, 4].map((k) => toneAt(h0 + ((k + 0.5) / 5) * TURN));
    for (const row of rows) row.forEach((v, c) => (total[c] += v));
    rows.forEach((row, k) => row.forEach((v, c) => (weights[k * 3 + c] = v / total[c])));
  };

  // ---- 大きさ
  const view = { x0: -2.42, y0: -0.78, x1: 2.42, y1: 1.94, pxW: 0.004 };
  let W = 2;
  let H = 2;
  let scale = 1;
  let rt0 = null;
  let rt1 = null;
  let chain = [];
  const freeTargets = () => {
    if (rt0) freeTarget(rt0);
    if (rt1) freeTarget(rt1);
    chain.forEach(freeTarget);
    rt0 = rt1 = null;
    chain = [];
  };
  const measure = () => {
    const box = root.getBoundingClientRect();
    const cssW = Math.max(1, box.width);
    const cssH = Math.max(1, box.height);
    const dpr = Math.min(2, devicePixelRatio || 1) * scale;
    const nextW = Math.max(2, Math.round(cssW * dpr));
    const nextH = Math.max(2, Math.round(cssH * dpr));

    // 窓の中で、壁のどこが見えるか。4項目が横に収まるように決める
    const aspect = cssW / cssH;
    const halfW = aspect >= 1.6 ? 2.42 : 2.3;
    const height = (halfW * 2) / aspect;
    const y0 = -(0.78 + Math.max(0, height - 2.72) * 0.17);
    view.x0 = -halfW;
    view.x1 = halfW;
    view.y0 = y0;
    view.y1 = y0 + height;
    view.pxW = (halfW * 2) / nextW;

    // 押す場所を、絵の項目に合わせる
    const gutter = ((halfW - CELLS / 2) / (halfW * 2)) * 100;
    tablist.style.setProperty('--gutter', `${gutter.toFixed(3)}%`);
    tablist.style.setProperty('--label-y', `${(((view.y1 - LABEL_Y) / height) * 100).toFixed(3)}%`);
    tablist.style.setProperty('--label-size', `${(GLYPH_S * 92).toFixed(2)}%`);

    if (nextW === W && nextH === H && rt0) return;
    W = nextW;
    H = nextH;
    canvas.width = W;
    canvas.height = H;
    freeTargets();
    rt0 = makeTarget(W, H);
    rt1 = makeTarget(W, H);
    let w = W;
    let h = H;
    for (let i = 0; i < 5; i++) {
      w = Math.max(2, w >> 1);
      h = Math.max(2, h >> 1);
      chain.push(makeTarget(w, h));
    }
    // 文字の距離場は、見える大きさに足りるだけ作る
    ensureGlyphs(false);
  };
  const ensureGlyphs = (force) => {
    const needed = view.pxW > 0 ? GLYPH_S / view.pxW : 256;
    const size = needed > 400 ? 512 : needed > 200 ? 384 : 256;
    if (size <= glyphSize && !force) return;
    glyphSize = Math.max(size, glyphSize);
    const atlas = buildGlyphAtlas(labels, getComputedStyle(root).fontFamily, glyphSize);
    gl.bindTexture(gl.TEXTURE_2D, texSdf);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16F, glyphSize * labels.length, glyphSize, 0, gl.RED, gl.FLOAT, atlas);
  };

  // ---- 光の時間
  // lights[0]:いま当たっている光、lights[1]:去った場所に残る光
  const staticLight = (x, side) => ({ x, side, born: 0, dying: null, still: true });
  let lightNow = null;
  let lightOld = null;
  let awaitingArrival = false;
  let lastSide = 1;
  const evalLight = (light, now) => {
    const out = { I: 0, head: 0, spread: 0.02, w0: 0.02, beam: 0, bw: 0.003, flash: 0, travel: 1 };
    if (light.still) {
      // 動きを止めたとき:虹は細く開いたまま、静かに置く
      out.I = 0.62;
      out.head = 9;
      out.spread = (9 * Math.PI) / 180;
      out.w0 = 0.05;
      out.beam = 0.42;
      out.bw = 0.0034;
      return out;
    }
    const T = light.hold ? 1.35 : now - light.born;
    const reach = 0.17 * timeScale;
    const hit = T - reach;
    const h = clamp(T / reach);
    out.travel = 1 - (1 - h) * (1 - h);
    out.beam = hit < 0 ? 1 : REST_BEAM + (1 - REST_BEAM) * Math.exp(-hit / 1.7);
    out.bw = mix(0.0022, 0.0036, hit < 0 ? 1 : Math.exp(-hit / 1.3));
    out.flash = hit > 0 ? Math.exp(-hit / 0.24) : 0;
    if (hit > 0) {
      const open = smooth(0, 0.42, hit) * Math.exp(-Math.max(hit - 0.95, 0) / 2.3);
      out.I = smooth(0, 0.18, hit) * (REST_FAN + (1 - REST_FAN) * Math.exp(-hit / 2.5));
      out.head = (1 - Math.pow(1 - clamp(hit / 0.9), 3)) * 4.2;
      out.spread = (mix(1.0, 21, open) * Math.PI) / 180;
      out.w0 = mix(0.014, 0.12, open);
    }
    if (light.dying !== null) {
      const k = Math.exp(-(now - light.dying) / 0.42);
      out.I *= k;
      out.beam *= k * k;
      out.flash *= k;
    }
    return out;
  };
  const strike = (side, now, age = 0) => {
    if (lightNow) {
      lightNow.dying = now;
      lightOld = lightNow;
    }
    lastSide = side;
    lightNow = calm ? staticLight(slide.x, side) : { x: slide.x, side, born: now - age, dying: null, still: false };
  };
  onSelect = (i, pulse) => {
    const now = performance.now() * 0.001;
    if (calm) {
      slide.x = lead.x = goal;
      slide.v = lead.v = 0;
      strike(travelSide, now);
      lightOld = null;
      draw(now, 0);
      return;
    }
    if (pulse) {
      strike(lastSide, now);
    } else {
      if (lightNow) {
        lightNow.dying = now;
        lightOld = lightNow;
        lightNow = null;
      }
      awaitingArrival = true;
    }
    wake();
  };

  // ---- カーソル
  const cursor = { x: 0, y: 1, on: false };
  const lamp = { x: { x: 0, v: 0 }, y: { x: 1, v: 0 }, near: { x: 0, v: 0 } };
  on(root, 'pointermove', (event) => {
    const box = root.getBoundingClientRect();
    cursor.x = mix(view.x0, view.x1, (event.clientX - box.left) / Math.max(box.width, 1));
    cursor.y = mix(view.y1, view.y0, (event.clientY - box.top) / Math.max(box.height, 1));
    if (!cursor.on) {
      lamp.x.x = cursor.x;
      lamp.y.x = cursor.y;
    }
    cursor.on = true;
    if (calm) draw(performance.now() * 0.001, 0);
    else wake();
  });
  on(root, 'pointerleave', () => {
    cursor.on = false;
    wake();
  });

  // ---- 描く
  const act = [0, 0, 0, 0];
  const hov = [0, 0, 0, 0];
  act[current] = 1;
  const pathE = new Float32Array(PATHS[0].E);
  const pathT = new Float32Array(10);
  const pathX = new Float32Array(10);
  const pathG = new Float32Array(10);
  PATHS.forEach((path, k) => {
    pathT.set(path.T, k * 2);
    pathX.set(path.X, k * 2);
    pathG.set(path.G, k * 2);
  });
  const fanA = new Float32Array(8);
  const fanB = new Float32Array(8);
  const fanC = new Float32Array(6);
  const beamU = new Float32Array(8);
  const tipU = new Float32Array(2);
  let glassLit = 0;
  let glassFlash = 0;
  let glassFanI = 0;
  let glassFanW = 0.05;
  let ready = false;
  let dead = false;
  let raf = 0;
  let last = 0;
  let visible = true;

  const writeLight = (slot, light, now) => {
    const a = slot * 4;
    if (!light) {
      fanB[a] = 0;
      beamU[a + 2] = 0;
      return null;
    }
    const v = evalLight(light, now);
    const side = light.side;
    const phi = side * LEAN;
    const r0 = v.w0 / 2 / Math.tan(v.spread);
    const xc = light.x + side * XOUT;
    fanA[a] = xc - Math.sin(phi) * r0;
    fanA[a + 1] = PH - Math.cos(phi) * r0;
    fanA[a + 2] = phi;
    fanA[a + 3] = v.spread;
    fanB[a] = v.I;
    fanB[a + 1] = v.head;
    fanB[a + 2] = side;
    fanB[a + 3] = light.x * 0.37 + 0.13;
    fanC[slot * 3] = r0;
    fanC[slot * 3 + 1] = v.w0;
    const xs = -side * beamEdge();
    const end = (slot === 0 ? slide.x : light.x) + side * BEAM_ENTRY;
    beamU[a] = mix(xs, end, v.travel);
    beamU[a + 1] = side;
    beamU[a + 2] = v.beam;
    beamU[a + 3] = v.bw;
    tipU[slot] = v.travel < 1 ? 1 : 0;
    return v;
  };
  const beamEdge = () => view.x1 + 0.1;

  const pass = (target, prog) => {
    gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.fb : null);
    gl.viewport(0, 0, target ? target.w : W, target ? target.h : H);
    gl.useProgram(prog.p);
  };
  const bindTex = (unit, tex, location) => {
    gl.activeTexture(gl.TEXTURE0 + unit);
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.uniform1i(location, unit);
  };
  const glassRect = (px) => {
    // 手前の面は、下へ行くほど手前へ出る
    const zFoot = ZF + PH * Math.tan(TILT);
    const corners = [
      [px - PA, PH, ZB],
      [px + PA, PH, ZB],
      [px - PA, PH, ZF],
      [px + PA, PH, ZF],
      [px - PF, 0, ZB],
      [px + PF, 0, ZB],
      [px - PF, 0, zFoot],
      [px + PF, 0, zFoot],
    ];
    let minX = 1e9;
    let maxX = -1e9;
    let minY = 1e9;
    let maxY = -1e9;
    for (const [x, y, z] of corners) {
      for (const sign of [1, -1]) {
        const k = CAM[2] / (CAM[2] - z);
        const wx = CAM[0] + (x - CAM[0]) * k;
        const wy = CAM[1] + (sign * y - CAM[1]) * k;
        minX = Math.min(minX, wx);
        maxX = Math.max(maxX, wx);
        minY = Math.min(minY, wy);
        maxY = Math.max(maxY, wy);
      }
    }
    const px0 = Math.floor(((minX - view.x0) / (view.x1 - view.x0)) * W) - 3;
    const px1 = Math.ceil(((maxX - view.x0) / (view.x1 - view.x0)) * W) + 3;
    const py0 = Math.floor(((minY - view.y0) / (view.y1 - view.y0)) * H) - 3;
    const py1 = Math.ceil(((maxY - view.y0) / (view.y1 - view.y0)) * H) + 3;
    const x = clamp(px0, 0, W);
    const y = clamp(py0, 0, H);
    return [x, y, clamp(px1, 0, W) - x, clamp(py1, 0, H) - y];
  };

  let fade = automated || calm ? 1 : 0;
  function draw(now, dt) {
    if (!ready || dead || !rt0) return;

    // 滑る。動き出しも止まりぎわも、やわらかく
    if (!calm) {
      follow(lead, goal, 10.5 / timeScale, dt);
      follow(slide, lead.x, 14 / timeScale, dt);
      if (awaitingArrival && Math.abs(slide.x - goal) < 0.008 && Math.abs(slide.v) < 0.4) {
        awaitingArrival = false;
        strike(travelSide, now);
      }
      fade = Math.min(1, fade + dt * 2.4);
    }
    if (lightOld && lightOld.dying !== null && now - lightOld.dying > 2.4) lightOld = null;

    // 文字の明るさ
    for (let i = 0; i < CELLS; i++) {
      const want = i === current && (!awaitingArrival || Math.abs(slide.x - goal) < 0.4) ? 1 : 0;
      act[i] = calm ? (i === current ? 1 : 0) : mix(act[i], want, 1 - Math.exp(-dt * 9));
      const hw = i === hoverTarget ? 1 : 0;
      hov[i] = calm ? hw : mix(hov[i], hw, 1 - Math.exp(-dt * 12));
    }

    // カーソルの光
    const dx = cursor.x - slide.x;
    const dy = cursor.y - 0.3;
    const near = cursor.on ? 1 - smooth(0.55, 2.1, Math.hypot(dx, dy)) : 0;
    if (calm) {
      lamp.x.x = cursor.on ? cursor.x : slide.x;
      lamp.y.x = cursor.on ? cursor.y : 1;
      lamp.near.x = near;
    } else {
      if (cursor.on) {
        follow(lamp.x, cursor.x, 15, dt);
        follow(lamp.y, cursor.y, 15, dt);
      }
      follow(lamp.near, near, 7, dt);
    }

    const v0 = writeLight(0, lightNow, now);
    writeLight(1, lightOld, now);
    const litAim = v0 && v0.travel >= 1 ? v0.beam : 0;
    if (calm) glassLit = litAim;
    else glassLit = litAim > glassLit ? litAim : mix(glassLit, litAim, 1 - Math.exp(-dt * 9));
    glassFlash = v0 ? v0.flash : 0;
    glassFanI = v0 ? v0.I : calm ? glassFanI : mix(glassFanI, 0, 1 - Math.exp(-dt * 10));
    if (v0) glassFanW = v0.w0;

    const time = calm ? 12 : now % 3600;
    const lod = Math.max(0, Math.log2(731 * view.pxW));
    const bgLum = luminance(colors.bg);

    // ---- 部屋
    const sc = programs.scene;
    pass(rt0, sc);
    gl.uniform3fv(sc.u.uCam, CAM);
    gl.uniform4f(sc.u.uView, view.x0, view.y0, view.x1, view.y1);
    gl.uniform1f(sc.u.uTime, time);
    gl.uniform1f(sc.u.uPx, slide.x);
    gl.uniform1f(sc.u.uPxW, view.pxW);
    gl.uniform1f(sc.u.uLod, lod);
    gl.uniform1f(sc.u.uBgLum, bgLum);
    gl.uniform1f(sc.u.uBeamEdge, beamEdge());
    gl.uniform4f(sc.u.uVig, 0, (view.y0 + view.y1) / 2, view.x1, (view.y1 - view.y0) / 2);
    gl.uniform3fv(sc.u.uBg, colors.bg);
    gl.uniform3fv(sc.u.uSurf, colors.surface);
    gl.uniform3fv(sc.u.uFg, colors.fg);
    gl.uniform3fv(sc.u.uMuted, colors.muted);
    gl.uniform3fv(sc.u.uBeamCol, colors.beam);
    gl.uniform4fv(sc.u.uAct, act);
    gl.uniform4fv(sc.u.uHov, hov);
    gl.uniform3f(sc.u.uLamp, lamp.x.x, lamp.y.x, lamp.near.x);
    gl.uniform4fv(sc.u.uFanA, fanA);
    gl.uniform4fv(sc.u.uFanB, fanB);
    gl.uniform3fv(sc.u.uFanC, fanC);
    gl.uniform4fv(sc.u.uBeam, beamU);
    gl.uniform1fv(sc.u.uTip, tipU);
    bindTex(0, texRelief, sc.u.uTex);
    bindTex(1, texSdf, sc.u.uSdf);
    bindTex(2, texSpec, sc.u.uSpec);
    gl.drawArrays(gl.TRIANGLES, 0, 3);

    // ---- ガラス
    gl.bindFramebuffer(gl.READ_FRAMEBUFFER, rt0.fb);
    gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, rt1.fb);
    gl.blitFramebuffer(0, 0, W, H, 0, 0, W, H, gl.COLOR_BUFFER_BIT, gl.NEAREST);
    const gs = programs.glass;
    pass(rt1, gs);
    const rect = glassRect(slide.x);
    gl.enable(gl.SCISSOR_TEST);
    gl.scissor(rect[0], rect[1], rect[2], rect[3]);
    gl.uniform3fv(gs.u.uCam, CAM);
    gl.uniform4f(gs.u.uView, view.x0, view.y0, view.x1, view.y1);
    gl.uniform1f(gs.u.uPx, slide.x);
    gl.uniform1f(gs.u.uPxW, view.pxW);
    bevel = Math.max(0.0055 * bevelToken, view.pxW * 1.4);
    gl.uniform1f(gs.u.uBev, bevel);
    gl.uniform1f(gs.u.uSide, lastSide);
    gl.uniform1f(gs.u.uLit, glassLit);
    gl.uniform1f(gs.u.uFlash, glassFlash);
    gl.uniform1f(gs.u.uFanI, glassFanI);
    gl.uniform1f(gs.u.uFanW, glassFanW);
    gl.uniform1f(gs.u.uBeamW, v0 ? v0.bw : 0.003);
    gl.uniform1f(gs.u.uTime, time);
    gl.uniform4f(gs.u.uVig, 0, (view.y0 + view.y1) / 2, view.x1, (view.y1 - view.y0) / 2);
    gl.uniform4fv(gs.u.uFanA, fanA);
    gl.uniform4fv(gs.u.uFanB, fanB);
    gl.uniform3fv(gs.u.uFanC, fanC);
    gl.uniform1f(gs.u.uAbsorb, 0.1);
    gl.uniform3fv(gs.u.uBg, colors.bg);
    gl.uniform3fv(gs.u.uSurf, colors.surface);
    gl.uniform3fv(gs.u.uMuted, colors.muted);
    gl.uniform3fv(gs.u.uWhite, colors.white);
    gl.uniform3f(gs.u.uLamp, lamp.x.x, lamp.y.x, lamp.near.x);
    gl.uniform1fv(gs.u.uIor, ENV_IOR);
    gl.uniform3fv(gs.u.uKw, weights);
    gl.uniform2fv(gs.u.uPE, pathE);
    gl.uniform2fv(gs.u.uPT, pathT);
    gl.uniform2fv(gs.u.uPX, pathX);
    gl.uniform2fv(gs.u.uPG, pathG);
    // 手前の窓の映り。カーソルが近いほど、カーソルについて動く
    const boxX = slide.x * 1.57 - 0.32 + (lamp.x.x - slide.x) * 0.95 * lamp.near.x;
    const boxY = 1.42 + (lamp.y.x - 0.5) * 0.8 * lamp.near.x;
    gl.uniform4f(gs.u.uBox, boxX, boxY, 1.1 + 1.1 * lamp.near.x, lamp.near.x);
    bindTex(0, rt0.tex, gs.u.uScene);
    bindTex(1, texSpec, gs.u.uSpec);
    bindTex(2, texRelief, gs.u.uTex);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    gl.disable(gl.SCISSOR_TEST);

    // ---- にじみ
    const dn = programs.down;
    let source = rt1;
    chain.forEach((target, i) => {
      pass(target, dn);
      gl.uniform2f(dn.u.uHp, 0.5 / target.w, 0.5 / target.h);
      gl.uniform1f(dn.u.uK, i === 0 ? 0.85 : 0);
      bindTex(0, source.tex, dn.u.uT);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      source = target;
    });
    const upP = programs.up;
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE);
    for (let i = chain.length - 1; i > 0; i--) {
      const target = chain[i - 1];
      pass(target, upP);
      gl.uniform2f(upP.u.uHp, 0.5 / target.w, 0.5 / target.h);
      bindTex(0, chain[i].tex, upP.u.uT);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    }
    gl.disable(gl.BLEND);

    // ---- 仕上げ
    const cp = programs.comp;
    pass(null, cp);
    gl.uniform1f(cp.u.uBloomK, 0.16);
    gl.uniform1f(cp.u.uFade, fade);
    gl.uniform1f(cp.u.uS, mix(0.82, 1, smooth(0.3, 0.85, bgLum)));
    bindTex(0, rt1.tex, cp.u.uHdr);
    bindTex(1, chain[0].tex, cp.u.uBloom);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }

  // ---- 速さが足りないときは、解像度を下げて 60fps を守る
  let sumDt = 0;
  let countDt = 0;
  let goodRuns = 0;
  let ceiling = 1;
  const watchSpeed = (dt) => {
    if (automated || dt > 0.1) return;
    sumDt += dt;
    countDt += 1;
    if (countDt < 45) return;
    const avg = sumDt / countDt;
    sumDt = 0;
    countDt = 0;
    if (avg > 0.0205 && scale > 0.5) {
      ceiling = Math.max(0.5, scale - 0.05);
      scale = Math.max(0.5, scale - 0.15);
      goodRuns = 0;
      measure();
    } else if (avg < 0.0172 && scale < ceiling) {
      goodRuns += 1;
      if (goodRuns > 5) {
        scale = Math.min(ceiling, scale + 0.05);
        goodRuns = 0;
        measure();
      }
    }
  };

  const frame = (ms) => {
    raf = 0;
    if (dead || !visible) return;
    const now = ms * 0.001;
    const dt = last ? Math.min(now - last, 0.05) : 0.016;
    const raw = last ? now - last : 0.016;
    last = now;
    if (!ready) {
      if (programList.every((p) => gl.getProgramParameter(p.p, parallel.COMPLETION_STATUS_KHR))) start();
      raf = requestAnimationFrame(frame);
      return;
    }
    watchSpeed(raw);
    draw(now, dt);
    if (!calm) raf = requestAnimationFrame(frame);
  };
  wake = () => {
    if (!raf && visible && !dead && (!ready || !calm)) raf = requestAnimationFrame(frame);
    else if (ready && calm && !dead) draw(performance.now() * 0.001, 0);
  };

  const start = () => {
    try {
      programList.forEach(finishProgram);
    } catch (error) {
      console.warn('tabs-prism: シェーダーを作れないので、WebGL を使わずに見せる', error);
      root.removeAttribute('data-gl');
      dead = true;
      return;
    }
    bakeRelief();
    readTokens();
    measure();
    root.setAttribute('data-gl', '');
    ready = true;
    // 開いたときは、光が当たった直後の姿から始める
    const now = performance.now() * 0.001;
    strike(1, now, 1.05);
    if (automated && lightNow) lightNow.hold = true;
    draw(now, 0);
  };

  // ---- 見えていないときは止める
  let onScreen = true;
  const updateVisible = () => {
    visible = onScreen && document.visibilityState !== 'hidden';
    if (visible) {
      last = 0;
      wake();
    } else if (raf) {
      cancelAnimationFrame(raf);
      raf = 0;
    }
  };
  const io = new IntersectionObserver((entries) => {
    onScreen = entries.some((e) => e.isIntersecting);
    updateVisible();
  });
  io.observe(root);
  on(document, 'visibilitychange', updateVisible);
  const ro = new ResizeObserver(() => {
    if (!ready) return;
    measure();
    if (calm) draw(performance.now() * 0.001, 0);
    else wake();
  });
  ro.observe(root);

  on(motionQuery, 'change', () => {
    calm = motionQuery.matches;
    if (!ready) return;
    const now = performance.now() * 0.001;
    if (calm) {
      slide.x = lead.x = goal;
      slide.v = lead.v = 0;
      awaitingArrival = false;
      lightOld = null;
      lightNow = staticLight(slide.x, lastSide);
      fade = 1;
      draw(now, 0);
    } else {
      lightNow = { x: slide.x, side: lastSide, born: now - 9, dying: null, still: false };
      wake();
    }
  });
  on(schemeQuery, 'change', () => {
    if (!ready) return;
    readTokens();
    wake();
  });
  on(canvas, 'webglcontextlost', (event) => {
    event.preventDefault();
    root.removeAttribute('data-gl');
    dead = true;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
  });

  if (document.fonts && document.fonts.size > 0) {
    document.fonts.ready.then(() => {
      if (dead || !ready) return;
      ensureGlyphs(true);
      wake();
    });
  }

  // ---- 組み立て
  if (parallel) {
    raf = requestAnimationFrame(frame);
  } else {
    start();
    if (!dead && !calm) raf = requestAnimationFrame(frame);
  }

  return () => {
    dead = true;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    io.disconnect();
    ro.disconnect();
    for (const off of cleanups) off();
    root.removeAttribute('data-gl');
    freeTargets();
    for (const p of programList) gl.deleteProgram(p.p);
    gl.deleteBuffer(quad);
    gl.deleteVertexArray(vao);
    gl.deleteTexture(texRelief);
    gl.deleteTexture(texSdf);
    gl.deleteTexture(texSpec);
    const lose = gl.getExtension('WEBGL_lose_context');
    if (lose) lose.loseContext();
  };
}
mount(document.querySelector('[data-ld-part="tabs-prism"]'));
</script>

@Lattice_Node のほかの作品