import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { mixRGB } from "/static/app.js?v=21"; const col = (mix) => { const [r, g, b] = mixRGB(mix); return new THREE.Color(r / 255, g / 255, b / 255); }; const UNIT_COLORS = [ new THREE.Color(0xa855f7), // Purple new THREE.Color(0x3b82f6), // Blue new THREE.Color(0x1fe0d0), // Cyan/Teal new THREE.Color(0xf59e0b), // Gold new THREE.Color(0x22c55e), // Green new THREE.Color(0xf43f5e), // Pink new THREE.Color(0x6366f1), // Indigo new THREE.Color(0xf97316), // Orange ]; // world layout constants (Z is now vertical, Y is layer depth, X is horizontal width) const LAYER_GAP = 7.0; // spacing between layers along Y (depth) const BLOCK_H = 2.0; // vertical height (along Z) const BLOCK_T = 0.55; // thickness (along Y) const WORLD_W = 26; // total row width (along X) const SOUNDBAR_X = -15.0; // X position of sound bar to the right of the row const FOCUS_X = 15.0; // X position of focus button to the left of the row const SOUNDBAR_W = 2.2; // sound bar base plate width export class CardsView { constructor(container, hooks) { this.c = container; this.hooks = hooks; this.data = null; this.blocks = []; // {mesh, label, unit, layer, baseOp, hoverOffset, targetHoverOffset, playOffset, playVelocity, isDivider} this.ribbons = []; // {mesh, a, b, lk, baseOp} // Morph states: 0 = 2D Parallel Sets, 1 = 3D Card Stack this.perspective = 1.0; this.targetPerspective = 1.0; this.transitionSpeed = 0.08; this.camRadius = null; this.targetCamRadius = null; this.theta3D = null; this.currentTheta = 0; this.targetTheta = 0; this.targetPan = new THREE.Vector3(0, 0, 0); this.panOffset = new THREE.Vector3(0, 0, 0); // horizontal view pan (x,y) this.targetPanOffset = new THREE.Vector3(0, 0, 0); // camera target pan offset this.panVel = new THREE.Vector3(0, 0, 0); // pan momentum (units/frame) this.activeLayerIdx = -1; this.activeFocusLayerIdx = -1; this._hoveredBlock = null; this.controlsEnabled = true; this._init(); } _init() { const w = this.c.clientWidth || 800, h = this.c.clientHeight || 600; this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); this.renderer.setSize(w, h); // Enable soft shadow mapping this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.c.appendChild(this.renderer.domElement); this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x030408); // dark background this.scene.fog = new THREE.FogExp2(0x030408, 0.0055); this.camera = new THREE.PerspectiveCamera(42, w / h, 0.1, 400); this.camera.up.set(0, 0, 1); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.08; this.controls.maxPolarAngle = Math.PI * 0.5; this.controls.minDistance = 4.0; this.controls.maxDistance = 100.0; // Wheel zoom is intentionally OFF so the mouse wheel scrolls the host page // normally (the app is embedded in an iframe on the HF Space — hijacking the // wheel made the stack "keep expanding" on scroll). Zoom/perspective are // driven by the on-screen slider + buttons instead. this.controls.enableZoom = false; // Bright studio ambient + soft sky/ground hemisphere fill this.scene.add(new THREE.AmbientLight(0xffffff, 1.15)); this.scene.add(new THREE.HemisphereLight(0xffffff, 0xd7dcea, 0.9)); // Directional Key Light with shadow casting (Z is now vertical high-axis) const k = new THREE.DirectionalLight(0xffffff, 1.7); k.position.set(12, 18, 35); k.castShadow = true; k.shadow.mapSize.width = 2048; k.shadow.mapSize.height = 2048; k.shadow.camera.near = 0.5; k.shadow.camera.far = 120; const d = 40; k.shadow.camera.left = -d; k.shadow.camera.right = d; k.shadow.camera.top = d; k.shadow.camera.bottom = -d; k.shadow.bias = -0.0003; this.scene.add(k); // Cyan and Purple accents saved as properties for animated shimmer reflections (Z is vertical) this.p1 = new THREE.PointLight(0xa855f7, 70, 100); this.p1.position.set(-22, -18, 10); this.scene.add(this.p1); this.p2 = new THREE.PointLight(0x1fe0d0, 70, 100); this.p2.position.set(22, 18, 10); this.scene.add(this.p2); this._initTextures(); this._ground(); this.raycaster = new THREE.Raycaster(); this.pointer = new THREE.Vector2(); this._hovered = null; this.renderer.domElement.addEventListener("pointermove", (e) => this._onMove(e)); this.renderer.domElement.addEventListener("pointerdown", (e) => this._onClick(e)); window.addEventListener("resize", () => this._resize()); this._animate(); } _initTextures() { // 1. High-fidelity procedural walnut wood texture (pixel loop) const woodCanvas = document.createElement("canvas"); woodCanvas.width = 512; woodCanvas.height = 512; const wCtx = woodCanvas.getContext("2d"); const imgData = wCtx.createImageData(512, 512); const data = imgData.data; // Distorted coordinate generation for realistic wood grain waves for (let y = 0; y < 512; y++) { for (let x = 0; x < 512; x++) { // wood plank runs vertically (grain lines parallel to Y) const scaleX = x * 0.055; const scaleY = y * 0.009; // Add coordinate distortion (wave frequency and amplitude) const distortion = Math.sin(scaleY * 2.8) * 3.5 + Math.cos(scaleX * 0.45) * 1.5; const ringValue = Math.sin((scaleX + distortion) * 1.1) * 0.5 + 0.5; // Add fine fibrous noise const fiberValue = (Math.sin(x * 1.6) * Math.cos(y * 0.22)) * 0.12; const val = Math.max(0, Math.min(1, ringValue + fiberValue)); // Map val to a rich, warm dark walnut color gradient // Dark ring lines: #20110a (rgb 32, 17, 10) // Mid grain: #3a2014 (rgb 58, 32, 20) // Golden grain: #4c2c1b (rgb 76, 44, 27) // Rich, warm dark walnut wood tones let r, g, b; if (val < 0.5) { const t = val * 2; r = 32 + t * (58 - 32); g = 17 + t * (32 - 17); b = 10 + t * (20 - 10); } else { const t = (val - 0.5) * 2; r = 58 + t * (76 - 58); g = 32 + t * (44 - 32); b = 20 + t * (27 - 20); } // Apply slight noise/roughness const noise = (Math.random() - 0.5) * 4; r = Math.max(0, Math.min(255, Math.round(r + noise))); g = Math.max(0, Math.min(255, Math.round(g + noise))); b = Math.max(0, Math.min(255, Math.round(b + noise))); const pixelIdx = (x + y * 512) * 4; data[pixelIdx] = r; data[pixelIdx + 1] = g; data[pixelIdx + 2] = b; data[pixelIdx + 3] = 255; } } wCtx.putImageData(imgData, 0, 0); // Draw fine vertical wood pores/scratches along the Y axis wCtx.strokeStyle = "rgba(76, 44, 27, 0.22)"; wCtx.lineWidth = 1.0; for (let i = 0; i < 500; i++) { const x = Math.random() * 512; const y = Math.random() * 512; const len = 35 + Math.random() * 140; wCtx.beginPath(); wCtx.moveTo(x, y); wCtx.lineTo(x, y + len); wCtx.stroke(); } // Fine vertical light grain reflections wCtx.strokeStyle = "rgba(255, 255, 255, 0.015)"; wCtx.lineWidth = 0.8; for (let i = 0; i < 300; i++) { const x = Math.random() * 512; const y = Math.random() * 512; const len = 20 + Math.random() * 80; wCtx.beginPath(); wCtx.moveTo(x, y); wCtx.lineTo(x, y + len); wCtx.stroke(); } this.woodTex = new THREE.CanvasTexture(woodCanvas); this.woodTex.wrapS = THREE.RepeatWrapping; this.woodTex.wrapT = THREE.RepeatWrapping; this.woodTex.repeat.set(4, 4); // 2. Procedural Card Brushed Metal Scratch Map const metalCanvas = document.createElement("canvas"); metalCanvas.width = 256; metalCanvas.height = 256; const mCtx = metalCanvas.getContext("2d"); mCtx.fillStyle = "#808080"; mCtx.fillRect(0, 0, 256, 256); // Fine bright steel scratches mCtx.strokeStyle = "rgba(255, 255, 255, 0.16)"; mCtx.lineWidth = 1.0; for (let i = 0; i < 500; i++) { const x = Math.random() * 256; const y = Math.random() * 256; const len = 25 + Math.random() * 80; mCtx.beginPath(); mCtx.moveTo(x, y); mCtx.lineTo(x + len, y); mCtx.stroke(); } // Fine dark steel scratches mCtx.strokeStyle = "rgba(0, 0, 0, 0.08)"; for (let i = 0; i < 250; i++) { const x = Math.random() * 256; const y = Math.random() * 256; const len = 15 + Math.random() * 55; mCtx.beginPath(); mCtx.moveTo(x, y); mCtx.lineTo(x + len, y); mCtx.stroke(); } this.cardMetalTex = new THREE.CanvasTexture(metalCanvas); this.cardMetalTex.wrapS = THREE.RepeatWrapping; this.cardMetalTex.wrapT = THREE.RepeatWrapping; this.cardMetalTex.repeat.set(2, 2); } _ground() { // 1. Sleek polished wooden walnut desk sitting flat in the X-Y plane (Z is up) const deskGeo = new THREE.PlaneGeometry(180, 180); const deskMat = new THREE.MeshStandardMaterial({ map: this.woodTex, bumpMap: this.woodTex, bumpScale: 0.005, roughness: 0.46, metalness: 0.08, side: THREE.DoubleSide }); const desk = new THREE.Mesh(deskGeo, deskMat); desk.position.z = 0; // sits flat on X-Y plane desk.receiveShadow = true; this.scene.add(desk); // 2. Faded structure grid overlay rotated flat to X-Y const grid = new THREE.GridHelper(180, 45, 0x7d93c4, 0xb9a98c); grid.rotation.x = Math.PI / 2; // orient to X-Y plane grid.position.z = 0.001; grid.material.opacity = 0.06; grid.material.transparent = true; this.scene.add(grid); // 3. Ambient radial glow texture const c = document.createElement("canvas"); c.width = c.height = 512; const g = c.getContext("2d"); const grd = g.createRadialGradient(256, 256, 10, 256, 256, 256); grd.addColorStop(0, "rgba(255,255,255,0.12)"); grd.addColorStop(1, "rgba(255,255,255,0)"); g.fillStyle = grd; g.fillRect(0, 0, 512, 512); const tex = new THREE.CanvasTexture(c); const glowPlane = new THREE.Mesh( new THREE.PlaneGeometry(150, 150), new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }) ); glowPlane.position.z = 0.002; this.scene.add(glowPlane); } _clear() { [...this.blocks, ...this.ribbons].forEach((o) => { o.mesh.geometry.dispose(); if (o.mesh.material.map) o.mesh.material.map.dispose(); o.mesh.material.dispose(); this.scene.remove(o.mesh); }); this.blocks = []; this.ribbons = []; } render(data) { this.data = data; this._clear(); const layers = data.layers; const n = layers.length; const byId = {}; // Fine lane dividers on the wooden desk (polished brass dividers aligned horizontally in X-Y) for (let i = 0; i < n - 1; i++) { const dividerY = (i - (n - 2) / 2) * LAYER_GAP - LAYER_GAP / 2; const dividerGeo = new THREE.BoxGeometry(WORLD_W * 1.25, 0.04, 0.02); const dividerMat = new THREE.MeshStandardMaterial({ color: 0xb58e4c, metalness: 1.0, roughness: 0.24 }); const divMesh = new THREE.Mesh(dividerGeo, dividerMat); divMesh.position.set(0, dividerY, 0.005); divMesh.receiveShadow = true; this.scene.add(divMesh); this.blocks.push({ mesh: divMesh, isDivider: true }); } layers.forEach((layer, li) => { const y = (li - (n - 1) / 2) * LAYER_GAP; // layer depth along Y // Realistic 3D speaker beside each row — CLICK it to play that layer. const layerMix = n > 1 ? li / (n - 1) : 0.0; const layerColor = col(layerMix); const frontY = (BLOCK_T * 0.9) / 2; // local +Y face (toward viewer) const noRay = (m) => { m.raycast = () => {}; return m; }; // Cabinet (dark, matte — reads as a speaker enclosure on the light desk) const sbGeo = new THREE.BoxGeometry(SOUNDBAR_W, BLOCK_T * 0.9, BLOCK_H); const sbMat = new THREE.MeshStandardMaterial({ color: 0x232838, roughness: 0.55, metalness: 0.5 }); const sbMesh = new THREE.Mesh(sbGeo, sbMat); sbMesh.castShadow = true; sbMesh.receiveShadow = true; sbMesh.position.set(SOUNDBAR_X, y, BLOCK_H / 2); sbMesh.add(new THREE.LineSegments( new THREE.EdgesGeometry(sbGeo), new THREE.LineBasicMaterial({ color: 0x3a4256, transparent: true, opacity: 0.5 }) )); const darkMat = new THREE.MeshStandardMaterial({ color: 0x0c0e16, roughness: 0.45, metalness: 0.7 }); const coneMat = new THREE.MeshStandardMaterial({ color: layerColor, emissive: layerColor, emissiveIntensity: 0.15, metalness: 0.45, roughness: 0.4 }); const domeMat = new THREE.MeshStandardMaterial({ color: layerColor, emissive: layerColor, emissiveIntensity: 0.2, metalness: 0.55, roughness: 0.3 }); // Woofer: rubber surround (torus) + convex cone + dust cap (lower) const surround = noRay(new THREE.Mesh(new THREE.TorusGeometry(0.5, 0.08, 16, 32), darkMat)); surround.rotation.x = Math.PI / 2; surround.position.set(0, frontY + 0.02, -0.45); sbMesh.add(surround); const woofer = noRay(new THREE.Mesh(new THREE.ConeGeometry(0.46, 0.18, 32), coneMat)); woofer.position.set(0, frontY + 0.10, -0.45); // apex toward +Y (viewer) sbMesh.add(woofer); const cap = noRay(new THREE.Mesh(new THREE.SphereGeometry(0.1, 16, 16), darkMat)); cap.position.set(0, frontY + 0.18, -0.45); sbMesh.add(cap); // Tweeter (upper): small surround + dome const twRing = noRay(new THREE.Mesh(new THREE.TorusGeometry(0.2, 0.05, 12, 24), darkMat)); twRing.rotation.x = Math.PI / 2; twRing.position.set(0, frontY + 0.02, 0.55); sbMesh.add(twRing); const tweeter = noRay(new THREE.Mesh(new THREE.SphereGeometry(0.16, 20, 20), domeMat)); tweeter.position.set(0, frontY + 0.05, 0.55); sbMesh.add(tweeter); // Status LED (glows while playing) const led = noRay(new THREE.Mesh( new THREE.SphereGeometry(0.055, 12, 12), new THREE.MeshStandardMaterial({ color: layerColor, emissive: layerColor, emissiveIntensity: 0.4 }) )); led.position.set(0.72, frontY + 0.03, 0.92); sbMesh.add(led); this.scene.add(sbMesh); this.blocks.push({ mesh: sbMesh, driver: woofer, tweeter, led, layer: li, isSoundBar: true, isDivider: false, hoverOffset: 0.0, targetHoverOffset: 0.0, playOffset: 0.0, playVelocity: 0.0, baseOp: 1.0, }); // Sleek Viewfinder/Focus Button on screen-LEFT (opposite speaker) const fbGeo = new THREE.BoxGeometry(SOUNDBAR_W * 0.7, BLOCK_T * 0.9, BLOCK_H); const fbMat = new THREE.MeshStandardMaterial({ color: 0x1f2330, roughness: 0.6, metalness: 0.4 }); const fbMesh = new THREE.Mesh(fbGeo, fbMat); fbMesh.castShadow = true; fbMesh.receiveShadow = true; fbMesh.position.set(FOCUS_X, y, BLOCK_H / 2); fbMesh.add(new THREE.LineSegments( new THREE.EdgesGeometry(fbGeo), new THREE.LineBasicMaterial({ color: 0x31384b, transparent: true, opacity: 0.5 }) )); // Upright metallic bezel ring const bezelGeo = noRay(new THREE.TorusGeometry(0.35, 0.05, 12, 24)); const chromeMat = new THREE.MeshStandardMaterial({ color: 0x8a9db8, metalness: 0.9, roughness: 0.15 }); const bezel = new THREE.Mesh(bezelGeo, chromeMat); bezel.rotation.x = Math.PI / 2; bezel.position.set(0, frontY + 0.03, 0); fbMesh.add(bezel); // Crosshairs targeting lines const hcLine1 = noRay(new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.02, 0.5), chromeMat)); hcLine1.position.set(0, frontY + 0.04, 0); fbMesh.add(hcLine1); const hcLine2 = noRay(new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.02, 0.04), chromeMat)); hcLine2.position.set(0, frontY + 0.04, 0); fbMesh.add(hcLine2); // Central glowing viewfinder reticle dot const dotGeo = noRay(new THREE.SphereGeometry(0.08, 16, 16)); const dotMat = new THREE.MeshStandardMaterial({ color: layerColor, emissive: layerColor, emissiveIntensity: 0.2, metalness: 0.3, roughness: 0.3 }); const dot = new THREE.Mesh(dotGeo, dotMat); dot.position.set(0, frontY + 0.06, 0); fbMesh.add(dot); this.scene.add(fbMesh); this.blocks.push({ mesh: fbMesh, dot, layer: li, isFocusButton: true, isDivider: false, hoverOffset: 0.0, targetHoverOffset: 0.0, playOffset: 0.0, playVelocity: 0.0, baseOp: 1.0, }); const chunks = [...layer.chunks].sort((a, b) => a.pos - b.pos); const lens = chunks.map((c) => Math.max(2.2, c.text.length)); const tot = lens.reduce((a, b) => a + b, 0); const gap = 0.5; const totGap = gap * (chunks.length - 1); const scale = (WORLD_W - totGap) / tot; // Lay out from +X toward -X so phrase pos 0 renders on screen-LEFT. // (The camera sits at +Y looking -Y, which maps world -X to screen-right, // so a naive -X..+X layout would read the row backwards.) let x = WORLD_W / 2; chunks.forEach((ch, i) => { const w = Math.max(2.0, lens[i] * scale); const cx = x - w / 2; const color = UNIT_COLORS[ch.unit % UNIT_COLORS.length]; // Solid Machined Brushed Anodized Metal Block const mat = new THREE.MeshStandardMaterial({ color, transparent: true, opacity: 1.0, metalness: 1.0, roughness: 0.26, bumpMap: this.cardMetalTex, bumpScale: 0.003, emissive: color.clone().multiplyScalar(0.08), }); // Box width = X, thickness = Y, height = Z const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, BLOCK_T, BLOCK_H), mat); mesh.castShadow = true; mesh.receiveShadow = true; mesh.position.set(cx, y, BLOCK_H / 2); // Shiny glowing metallic edges const edges = new THREE.LineSegments( new THREE.EdgesGeometry(mesh.geometry), new THREE.LineBasicMaterial({ color: color.clone().lerp(new THREE.Color(1, 1, 1), 0.45), transparent: true, opacity: 0.8 }) ); mesh.add(edges); // Create label and add it flat against the local front face (local Y face) const label = this._label(ch.text, w, color); label.position.set(0, BLOCK_T / 2 + 0.02, 0); label.rotation.set(-Math.PI / 2, 0, Math.PI); mesh.add(label); this.scene.add(mesh); const rec = { mesh, label, unit: ch.unit, layer: li, baseOp: 1.0, cx, y, isDivider: false, // Physical spring offsets hoverOffset: 0.0, targetHoverOffset: 0.0, playOffset: 0.0, playVelocity: 0.0 }; this.blocks.push(rec); byId[ch.id] = rec; x -= w + gap; }); }); // Ribbons between adjacent layers data.links.forEach((lk) => { const a = byId[lk.from], b = byId[lk.to]; if (!a || !b) return; this.ribbons.push(this._ribbon(a, b, lk)); }); this._frameCamera(n); this._updateMorph(); } _label(text, w, color) { const PX = 256, ratio = Math.max(1, Math.min(12, w / BLOCK_H)); const cw = Math.round(PX * ratio), ch = PX; const cv = document.createElement("canvas"); cv.width = cw; cv.height = ch; const g = cv.getContext("2d"); g.clearRect(0, 0, cw, ch); let fs = 76; g.font = `600 ${fs}px 'Space Grotesk', system-ui, sans-serif`; const textWidth = g.measureText(text).width; const maxTextWidth = cw * 0.88; if (textWidth > maxTextWidth) { fs = Math.floor(fs * (maxTextWidth / textWidth)); g.font = `600 ${fs}px 'Space Grotesk', system-ui, sans-serif`; } g.fillStyle = "rgba(255,255,255,0.96)"; g.textAlign = "center"; g.textBaseline = "middle"; g.shadowColor = "rgba(0,0,0,0.6)"; g.shadowBlur = 8; g.fillText(text, cw / 2, ch / 2); const tex = new THREE.CanvasTexture(cv); tex.anisotropy = 4; const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide }); const plane = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.92, BLOCK_H * 0.92), mat); plane.renderOrder = 1; return plane; } // Ribbon as a thin metallic FOIL slab: 4 vertices per sample (top/bottom x // left/right) -> top face, bottom face, and two edges give it real thickness. _ribbon(a, b, lk) { const N = 40; const V = (N + 1) * 4; const geo = new THREE.BufferGeometry(); const verts = new Float32Array(V * 3); const cols = new Float32Array(V * 3); const colorA = a.mesh.material.color; const colorB = b.mesh.material.color; for (let i = 0; i <= N; i++) { const cc = colorA.clone().lerp(colorB, i / N); for (let c = 0; c < 4; c++) { const o = (i * 4 + c) * 3; cols[o] = cc.r; cols[o + 1] = cc.g; cols[o + 2] = cc.b; } } const idx = []; for (let i = 0; i < N; i++) { const a0 = i * 4, b0 = (i + 1) * 4; // corners: 0 TL,1 TR,2 BL,3 BR idx.push(a0 + 0, a0 + 1, b0 + 0, a0 + 1, b0 + 1, b0 + 0); // top idx.push(a0 + 2, b0 + 2, a0 + 3, a0 + 3, b0 + 2, b0 + 3); // bottom idx.push(a0 + 0, b0 + 0, a0 + 2, a0 + 2, b0 + 0, b0 + 2); // left edge idx.push(a0 + 1, a0 + 3, b0 + 1, a0 + 3, b0 + 3, b0 + 1); // right edge } geo.setAttribute("position", new THREE.BufferAttribute(verts, 3)); geo.setAttribute("color", new THREE.BufferAttribute(cols, 3)); geo.setIndex(idx); const mat = new THREE.MeshStandardMaterial({ vertexColors: true, transparent: true, opacity: lk.kind === "keep" ? 0.5 : 0.8, side: THREE.DoubleSide, depthWrite: false, metalness: 0.9, roughness: 0.3, }); const mesh = new THREE.Mesh(geo, mat); mesh.userData.baseOp = mat.opacity; this.scene.add(mesh); return { mesh, a, b, lk, baseOp: mat.opacity }; } _updateRibbonGeometry(rb, t) { const up = new THREE.Vector3(0, 0, 1); // vertical axis is Z const p0 = new THREE.Vector3(); const p3 = new THREE.Vector3(); // Precise endpoints: previous-layer card BOTTOM (local -Z) to next-layer // card TOP (local +Z). localToWorld tracks the card's live transform. rb.a.mesh.localToWorld(p0.set(0, 0, -BLOCK_H / 2)); rb.b.mesh.localToWorld(p3.set(0, 0, BLOCK_H / 2)); const dy = p3.y - p0.y; const dz = p3.z - p0.z; const lift = 0.6 * t; // gentle elevated arc in 3D, flat in 2D const c1 = p0.clone().add(new THREE.Vector3(0, dy * 0.25, dz * 0.25 + lift)); const c2 = p3.clone().add(new THREE.Vector3(0, -dy * 0.25, -dz * 0.25 + lift)); const curve = new THREE.CubicBezierCurve3(p0, c1, c2, p3); const N = 40; const hw = 1.25; // foil half-width const halfT = 0.08; // foil half-thickness const pts = curve.getPoints(N); const geo = rb.mesh.geometry; const posAttr = geo.getAttribute("position"); const verts = posAttr.array; const side = new THREE.Vector3(); const nrm = new THREE.Vector3(); let v = 0; for (let i = 0; i <= N; i++) { const pt = pts[i]; const tan = curve.getTangent(i / N); side.crossVectors(up, tan).normalize().multiplyScalar(hw); nrm.crossVectors(tan, side).normalize().multiplyScalar(halfT); // corners — 0: +side +nrm verts[v++] = pt.x + side.x + nrm.x; verts[v++] = pt.y + side.y + nrm.y; verts[v++] = pt.z + side.z + nrm.z; // 1: -side +nrm verts[v++] = pt.x - side.x + nrm.x; verts[v++] = pt.y - side.y + nrm.y; verts[v++] = pt.z - side.z + nrm.z; // 2: +side -nrm verts[v++] = pt.x + side.x - nrm.x; verts[v++] = pt.y + side.y - nrm.y; verts[v++] = pt.z + side.z - nrm.z; // 3: -side -nrm verts[v++] = pt.x - side.x - nrm.x; verts[v++] = pt.y - side.y - nrm.y; verts[v++] = pt.z - side.z - nrm.z; } posAttr.needsUpdate = true; geo.computeVertexNormals(); } _frameCamera(n) { const depth = (n - 1) * LAYER_GAP; // Radial distance and angled framing (theta3D = 60 degrees from vertical Z) // Pull out default viewing position a bit more this.camRadius = Math.max(18.5, depth * 0.55 + 13.5); this.targetCamRadius = this.camRadius; this.theta3D = 1.05; this.currentTheta = 0; // Reset rotation on new load this.targetTheta = 0; this.panOffset.set(0, 0, 0); // re-center pan on new load this.targetPanOffset.set(0, 0, 0); this.panVel.set(0, 0, 0); this.targetPan.set(0, 0, 1.0 * this.perspective); this.controls.target.copy(this.targetPan); if (this.hooks.onZoomChange) { this.hooks.onZoomChange(this.getZoom()); } } _updateCameraPerspective(t, forceUseCurrentTheta = false) { if (!this.camRadius) return; // Get target-relative camera position const dx = this.camera.position.x - this.controls.target.x; const dy = this.camera.position.y - this.controls.target.y; // Recalculate azimuthal angle (rotation around Z axis) // Only update this.currentTheta if we are not at the overhead singularity (flat 2D view) let azimuth = this.currentTheta || 0; if (!forceUseCurrentTheta && t >= 0.01 && Math.sqrt(dx * dx + dy * dy) > 0.1) { azimuth = Math.atan2(dx, dy); this.currentTheta = azimuth; this.targetTheta = azimuth; this.targetPan.copy(this.controls.target); this.targetCamRadius = this.camera.position.distanceTo(this.controls.target); } if (t < 0.01) { // 2D Zenith View: lock controls, set camera overhead, up vector is rotated Y-back this.controls.enabled = false; this.camera.position.set(this.panOffset.x, this.panOffset.y, this.camRadius); this.camera.up.set(Math.sin(azimuth), -Math.cos(azimuth), 0).normalize(); this.controls.target.set(this.panOffset.x, this.panOffset.y, 0); } else { // 3D Perspective View: enable controls, up vector is strictly Z-up this.controls.enabled = true; const phi = t * this.theta3D; const x = this.camRadius * Math.sin(phi) * Math.sin(azimuth); const y = this.camRadius * Math.sin(phi) * Math.cos(azimuth); const z = this.camRadius * Math.cos(phi); const tx = this.panOffset.x, ty = this.panOffset.y, tz = 1.0 * t; this.camera.position.set(tx + x, ty + y, tz + z); this.camera.up.set(0, 0, 1); this.controls.target.set(tx, ty, tz); // Update OrbitControls limits this.controls.enableRotate = true; this.controls.maxPolarAngle = Math.PI * 0.5; this.controls.minPolarAngle = 0.01; } } _updateCardsPerspective(t) { this.blocks.forEach((b) => { if (b.isDivider) return; // Rotate cards around X: flat (PI/2) at 2D overview, to upright (0) at 3D perspective b.mesh.rotation.x = (1 - t) * (Math.PI / 2); // Height position in vertical Z: flat (BLOCK_T / 2 + 0.01) to upright (BLOCK_H / 2) with active spring offsets added const baseZ = (1 - t) * (BLOCK_T / 2 + 0.01) + t * (BLOCK_H / 2); b.mesh.position.z = baseZ + b.hoverOffset + b.playOffset; }); } _updateMorph(forceUseCurrentTheta = false) { this._updateCameraPerspective(this.perspective, forceUseCurrentTheta); this._updateCardsPerspective(this.perspective); this.ribbons.forEach((rb) => this._updateRibbonGeometry(rb, this.perspective)); } transitionTo(target) { this.targetPerspective = Math.max(0, Math.min(1, target)); } setPerspective(val, triggerCallback = true) { this.perspective = Math.max(0, Math.min(1, val)); this.targetPerspective = this.perspective; this._updateMorph(); if (triggerCallback && this.hooks.onPerspectiveChange) { this.hooks.onPerspectiveChange(this.perspective); } } _getRadii() { const depth = (this.data ? this.data.layers.length : 4) * LAYER_GAP; // Allow zooming in much closer (minRadius smaller) and pulling out further (maxRadius larger) const minRadius = Math.max(2.0, depth * 0.08 + 1.0); const maxRadius = Math.max(38.0, depth * 0.68 + 16.0); return { minRadius, maxRadius }; } getZoom() { if (this.camRadius === null) return 0.5; const { minRadius, maxRadius } = this._getRadii(); const val = (maxRadius - this.camRadius) / (maxRadius - minRadius); return Math.max(0, Math.min(1, val)); } setZoom(t) { const { minRadius, maxRadius } = this._getRadii(); const radius = maxRadius - t * (maxRadius - minRadius); this.targetCamRadius = Math.max(minRadius, Math.min(maxRadius, radius)); } zoomIn() { this.zoomStep(0.08); } zoomOut() { this.zoomStep(-0.08); } zoomStep(delta) { const { minRadius, maxRadius } = this._getRadii(); const step = delta * (maxRadius - minRadius); this.targetCamRadius = Math.max(minRadius, Math.min(maxRadius, (this.targetCamRadius || this.camRadius) - step)); } setControlsEnabled(enabled) { this.controlsEnabled = enabled; if (this.controls) { this.controls.enabled = enabled && (this.perspective >= 0.01); } } rotateLeft() { this.targetTheta = (this.targetTheta || 0) + 0.3; } rotateRight() { this.targetTheta = (this.targetTheta || 0) - 0.3; } moveForward() { this._addPanImpulse(0.25); } moveBackward() { this._addPanImpulse(-0.25); } // Add velocity along the horizontal view direction. Clicks accumulate; the // friction in _animate eases motion out smoothly (no abrupt stop). Capped so // rapid clicking reaches a steady glide speed rather than flinging away. _addPanImpulse(step) { const dir = new THREE.Vector3(); this.camera.getWorldDirection(dir); dir.z = 0; // horizontal plane only if (dir.lengthSq() < 1e-4) { dir.set(0, 1, 0); } else { dir.normalize(); } this.panVel.addScaledVector(dir, step); const MAX = 0.5; // units/frame steady-state speed cap if (this.panVel.length() > MAX) this.panVel.setLength(MAX); } // ---- interaction _onMove(e) { if (!this.controlsEnabled) return; const r = this.renderer.domElement.getBoundingClientRect(); this.pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1; this.pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1; this.raycaster.setFromCamera(this.pointer, this.camera); // Filter out dividers for raycasting const activeBlocks = this.blocks.filter(b => !b.isDivider); const hit = this.raycaster.intersectObjects(activeBlocks.map((b) => b.mesh), false)[0]; const hitBlock = hit ? activeBlocks.find((b) => b.mesh === hit.object) : null; let cursor = "default"; if (hitBlock) { if (hitBlock.isSoundBar || hitBlock.isFocusButton) { cursor = "pointer"; } else if (hitBlock.unit != null) { cursor = "pointer"; } } const unit = (hitBlock && !hitBlock.isSoundBar && !hitBlock.isFocusButton) ? hitBlock.unit : null; if (unit !== this._hovered || hitBlock !== this._hoveredBlock) { this._hovered = unit ?? null; this._hoveredBlock = hitBlock; this.hooks.onHover(this._hovered); // Word-chain levitation — phrase blocks only; speakers never lift on hover. // The directly-hovered block does NOT lift (avoids cursor-leave → jitter // feedback loop). It gets an emissive glow instead. Chain siblings on // other layers still levitate to show the trace. this.blocks.forEach((b) => { if (b.isDivider || b.isSoundBar || b.isFocusButton) { b.targetHoverOffset = 0.0; return; } if (hitBlock && b === hitBlock) { // Stay in place — highlight via emissive boost (applied below) b.targetHoverOffset = 0.0; } else if (unit != null && b.unit === unit) { b.targetHoverOffset = 0.35; } else { b.targetHoverOffset = 0.0; } // Emissive glow: bright on the direct hit, medium on chain siblings, restore otherwise const baseEmissive = b.baseEmissiveIntensity ?? b.mesh.material.emissive?.clone(); if (!b.baseEmissiveIntensity && b.mesh.material.emissive) { b.baseEmissiveIntensity = b.mesh.material.emissiveIntensity || 0.08; } if (hitBlock && b === hitBlock) { b.targetEmissive = 0.55; } else if (unit != null && b.unit === unit) { b.targetEmissive = 0.3; } else { b.targetEmissive = b.baseEmissiveIntensity ?? 0.08; } }); } this.renderer.domElement.style.cursor = cursor; } _onClick(e) { if (!this.controlsEnabled) return; const r = this.renderer.domElement.getBoundingClientRect(); this.pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1; this.pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1; this.raycaster.setFromCamera(this.pointer, this.camera); const activeBlocks = this.blocks.filter(b => !b.isDivider); const hit = this.raycaster.intersectObjects(activeBlocks.map((b) => b.mesh), false)[0]; if (hit) { const blk = activeBlocks.find((b) => b.mesh === hit.object); if (blk) { if (blk.isFocusButton) { this.focusLayer(blk.layer); } else { this.hooks.onPlay(blk.layer); } } } } setHover(unit) { this.blocks.forEach((b) => { if (b.isDivider || b.isSoundBar || b.isFocusButton) return; // unaffected by hover const on = unit == null || b.unit === unit; if (b.mesh.material) b.mesh.material.opacity = on ? b.baseOp : 0.45; if (b.label) b.label.material.opacity = on ? 1 : 0.5; }); this.ribbons.forEach((rb) => { const u = rb.lk ? rb.lk.unit : undefined; const on = unit == null || u === unit; rb.mesh.material.opacity = on ? (unit != null && u === unit ? 0.95 : rb.mesh.userData.baseOp) : 0.2; }); } highlightLayer(idx) { this.activeLayerIdx = idx; if (idx >= 0) { this.focusLayer(idx); } this.blocks.forEach((b) => { if (b.isDivider) return; const on = idx < 0 || b.layer === idx; b.mesh.material.emissiveIntensity = on ? 1.0 : 0.22; b.mesh.scale.setScalar(idx >= 0 && b.layer === idx ? 1.06 : 1.0); // dip the cards in Z vertical axis to trigger a physical spring release bounce if (idx >= 0 && b.layer === idx) { b.playOffset = -0.45; b.playVelocity = 0.08; } }); } focusLayer(idx) { if (!this.data) return; const n = this.data.layers.length; if (idx < 0 || idx >= n) return; this.activeFocusLayerIdx = idx; // Smoothly pan camera target to the focused layer's depth (Y) const y = (idx - (n - 1) / 2) * LAYER_GAP; this.targetPanOffset.y = y; // Zoom in closer const { minRadius } = this._getRadii(); this.targetCamRadius = minRadius + 11.0; // Zoom in nicely to frame the focused row, keeping it slightly zoomed out per user preference } _resize() { const w = this.c.clientWidth, h = this.c.clientHeight; if (!w || !h) return; this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.renderer.setSize(w, h); } _animate() { requestAnimationFrame(() => this._animate()); // 1. Spotlights dynamic shimmer reflections (Y and Z swapped) if (this.p1 && this.p2) { const time = Date.now() * 0.001; this.p1.position.x = -22 + Math.cos(time * 0.4) * 4; this.p1.position.y = -18 + Math.sin(time * 0.4) * 4; this.p2.position.x = 22 + Math.sin(time * 0.4) * 4; this.p2.position.y = 18 + Math.cos(time * 0.4) * 4; } // 2. Easing transition factor let morphChanged = false; if (Math.abs(this.perspective - this.targetPerspective) > 0.0001) { this.perspective += (this.targetPerspective - this.perspective) * this.transitionSpeed; if (Math.abs(this.perspective - this.targetPerspective) < 0.0001) { this.perspective = this.targetPerspective; } morphChanged = true; if (this.hooks.onPerspectiveChange) { this.hooks.onPerspectiveChange(this.perspective); } } // Smooth rotation interpolation (inertia) if (this.targetTheta !== undefined && this.currentTheta !== undefined) { if (Math.abs(this.targetTheta - this.currentTheta) > 0.001) { this.currentTheta += (this.targetTheta - this.currentTheta) * 0.1; this._updateCameraPerspective(this.perspective, true); } else { this.currentTheta = this.targetTheta; } } // Smooth zoom interpolation (inertia) if (this.targetCamRadius !== null && this.camRadius !== null) { if (Math.abs(this.targetCamRadius - this.camRadius) > 0.01) { this.camRadius += (this.targetCamRadius - this.camRadius) * 0.12; this._updateCameraPerspective(this.perspective, true); if (this.hooks.onZoomChange) { this.hooks.onZoomChange(this.getZoom()); } } else { this.camRadius = this.targetCamRadius; if (this.hooks.onZoomChange) { this.hooks.onZoomChange(this.getZoom()); } } } // Smooth panOffset interpolation if (this.panOffset.distanceTo(this.targetPanOffset) > 0.001) { this.panOffset.lerp(this.targetPanOffset, 0.08); this._updateCameraPerspective(this.perspective, true); } // Pan momentum: friction decays the velocity so motion eases out smoothly // (no abrupt end), while repeated clicks accumulate into continuous glide. if (this.panVel.lengthSq() > 1e-5) { this.panOffset.addScaledVector(this.panVel, 1); this.targetPanOffset.copy(this.panOffset); this.panVel.multiplyScalar(0.90); // friction if (this.panVel.lengthSq() < 1e-5) this.panVel.set(0, 0, 0); this._updateCameraPerspective(this.perspective, true); } // 3. Update active spring physics (levitation and playback click-bounce in Z) const kSpring = 0.16; const dSpring = 0.80; const time = Date.now() * 0.001; this.blocks.forEach((b) => { if (b.isDivider) return; // Hover spring (smoothing) b.hoverOffset += (b.targetHoverOffset - b.hoverOffset) * 0.14; // Emissive glow spring (smoothing) — drives the hover highlight if (b.targetEmissive !== undefined && b.mesh.material.emissiveIntensity !== undefined) { b.mesh.material.emissiveIntensity += (b.targetEmissive - b.mesh.material.emissiveIntensity) * 0.15; } // Play dip spring (damped harmonic oscillation) const force = -kSpring * b.playOffset; b.playVelocity = (b.playVelocity + force) * dSpring; b.playOffset += b.playVelocity; // Speaker comes alive only while its layer is PLAYING (no hover effect): // the woofer cone pumps and the driver + LED glow. if (b.isSoundBar) { const isActive = this.activeLayerIdx === b.layer; if (b.driver) { const pump = isActive ? 1 + 0.22 * Math.abs(Math.sin(time * 9)) : 1; b.driver.scale.set(1, pump, 1); const g = isActive ? 0.6 : 0.15; b.driver.material.emissiveIntensity += (g - b.driver.material.emissiveIntensity) * 0.2; } if (b.tweeter) { const g = isActive ? 0.6 : 0.2; b.tweeter.material.emissiveIntensity += (g - b.tweeter.material.emissiveIntensity) * 0.2; } if (b.led) { const g = isActive ? 1.8 : 0.4; b.led.material.emissiveIntensity += (g - b.led.material.emissiveIntensity) * 0.2; } } // Focus button glowing reticle dot animation if (b.isFocusButton && b.dot) { const isFocused = this.activeFocusLayerIdx === b.layer; const targetGlow = isFocused ? 1.8 : 0.2; b.dot.material.emissiveIntensity += (targetGlow - b.dot.material.emissiveIntensity) * 0.15; } }); // 4. Always update positions/geometries to render animations in real-time if (this.data) { this._updateCardsPerspective(this.perspective); this.ribbons.forEach((rb) => this._updateRibbonGeometry(rb, this.perspective)); } // 5. Update camera matrix on transition morph if (morphChanged) { this._updateCameraPerspective(this.perspective); } if (this.controls.enabled) { this.controls.update(); } this.renderer.render(this.scene, this.camera); } }