← back to Quadrille Showroom

proto/v6-colorriver.html

548 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>COLOR RIVER · China Seas</title>
<style>
  :root { --ink:#0d0c0f; --paper:#f4f1ea; }
  * { box-sizing:border-box; margin:0; padding:0; }
  html,body { height:100%; overflow:hidden; background:#07060a; color:var(--paper);
    font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }
  #app { position:fixed; inset:0; }
  canvas { display:block; }

  /* ---- chrome / overlay ---- */
  .masthead { position:fixed; top:0; left:0; right:0; z-index:20; padding:22px 30px 40px;
    pointer-events:none;
    background:linear-gradient(180deg, rgba(7,6,10,.85) 0%, rgba(7,6,10,0) 100%); }
  .masthead .kicker { font-size:11px; letter-spacing:.42em; text-transform:uppercase;
    color:#bdb7c8; font-weight:600; }
  .masthead h1 { font-size:clamp(28px,5vw,52px); font-weight:300; letter-spacing:.02em;
    line-height:.96; margin-top:6px; }
  .masthead h1 b { font-weight:700; }
  .masthead .sub { margin-top:8px; font-size:12.5px; letter-spacing:.04em; color:#9b96a6;
    max-width:520px; }

  /* hue navigation rail */
  #rail { position:fixed; left:0; right:0; bottom:0; z-index:20; height:64px;
    display:flex; align-items:stretch; pointer-events:auto; cursor:pointer;
    background:linear-gradient(0deg, rgba(7,6,10,.92), rgba(7,6,10,.35) 70%, transparent); }
  #rail .band { flex:1; position:relative; }
  #rail .tick { position:absolute; bottom:8px; left:50%; transform:translateX(-50%);
    font-size:9.5px; letter-spacing:.18em; text-transform:uppercase; color:rgba(255,255,255,.62);
    white-space:nowrap; font-weight:600; text-shadow:0 1px 3px rgba(0,0,0,.8); }
  #railhandle { position:absolute; top:0; height:100%; width:3px; background:#fff;
    box-shadow:0 0 16px 3px rgba(255,255,255,.7); pointer-events:none; transition:left .08s linear; }

  .hint { position:fixed; bottom:74px; left:50%; transform:translateX(-50%); z-index:20;
    font-size:11px; letter-spacing:.28em; text-transform:uppercase; color:rgba(255,255,255,.5);
    pointer-events:none; transition:opacity .5s; }

  /* loading */
  #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
    align-items:center; justify-content:center; background:#07060a; transition:opacity .8s; }
  #boot .ring { width:46px; height:46px; border-radius:50%;
    border:2px solid rgba(255,255,255,.15); border-top-color:#fff; animation:spin 1s linear infinite; }
  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.34em; text-transform:uppercase; color:#8c8796; }
  @keyframes spin { to { transform:rotate(360deg); } }

  /* focus detail card */
  #detail { position:fixed; z-index:30; right:34px; top:50%; transform:translateY(-50%) translateX(40px);
    width:min(340px,40vw); background:rgba(15,13,18,.74); backdrop-filter:blur(18px);
    -webkit-backdrop-filter:blur(18px); border:1px solid rgba(255,255,255,.1);
    border-radius:4px; padding:24px 24px 26px; opacity:0; pointer-events:none;
    transition:opacity .45s ease, transform .45s cubic-bezier(.2,.8,.2,1); }
  #detail.show { opacity:1; transform:translateY(-50%) translateX(0); pointer-events:auto; }
  #detail .swatchchip { width:38px; height:38px; border-radius:50%; border:1px solid rgba(255,255,255,.2);
    margin-bottom:14px; box-shadow:0 4px 18px rgba(0,0,0,.5); }
  #detail .pname { font-size:21px; font-weight:600; line-height:1.12; letter-spacing:.01em; }
  #detail .pcollection { margin-top:4px; font-size:11px; letter-spacing:.26em; text-transform:uppercase; color:#a7a1b3; }
  #detail .specrow { display:flex; justify-content:space-between; padding:9px 0;
    border-bottom:1px solid rgba(255,255,255,.08); font-size:12.5px; }
  #detail .specrow:first-of-type { margin-top:16px; border-top:1px solid rgba(255,255,255,.08); }
  #detail .specrow .k { color:#8e8898; letter-spacing:.04em; }
  #detail .specrow .v { color:#efece5; text-align:right; }
  #detail a.cta { display:block; margin-top:18px; text-align:center; padding:12px;
    background:var(--paper); color:#0d0c0f; font-size:11px; letter-spacing:.22em; text-transform:uppercase;
    font-weight:700; text-decoration:none; border-radius:2px; transition:transform .15s, background .15s; }
  #detail a.cta:hover { transform:translateY(-1px); background:#fff; }
  #detail .closex { position:absolute; top:12px; right:14px; font-size:18px; color:#9b96a6;
    cursor:pointer; line-height:1; }
  #detail .closex:hover { color:#fff; }

  @media (max-width:720px){
    #detail { right:14px; left:14px; width:auto; top:auto; bottom:84px; transform:translateY(20px); }
    #detail.show { transform:translateY(0); }
    .masthead .sub { display:none; }
  }
</style>
</head>
<body>
<div id="app"></div>

<div class="masthead">
  <div class="kicker">Designer Wallcoverings · Shop by Color</div>
  <h1>COLOR <b>RIVER</b></h1>
  <div class="sub">Every China Seas pattern set adrift on a single current of hue — from warm corals through
    jungle greens into deep indigos. Scroll or drag to travel downstream. Tap a swatch to surface it.</div>
</div>

<div id="rail" title="Jump to a hue"></div>
<div id="railhandle"></div>
<div class="hint">scroll · drag · click a swatch</div>

<div id="detail">
  <div class="closex">&times;</div>
  <div class="swatchchip" id="d_chip"></div>
  <div class="pname" id="d_name">—</div>
  <div class="pcollection" id="d_coll">China Seas</div>
  <div class="specrow"><span class="k">Dimensions</span><span class="v" id="d_dim">27" × 27"</span></div>
  <div class="specrow"><span class="k">Repeat</span><span class="v" id="d_rep">—</span></div>
  <div class="specrow"><span class="k">Match</span><span class="v" id="d_match">—</span></div>
  <div class="specrow"><span class="k">Finish</span><span class="v" id="d_finish">—</span></div>
  <div class="specrow"><span class="k">Material</span><span class="v" id="d_mat">—</span></div>
  <a class="cta" id="d_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
</div>

<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Gathering the current…</div></div>

<script src="./three.min.js"></script>
<script>
"use strict";
(function () {
  const API = "/api/showroom/products?limit=120";
  // ---- helpers -----------------------------------------------------------
  const TAU = Math.PI * 2;
  const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
  const lerp = (a,b,t)=>a+(b-a)*t;

  // rgb -> hsl, returns [h(0..1), s(0..1), l(0..1)]
  function rgb2hsl(r,g,b){
    r/=255; g/=255; b/=255;
    const mx=Math.max(r,g,b), mn=Math.min(r,g,b); let h=0,s=0; const l=(mx+mn)/2;
    if(mx!==mn){ const d=mx-mn; s=l>0.5?d/(2-mx-mn):d/(mx+mn);
      switch(mx){ case r:h=(g-b)/d+(g<b?6:0);break; case g:h=(b-r)/d+2;break; default:h=(r-g)/d+4; }
      h/=6; }
    return [h,s,l];
  }
  function hsl2css(h,s,l){
    // h 0..1 -> css hsl string
    return `hsl(${(h*360).toFixed(0)} ${(s*100).toFixed(0)}% ${(l*100).toFixed(0)}%)`;
  }

  // ---- extract a representative hue from an image ------------------------
  // Strategy: downscale to 24x24, bin pixels into hue buckets weighted by
  // saturation (so neutral pinstripe grounds don't dominate). The "color" of a
  // China Seas pattern is its INK, not its near-white paper ground.
  const huecanvas = document.createElement("canvas");
  huecanvas.width = huecanvas.height = 24;
  const hctx = huecanvas.getContext("2d", { willReadFrequently:true });

  function analyzeImage(img){
    try {
      hctx.clearRect(0,0,24,24);
      hctx.drawImage(img,0,0,24,24);
      const data = hctx.getImageData(0,0,24,24).data;
      const BINS = 36;
      const hueWeight = new Float64Array(BINS);
      const hueSatSum = new Float64Array(BINS);
      const hueLightSum = new Float64Array(BINS);
      let satWeightTotal = 0, lAcc = 0, n = 0, neutralL = 0, neutralW = 0;
      for(let i=0;i<data.length;i+=4){
        const r=data[i],g=data[i+1],b=data[i+2];
        const [h,s,l]=rgb2hsl(r,g,b);
        n++; lAcc += l;
        if(s < 0.12){ neutralL += l; neutralW++; continue; } // skip near-neutral for hue
        // weight chromatic pixels by saturation, de-emphasise the near-white ground
        const w = s * (1 - Math.abs(l-0.5)*0.7);
        const bin = Math.min(BINS-1, Math.floor(h*BINS));
        hueWeight[bin] += w;
        hueSatSum[bin] += s*w;
        hueLightSum[bin] += l*w;
        satWeightTotal += w;
      }
      // pick dominant chromatic hue bin
      let best=-1, bestW=0;
      for(let k=0;k<BINS;k++){ if(hueWeight[k]>bestW){ bestW=hueWeight[k]; best=k; } }
      const avgL = n? lAcc/n : 0.6;
      if(best<0 || satWeightTotal < 0.02){
        // genuinely neutral pattern (greys / black & white). Park it as low-sat,
        // hue mapped from lightness so neutrals form their own greyscale eddy at the river's tail.
        const nl = neutralW? neutralL/neutralW : avgL;
        return { hue: 0, sat: 0.0, light: nl, neutral:true };
      }
      const hue = (best + 0.5)/BINS;            // bin centre
      const sat = clamp(hueSatSum[best]/bestW, 0, 1);
      const light = clamp(hueLightSum[best]/bestW, 0, 1);
      return { hue, sat, light, neutral:false };
    } catch(e){
      return { hue: 0, sat: 0, light: 0.6, neutral:true };
    }
  }

  // ---- THREE scene -------------------------------------------------------
  const app = document.getElementById("app");
  const W = ()=>window.innerWidth, H = ()=>window.innerHeight;

  const renderer = new THREE.WebGLRenderer({ antialias:true, alpha:false });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
  renderer.setSize(W(), H());
  renderer.setClearColor(0x07060a, 1);
  app.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  scene.fog = new THREE.FogExp2(0x07060a, 0.018);

  const camera = new THREE.PerspectiveCamera(52, W()/H(), 0.1, 400);
  camera.position.set(0, 0.4, 18);

  scene.add(new THREE.AmbientLight(0xffffff, 0.92));
  const key = new THREE.DirectionalLight(0xffffff, 0.55); key.position.set(4,8,12); scene.add(key);

  // --- the riverbed: a long horizontal gradient plane spanning the full spectrum ---
  const RIVER_LEN = 132;          // world units the ribbon spans in X (tighter = denser current)
  const X0 = -RIVER_LEN/2;
  function hueToX(h){ return X0 + h*RIVER_LEN; }
  // undulation of the current — bigger amplitude so the flow visibly courses
  function flowY(h){ return Math.sin(h*TAU*2.4)*3.6 + Math.sin(h*TAU*5.1+1.3)*1.5; }

  (function buildRiverbed(){
    const seg = 160;
    const geo = new THREE.PlaneGeometry(RIVER_LEN+30, 22, seg, 1);
    const cols = [];
    const pos = geo.attributes.position;
    const col = new THREE.Color();
    for(let i=0;i<pos.count;i++){
      const x = pos.getX(i);
      const t = clamp((x - X0)/RIVER_LEN, 0, 1);
      const yTop = pos.getY(i) > 0;
      // riverbed glows the spectrum hue, darker top/bottom for depth
      col.setHSL(t, 0.55, yTop?0.05:0.14);
      cols.push(col.r,col.g,col.b);
    }
    geo.setAttribute("color", new THREE.Float32BufferAttribute(cols,3));
    const mat = new THREE.MeshBasicMaterial({ vertexColors:true });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.position.set(0, 0, -8);
    scene.add(mesh);

    // a brighter thin "waterline" gradient strip behind the swatches
    const geo2 = new THREE.PlaneGeometry(RIVER_LEN+30, 7.5, seg, 1);
    const cols2=[];
    const p2=geo2.attributes.position;
    for(let i=0;i<p2.count;i++){
      const x=p2.getX(i); const t=clamp((x-X0)/RIVER_LEN,0,1);
      col.setHSL(t,0.7,0.42); cols2.push(col.r,col.g,col.b);
    }
    geo2.setAttribute("color", new THREE.Float32BufferAttribute(cols2,3));
    const m2 = new THREE.Mesh(geo2, new THREE.MeshBasicMaterial({vertexColors:true, transparent:true, opacity:0.5}));
    m2.position.set(0,0,-5.5);
    scene.add(m2);
  })();

  // group that holds all swatches and pans in X
  const flow = new THREE.Group();
  scene.add(flow);

  const swatchGeo = new THREE.PlaneGeometry(1,1);
  const loader = new THREE.TextureLoader();
  loader.crossOrigin = "anonymous";

  const swatches = [];   // { mesh, base{x,y,z,scale}, data, hue }
  let focused = null;

  // raycast picking
  const ray = new THREE.Raycaster();
  const ndc = new THREE.Vector2();
  const pickables = [];

  // ---- build a swatch mesh ----
  function addSwatch(d, analysis, tex){
    const { hue, sat, light, neutral } = analysis;
    // X by hue; neutrals pushed to the far warm end as a greyscale eddy
    const hpos = neutral ? 0.985 : hue;
    const x = hueToX(hpos) + (Math.random()-0.5)*1.3;     // jitter so equal hues don't z-fight
    const baseY = flowY(hpos);
    // saturation lifts the swatch up onto the crest; lightness pushes depth
    const y = baseY + (sat-0.4)*2.6 + (Math.random()-0.5)*1.1;
    const z = (light-0.5)*3.2 + (Math.random()-0.5)*1.4;
    const scale = lerp(1.7, 2.7, sat) ;                  // punchier hues bigger

    const mat = new THREE.MeshBasicMaterial({ map:tex, toneMapped:false });
    const mesh = new THREE.Mesh(swatchGeo, mat);
    mesh.position.set(x, y, z);
    mesh.scale.setScalar(scale);
    mesh.userData.idx = swatches.length;
    flow.add(mesh);
    pickables.push(mesh);

    swatches.push({ mesh, base:{x,y,z,scale}, data:d, hue:hpos, sat, light });
  }

  // ---- data load + texture pipeline -------------------------------------
  const bootlbl = document.getElementById("bootlbl");
  let loadedCount = 0, totalCount = 0;

  function imgURL(u){
    // CDN sends ACAO:* so direct load works & is canvas-readable; fall back to proxy for non-cdn.
    if(/cdn\.shopify\.com/.test(u)) return u;
    return "/api/proxy/image?url=" + encodeURIComponent(u);
  }

  function loadOne(d){
    return new Promise((resolve)=>{
      const url = imgURL(d.image);
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.onload = ()=>{
        const analysis = analyzeImage(img);
        const tex = new THREE.Texture(img);
        tex.colorSpace = THREE.SRGBColorSpace || undefined;
        tex.needsUpdate = true;
        tex.anisotropy = 4;
        addSwatch(d, analysis, tex);
        loadedCount++;
        bootlbl.textContent = `Sorting ${loadedCount} / ${totalCount} swatches by hue…`;
        resolve();
      };
      img.onerror = ()=>{ loadedCount++; resolve(); };
      img.src = url;
    });
  }

  async function start(){
    let products = [];
    try {
      const res = await fetch(API);
      const json = await res.json();
      products = json.products || [];
    } catch(e){
      bootlbl.textContent = "Could not reach the catalog.";
      console.error("fetch failed", e);
      return;
    }
    totalCount = products.length;
    // load with limited concurrency so we don't stall the main thread
    const POOL = 8;
    let cursor = 0;
    async function worker(){
      while(cursor < products.length){
        const d = products[cursor++];
        await loadOne(d);
      }
    }
    await Promise.all(Array.from({length:POOL}, worker));

    // sort swatches array by hue so rail navigation maps cleanly
    swatches.sort((a,b)=>a.hue - b.hue);

    // open the river at its DENSEST stretch so the first frame is full of color,
    // not a half-empty warm head. Sliding window over hue density.
    if(swatches.length){
      const win = 0.09;             // ~10% of the spectrum visible at once
      let bestH = swatches[Math.floor(swatches.length/2)].hue, bestN = 0;
      for(const s of swatches){
        let n=0; for(const o of swatches){ if(Math.abs(o.hue - s.hue) <= win) n++; }
        if(n>bestN){ bestN=n; bestH=s.hue; }
      }
      pan = targetPan = clamp(-hueToX(bestH), PAN_MIN, PAN_MAX);
    }
    window.__RIVER_DONE = true;

    finishBoot();
  }

  function finishBoot(){
    const boot = document.getElementById("boot");
    boot.style.opacity = "0";
    setTimeout(()=>boot.remove(), 850);
    buildRail();
  }

  // ---- hue rail (navigation) --------------------------------------------
  const HUE_BANDS = [
    [0.00,"Coral"],[0.06,"Terracotta"],[0.12,"Gold"],[0.22,"Citron"],
    [0.30,"Jungle"],[0.42,"Viridian"],[0.50,"Teal"],[0.58,"Sky"],
    [0.66,"Indigo"],[0.74,"Violet"],[0.82,"Plum"],[0.92,"Magenta"]
  ];
  function buildRail(){
    const rail = document.getElementById("rail");
    rail.innerHTML = "";
    HUE_BANDS.forEach(([h,name],i)=>{
      const band = document.createElement("div");
      band.className = "band";
      const c1 = hsl2css(h,0.65,0.42), c2 = hsl2css((HUE_BANDS[i+1]?HUE_BANDS[i+1][0]:1),0.65,0.42);
      band.style.background = `linear-gradient(90deg, ${c1}, ${c2})`;
      const tk = document.createElement("div"); tk.className="tick"; tk.textContent=name;
      band.appendChild(tk); rail.appendChild(band);
    });
    rail.addEventListener("pointerdown", e=>{
      const r = rail.getBoundingClientRect();
      const t = clamp((e.clientX - r.left)/r.width, 0, 1);
      targetPan = -hueToX(t);   // center that hue
    });
  }

  // ---- camera pan / travel ----------------------------------------------
  // We pan the flow group in X. pan ranges so first/last swatch stay reachable.
  let pan = 0, targetPan = -hueToX(0.0);   // start at the warm head of the river
  const PAN_MIN = -hueToX(1.0) , PAN_MAX = -hueToX(0.0);
  function clampPan(p){ return clamp(p, PAN_MIN, PAN_MAX); }

  // wheel
  window.addEventListener("wheel", (e)=>{
    e.preventDefault();
    const dom = (Math.abs(e.deltaX) > Math.abs(e.deltaY)) ? e.deltaX : e.deltaY;
    targetPan = clampPan(targetPan - dom*0.045);
    if(focused) defocus();
  }, { passive:false });

  // drag
  let dragging=false, dragStartX=0, panStart=0, moved=0;
  renderer.domElement.addEventListener("pointerdown",(e)=>{
    dragging=true; moved=0; dragStartX=e.clientX; panStart=targetPan;
    renderer.domElement.setPointerCapture(e.pointerId);
  });
  renderer.domElement.addEventListener("pointermove",(e)=>{
    if(!dragging) return;
    const dx = e.clientX - dragStartX; moved += Math.abs(dx);
    targetPan = clampPan(panStart + dx*0.06);
  });
  renderer.domElement.addEventListener("pointerup",(e)=>{
    dragging=false;
    if(moved < 6){ tryPick(e); }   // treat as click
  });

  // touch fallback handled by pointer events above

  // ---- picking / focus ---------------------------------------------------
  function tryPick(e){
    const r = renderer.domElement.getBoundingClientRect();
    ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
    ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
    ray.setFromCamera(ndc, camera);
    const hits = ray.intersectObjects(pickables, false);
    if(hits.length){
      const mesh = hits[0].object;
      const sw = swatches.find(s=>s.mesh===mesh);
      if(sw) focus(sw);
    } else if(focused){ defocus(); }
  }

  function focus(sw){
    focused = sw;
    // bring its hue to center
    targetPan = clampPan(-sw.base.x);
    showDetail(sw);
  }
  function defocus(){
    focused = null;
    hideDetail();
  }

  // detail card
  const detail = document.getElementById("detail");
  document.querySelector("#detail .closex").addEventListener("click", defocus);
  function showDetail(sw){
    const d = sw.data;
    document.getElementById("d_name").textContent = d.pattern_name || d.title || "Untitled";
    document.getElementById("d_coll").textContent = (d.collection || d.vendor || "China Seas");
    const w = d.width||27;
    document.getElementById("d_dim").textContent = `${w}" × ${w}"`;
    document.getElementById("d_rep").textContent = d.repeat ? `${d.repeat}"` : "—";
    document.getElementById("d_match").textContent = d.match_type || "—";
    document.getElementById("d_finish").textContent = d.finish || "—";
    document.getElementById("d_mat").textContent = d.material || "—";
    const chip = document.getElementById("d_chip");
    chip.style.background = sw.sat>0.02 ? hsl2css(sw.hue,sw.sat,sw.light) : hsl2css(0,0,sw.light||0.5);
    const cta = document.getElementById("d_cta");
    if(d.store_url){ cta.href = d.store_url; cta.style.display="block"; }
    else cta.style.display="none";
    detail.classList.add("show");
  }
  function hideDetail(){ detail.classList.remove("show"); }

  // ---- resize ------------------------------------------------------------
  window.addEventListener("resize", ()=>{
    camera.aspect = W()/H(); camera.updateProjectionMatrix();
    renderer.setSize(W(), H());
  });

  // ---- render loop -------------------------------------------------------
  const railhandle = document.getElementById("railhandle");
  const hint = document.querySelector(".hint");
  let hintFaded = false;
  let t0 = performance.now();

  function tick(now){
    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
    pan += (targetPan - pan) * Math.min(1, dt*6.5);
    flow.position.x = pan;

    // gentle drift of the whole current so it feels alive
    const breathe = Math.sin(now*0.0004)*0.15;
    flow.position.y = breathe;

    // billboard + focus animation
    for(const sw of swatches){
      const m = sw.mesh;
      m.lookAt(camera.position.x, camera.position.y, m.position.z + 10); // face camera (yaw only-ish)
      m.rotation.x = 0; // keep upright
      let targetScale = sw.base.scale;
      let tz = sw.base.z, ty = sw.base.y;
      if(sw === focused){
        targetScale = 6.4;
        tz = sw.base.z + 9.5;      // float toward camera
        ty = sw.base.y*0.3 + 0.6;
      }
      m.scale.x += (targetScale - m.scale.x)*Math.min(1,dt*8);
      m.scale.y = m.scale.x;
      m.position.z += (tz - m.position.z)*Math.min(1,dt*8);
      m.position.y += (ty - m.position.y)*Math.min(1,dt*8);
      // dim non-focused when something is focused
      const wantOp = (focused && sw!==focused) ? 0.28 : 1.0;
      if(m.material.opacity===undefined) m.material.opacity=1;
      m.material.transparent = wantOp<1;
      m.material.opacity += (wantOp - m.material.opacity)*Math.min(1,dt*8);
    }

    // rail handle position = current center hue
    const centerHue = clamp((-pan - X0)/RIVER_LEN, 0, 1);
    railhandle.style.left = (centerHue*100).toFixed(2)+"%";

    if(!hintFaded && Math.abs(targetPan - PAN_MAX) > 4){
      hint.style.opacity = "0"; hintFaded = true;
    }

    renderer.render(scene, camera);
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

  // expose a tiny probe for headless verification
  window.__RIVER__ = {
    ready:()=>swatches.length,
    done:()=>!!window.__RIVER_DONE,
    sortedByHue:()=>{
      // visual sort: swatch world-X must increase monotonically with hue
      const arr = swatches.slice().sort((a,b)=>a.hue-b.hue);
      for(let i=1;i<arr.length;i++){ if(arr[i].hue < arr[i-1].hue-0.001) return false; }
      return true;
    },
    // proves layout is spatial-by-hue: mesh X strictly tracks hue
    spatialSorted:()=>{
      const arr = swatches.slice().sort((a,b)=>a.base.x-b.base.x);
      for(let i=1;i<arr.length;i++){ if(arr[i].hue < arr[i-1].hue - 0.06) return false; }
      return true;
    },
    hues:()=>swatches.slice().sort((a,b)=>a.hue-b.hue).map(s=>+s.hue.toFixed(3)),
    count:()=>swatches.length
  };

  start();
})();
</script>
</body>
</html>