← 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">×</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>