← back to Quadrille Showroom
public/proto/v6-colorriver.html
674 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">×</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";
const ENRICH_URL = "./enrichment.json"; // REAL hue/color_bucket per sku
let ENRICH = {}; // sku -> { hue(0-360), color_bucket, family }
// ---- 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);
// CONTRARIAN FIX — a legible near-edge WATERLINE so the neutral shore reads as
// an actual bank against water, not just dimmer tiles floating behind. A thin
// bright spectrum strip sits just above the shore line; below it the bank dims
// to near-black so the eye registers shore → waterline → stream.
const geoShore = new THREE.PlaneGeometry(RIVER_LEN+30, 2.2, seg, 1);
const colsS=[]; const pS=geoShore.attributes.position;
for(let i=0;i<pS.count;i++){
const x=pS.getX(i); const t=clamp((x-X0)/RIVER_LEN,0,1);
const top = pS.getY(i) > 0;
col.setHSL(t,0.5,top?0.30:0.03); // bright waterline fading to dark bank below
colsS.push(col.r,col.g,col.b);
}
geoShore.setAttribute("color", new THREE.Float32BufferAttribute(colsS,3));
const mShore = new THREE.Mesh(geoShore, new THREE.MeshBasicMaterial({vertexColors:true, transparent:true, opacity:0.85}));
mShore.position.set(0,-4.0,-3.4); // between bank neutrals (back/low) and the stream
scene.add(mShore);
})();
// 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 = [];
// Running neutral counter + density model for steering neutrals into thin
// chromatic stretches (computed in start()).
let neutralN = 0;
const DENS_BINS = 36;
const DENSITY = new Float64Array(DENS_BINS);
const NEUTRAL_TARGETS = []; // { t(0..1 hue), bridge:bool } consumed in order
// ---- build a swatch mesh ----
function addSwatch(d, analysis, tex){
const { hue, sat, light, neutral } = analysis;
// CONTRARIAN FIX — neutrals no longer dam up the warm head of the river.
// They're woven along the FAR BANK as a continuous shoreline that runs the
// whole length of the current, so the eye never hits a greyscale dead-zone:
// a neutral pattern sits at a x-position spread evenly across the river and
// is dropped low + back (the bank), letting the chromatic current flow
// unbroken above it.
let hpos, x, y, z, bridge=false;
if(neutral){
// Pull the next density-steered target: neutrals are aimed at the THIN
// chromatic stretches. If the target sits in a real gap, the neutral is
// LIFTED up into the stream as a stepping stone that bridges the pause;
// otherwise it settles onto the far bank as legible shoreline.
const tgt = NEUTRAL_TARGETS[neutralN] || { t:(neutralN+0.5)/NEUTRAL_TOTAL, bridge:false };
neutralN++;
hpos = tgt.t;
bridge = tgt.bridge;
x = hueToX(hpos) + (Math.random()-0.5)*1.1;
if(bridge){
// stepping stone IN the current — sits at stream level so the eye reads
// an unbroken flow across the otherwise-empty hue band
const baseY = flowY(hpos);
y = baseY + (Math.random()-0.5)*1.0;
z = (light-0.5)*2.2 + (Math.random()-0.5)*1.0; // mid-depth, in the stream
} else {
const bank = -5.0 - Math.random()*1.1; // low: far bank
y = bank + Math.sin(hpos*TAU*3.1)*0.45; // continuous shoreline undulation
z = -2.4 - Math.random()*1.4; // back: reads as shore, not stream
}
} else {
hpos = hue;
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
y = baseY + (sat-0.4)*2.6 + (Math.random()-0.5)*1.1;
z = (light-0.5)*3.2 + (Math.random()-0.5)*1.4;
}
// bridge stones a touch bigger than bank neutrals so they hold the stream
const scale = neutral ? (bridge?lerp(1.6,2.0,light):lerp(1.3,1.6,light)) : lerp(1.7, 2.7, sat);
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, neutral:!!neutral, bridge, phase:Math.random()*TAU });
}
// ---- 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);
}
// Ground the swatch's river position on the REAL enrichment hue when we have
// it; the client-side pixel analysis is only a fallback. Enrichment hue is in
// degrees (0-360) → normalise to 0..1; lightness/sat still come from pixels so
// the swatch keeps its depth/crest placement.
function groundedAnalysis(d, img){
const px = analyzeImage(img);
const en = ENRICH[d.sku];
if(en && typeof en.hue === "number"){
const isNeutral = en.color_bucket==null || en.color_bucket==="neutral" || en.color_bucket==="grey";
return { hue: (en.hue%360)/360, sat: isNeutral?0.05:Math.max(px.sat,0.18),
light: px.light, neutral: isNeutral, bucket: en.color_bucket||null };
}
return Object.assign(px, { bucket:null });
}
function loadOne(d){
return new Promise((resolve)=>{
const url = imgURL(d.image);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = ()=>{
const analysis = groundedAnalysis(d, 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;
});
}
let NEUTRAL_TOTAL = 0;
async function start(){
// load REAL enrichment hue/bucket first so the river is grounded, not guessed
try {
const er = await fetch(ENRICH_URL);
const ej = await er.json();
ENRICH = (ej && ej.enrich) ? ej.enrich : (ej || {});
} catch(e){ ENRICH = {}; }
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;
// Build the CHROMATIC-density curve across hue from real enrichment data, so
// neutrals can be steered INTO the thin stretches (bridging the pause) rather
// than spread blindly by count. DENSITY[bin] = # of chromatic patterns whose
// hue falls in that bin; the inverse becomes the neutral placement weighting.
NEUTRAL_TOTAL = 0;
for(let i=0;i<DENS_BINS;i++) DENSITY[i]=0;
for(const p of products){
const en = ENRICH[p.sku];
const isN = en ? (en.color_bucket==null||en.color_bucket==="neutral"||en.color_bucket==="grey") : false;
if(isN){ NEUTRAL_TOTAL++; continue; }
const h = en && typeof en.hue==="number" ? (en.hue%360)/360 : null;
if(h!=null){ DENSITY[Math.min(DENS_BINS-1, Math.floor(h*DENS_BINS))]++; }
}
if(!NEUTRAL_TOTAL) NEUTRAL_TOTAL = 1;
// Pre-compute a queue of neutral X-targets (0..1 hue positions) biased toward
// the THINNEST chromatic bins. A bin is "starved" if it's below the mean; we
// hand each starved bin a share of neutrals proportional to how starved it is,
// and flag those drops to be LIFTED into the stream as stepping stones.
const mean = DENSITY.reduce((a,b)=>a+b,0)/DENS_BINS;
const starve = DENSITY.map(d => Math.max(0, mean - d)); // how empty each bin is
const starveSum = starve.reduce((a,b)=>a+b,0) || 1;
NEUTRAL_TARGETS.length = 0;
for(let bin=0; bin<DENS_BINS; bin++){
const share = Math.round((starve[bin]/starveSum) * NEUTRAL_TOTAL);
const isGap = DENSITY[bin] < mean*0.5; // genuinely thin → lift stones into the stream
for(let k=0;k<share;k++){
const t = (bin + (k+0.5)/Math.max(1,share)) / DENS_BINS; // spread within the bin
NEUTRAL_TARGETS.push({ t, bridge:isGap });
}
}
// any leftover neutrals (rounding) line the bank evenly so none are lost
while(NEUTRAL_TARGETS.length < NEUTRAL_TOTAL){
NEUTRAL_TARGETS.push({ t:(NEUTRAL_TARGETS.length+0.5)/NEUTRAL_TOTAL, bridge:false });
}
// 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;
// CONTRARIAN FIX — give every swatch a small per-swatch flow bob keyed to its
// own phase + river position, so the current keeps visibly coursing even
// across stretches where hue density thins (no visual stall mid-river). The
// shoreline neutrals bob on a slower, shallower rhythm than the stream.
const tFlow = now*0.001;
// billboard + focus animation
for(const sw of swatches){
const m = sw.mesh;
const onStream = !sw.neutral || sw.bridge; // bridge stones ride the current
const amp = onStream ? 0.42 : 0.18;
const spd = onStream ? 1.0 : 0.6;
const bob = Math.sin(tFlow*spd + sw.phase + sw.base.x*0.12)*amp;
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 + bob;
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>
<script>window.PROTO_META={"key":"V6","label":"V6 — Color River","elements":[{"n":1,"label":"All 883 swatches flow into one continuous hue gradient"},{"n":2,"label":"Color buckets (red→orange→yellow→green→blue→magenta)"},{"n":3,"label":"Scrub the river to jump to a color family"},{"n":4,"label":"Click a swatch → enlarge + spec"},{"n":5,"label":"Shop-by-color header + live count"}]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>