| 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), |
| new THREE.Color(0x3b82f6), |
| new THREE.Color(0x1fe0d0), |
| new THREE.Color(0xf59e0b), |
| new THREE.Color(0x22c55e), |
| new THREE.Color(0xf43f5e), |
| new THREE.Color(0x6366f1), |
| new THREE.Color(0xf97316), |
| ]; |
|
|
| |
| const LAYER_GAP = 7.0; |
| const BLOCK_H = 2.0; |
| const BLOCK_T = 0.55; |
| const WORLD_W = 26; |
| const SOUNDBAR_X = -15.0; |
| const FOCUS_X = 15.0; |
| const SOUNDBAR_W = 2.2; |
|
|
| export class CardsView { |
| constructor(container, hooks) { |
| this.c = container; |
| this.hooks = hooks; |
| this.data = null; |
| this.blocks = []; |
| this.ribbons = []; |
| |
| |
| 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); |
| this.targetPanOffset = new THREE.Vector3(0, 0, 0); |
| this.panVel = new THREE.Vector3(0, 0, 0); |
| 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); |
| |
| |
| 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); |
| 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; |
| |
| |
| |
| |
| this.controls.enableZoom = false; |
|
|
| |
| this.scene.add(new THREE.AmbientLight(0xffffff, 1.15)); |
| this.scene.add(new THREE.HemisphereLight(0xffffff, 0xd7dcea, 0.9)); |
|
|
| |
| 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); |
|
|
| |
| 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() { |
| |
| 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; |
| |
| |
| for (let y = 0; y < 512; y++) { |
| for (let x = 0; x < 512; x++) { |
| |
| const scaleX = x * 0.055; |
| const scaleY = y * 0.009; |
| |
| |
| 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; |
| |
| |
| const fiberValue = (Math.sin(x * 1.6) * Math.cos(y * 0.22)) * 0.12; |
| const val = Math.max(0, Math.min(1, ringValue + fiberValue)); |
| |
| |
| |
| |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| |
| |
| 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(); |
| } |
| |
| |
| 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); |
|
|
| |
| 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); |
| |
| |
| 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(); |
| } |
| |
| |
| 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() { |
| |
| 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; |
| desk.receiveShadow = true; |
| this.scene.add(desk); |
|
|
| |
| const grid = new THREE.GridHelper(180, 45, 0x7d93c4, 0xb9a98c); |
| grid.rotation.x = Math.PI / 2; |
| grid.position.z = 0.001; |
| grid.material.opacity = 0.06; |
| grid.material.transparent = true; |
| this.scene.add(grid); |
|
|
| |
| 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 = {}; |
|
|
| |
| 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; |
|
|
| |
| const layerMix = n > 1 ? li / (n - 1) : 0.0; |
| const layerColor = col(layerMix); |
| const frontY = (BLOCK_T * 0.9) / 2; |
| const noRay = (m) => { m.raycast = () => {}; return m; }; |
|
|
| |
| 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 }); |
|
|
| |
| 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); |
| 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); |
|
|
| |
| 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); |
|
|
| |
| 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, |
| }); |
|
|
| |
| 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 }) |
| )); |
|
|
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| 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; |
| |
| |
| |
| 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]; |
| |
| |
| 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), |
| }); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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, |
| |
| |
| hoverOffset: 0.0, |
| targetHoverOffset: 0.0, |
| playOffset: 0.0, |
| playVelocity: 0.0 |
| }; |
| this.blocks.push(rec); |
| byId[ch.id] = rec; |
| x -= w + gap; |
| }); |
| }); |
|
|
| |
| 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(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; |
| idx.push(a0 + 0, a0 + 1, b0 + 0, a0 + 1, b0 + 1, b0 + 0); |
| idx.push(a0 + 2, b0 + 2, a0 + 3, a0 + 3, b0 + 2, b0 + 3); |
| idx.push(a0 + 0, b0 + 0, a0 + 2, a0 + 2, b0 + 0, b0 + 2); |
| idx.push(a0 + 1, a0 + 3, b0 + 1, a0 + 3, b0 + 3, b0 + 1); |
| } |
|
|
| 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); |
| const p0 = new THREE.Vector3(); |
| const p3 = new THREE.Vector3(); |
|
|
| |
| |
| 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; |
| 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; |
| const halfT = 0.08; |
| 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); |
| |
| verts[v++] = pt.x + side.x + nrm.x; verts[v++] = pt.y + side.y + nrm.y; verts[v++] = pt.z + side.z + nrm.z; |
| |
| verts[v++] = pt.x - side.x + nrm.x; verts[v++] = pt.y - side.y + nrm.y; verts[v++] = pt.z - side.z + nrm.z; |
| |
| verts[v++] = pt.x + side.x - nrm.x; verts[v++] = pt.y + side.y - nrm.y; verts[v++] = pt.z + side.z - nrm.z; |
| |
| 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; |
| |
| |
| this.camRadius = Math.max(18.5, depth * 0.55 + 13.5); |
| this.targetCamRadius = this.camRadius; |
| this.theta3D = 1.05; |
| this.currentTheta = 0; |
| this.targetTheta = 0; |
| this.panOffset.set(0, 0, 0); |
| 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; |
| |
| |
| const dx = this.camera.position.x - this.controls.target.x; |
| const dy = this.camera.position.y - this.controls.target.y; |
| |
| |
| |
| 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) { |
| |
| 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 { |
| |
| 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); |
| |
| |
| 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; |
| |
| |
| b.mesh.rotation.x = (1 - t) * (Math.PI / 2); |
| |
| |
| 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; |
| |
| 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); |
| } |
|
|
| |
| |
| |
| _addPanImpulse(step) { |
| const dir = new THREE.Vector3(); |
| this.camera.getWorldDirection(dir); |
| dir.z = 0; |
| if (dir.lengthSq() < 1e-4) { |
| dir.set(0, 1, 0); |
| } else { |
| dir.normalize(); |
| } |
| this.panVel.addScaledVector(dir, step); |
| const MAX = 0.5; |
| if (this.panVel.length() > MAX) this.panVel.setLength(MAX); |
| } |
|
|
| |
| _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); |
| |
| |
| 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); |
| |
| |
| |
| |
| |
| this.blocks.forEach((b) => { |
| if (b.isDivider || b.isSoundBar || b.isFocusButton) { b.targetHoverOffset = 0.0; return; } |
| if (hitBlock && b === hitBlock) { |
| |
| b.targetHoverOffset = 0.0; |
| } else if (unit != null && b.unit === unit) { |
| b.targetHoverOffset = 0.35; |
| } else { |
| b.targetHoverOffset = 0.0; |
| } |
| |
| 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; |
| 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); |
| |
| |
| 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; |
|
|
| |
| const y = (idx - (n - 1) / 2) * LAYER_GAP; |
| this.targetPanOffset.y = y; |
|
|
| |
| const { minRadius } = this._getRadii(); |
| this.targetCamRadius = minRadius + 11.0; |
| } |
|
|
| _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()); |
| |
| |
| 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; |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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; |
| } |
| } |
| |
| |
| 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()); |
| } |
| } |
| } |
| |
| |
| if (this.panOffset.distanceTo(this.targetPanOffset) > 0.001) { |
| this.panOffset.lerp(this.targetPanOffset, 0.08); |
| this._updateCameraPerspective(this.perspective, true); |
| } |
| |
| |
| |
| if (this.panVel.lengthSq() > 1e-5) { |
| this.panOffset.addScaledVector(this.panVel, 1); |
| this.targetPanOffset.copy(this.panOffset); |
| this.panVel.multiplyScalar(0.90); |
| if (this.panVel.lengthSq() < 1e-5) this.panVel.set(0, 0, 0); |
| this._updateCameraPerspective(this.perspective, true); |
| } |
| |
| |
| const kSpring = 0.16; |
| const dSpring = 0.80; |
| const time = Date.now() * 0.001; |
| this.blocks.forEach((b) => { |
| if (b.isDivider) return; |
| |
| |
| b.hoverOffset += (b.targetHoverOffset - b.hoverOffset) * 0.14; |
| |
| |
| if (b.targetEmissive !== undefined && b.mesh.material.emissiveIntensity !== undefined) { |
| b.mesh.material.emissiveIntensity += (b.targetEmissive - b.mesh.material.emissiveIntensity) * 0.15; |
| } |
| |
| |
| const force = -kSpring * b.playOffset; |
| b.playVelocity = (b.playVelocity + force) * dSpring; |
| b.playOffset += b.playVelocity; |
|
|
| |
| |
| 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; |
| } |
| } |
|
|
| |
| 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; |
| } |
| }); |
|
|
| |
| if (this.data) { |
| this._updateCardsPerspective(this.perspective); |
| this.ribbons.forEach((rb) => this._updateRibbonGeometry(rb, this.perspective)); |
| } |
| |
| |
| if (morphChanged) { |
| this._updateCameraPerspective(this.perspective); |
| } |
| |
| if (this.controls.enabled) { |
| this.controls.update(); |
| } |
| this.renderer.render(this.scene, this.camera); |
| } |
| } |
|
|