import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { HEXAGRAMS, TRIGRAMS } from "./hexagrams.js"; import { DETAILS } from "./details.js"; /* ============================================================ State ============================================================ */ let scene, camera, renderer, controls, raycaster, pointer; let group; // holds every hexagram card const cards = []; // { mesh, hex, angle, targetScale } let hovered = null; let selected = null; let matchSet = null; // Set of hexagram numbers matching search, or null let hintTimer; const els = { container: document.getElementById("scene-container"), loader: document.getElementById("loader"), panel: document.getElementById("panel"), panelContent: document.getElementById("panel-content"), panelClose: document.getElementById("panel-close"), modal: document.getElementById("modal"), modalContent: document.getElementById("modal-content"), modalClose: document.getElementById("modal-close"), langToggle: document.getElementById("lang-toggle"), pickOne: document.getElementById("pick-one"), search: document.getElementById("search"), hint: document.getElementById("hint") }; let targetRotY = null; // when set, the ring eases toward this Y rotation /* ============================================================ Card texture — draw a hexagram onto a canvas ============================================================ */ function drawCard(hex, highlight = false) { const W = 260, H = 348; const c = document.createElement("canvas"); c.width = W; c.height = H; const ctx = c.getContext("2d"); // rounded card background roundRect(ctx, 8, 8, W - 16, H - 16, 20); ctx.fillStyle = "rgba(10,12,26,0.92)"; ctx.fill(); ctx.lineWidth = highlight ? 5 : 3; ctx.strokeStyle = highlight ? "#f7e2a6" : "rgba(233,196,106,0.55)"; ctx.stroke(); // King Wen number ctx.fillStyle = "#e9c46a"; ctx.textAlign = "center"; ctx.font = "600 30px 'Noto Serif SC', serif"; ctx.fillText(String(hex.n).padStart(2, "0"), W / 2, 48); // six lines, bottom (index 0) at the bottom const step = 25, thick = 13, baseBottom = 198; const cx = W / 2, fullHalf = 78, gap = 15; for (let i = 0; i < 6; i++) { const y = baseBottom - i * step; ctx.fillStyle = "#f4d58d"; const isYang = hex.lines[i] === "1"; if (isYang) { bar(ctx, cx - fullHalf, y - thick / 2, fullHalf * 2, thick); } else { const segW = fullHalf - gap / 2; bar(ctx, cx - fullHalf, y - thick / 2, segW, thick); bar(ctx, cx + gap / 2, y - thick / 2, segW, thick); } } // Chinese name ctx.fillStyle = "#f4d58d"; ctx.font = "600 46px 'Noto Serif SC', serif"; ctx.fillText(hex.cn, W / 2, 270); // pinyin ctx.fillStyle = "#b9b3a3"; ctx.font = "italic 20px 'Noto Serif SC', serif"; ctx.fillText(hex.py, W / 2, 302); return c; } function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function bar(ctx, x, y, w, h) { roundRect(ctx, x, y, w, h, h / 2); ctx.fill(); } /* ============================================================ Build the 3D scene ============================================================ */ function init() { scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera( 52, window.innerWidth / window.innerHeight, 0.1, 200 ); camera.position.set(0, 3, 17); renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; els.container.appendChild(renderer.domElement); controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.enablePan = false; controls.minDistance = 8; controls.maxDistance = 30; controls.autoRotate = true; controls.autoRotateSpeed = 0.55; controls.target.set(0, 0, 0); controls.minPolarAngle = Math.PI * 0.18; controls.maxPolarAngle = Math.PI * 0.82; raycaster = new THREE.Raycaster(); pointer = new THREE.Vector2(); addStars(); addGlow(); buildCards(); window.addEventListener("resize", onResize); renderer.domElement.addEventListener("pointermove", onPointerMove); renderer.domElement.addEventListener("pointerdown", onPointerDown); animate(); } /* Cylinder wall: 4 rings of 16 hexagrams */ function buildCards() { group = new THREE.Group(); scene.add(group); const R = 6.6; const perRing = 16; const rows = 4; const rowGap = 2.72; const yTop = ((rows - 1) * rowGap) / 2; HEXAGRAMS.forEach((hex, i) => { const row = Math.floor(i / perRing); const col = i % perRing; const angle = (col / perRing) * Math.PI * 2; const x = Math.sin(angle) * R; const z = Math.cos(angle) * R; const y = yTop - row * rowGap; const tex = new THREE.CanvasTexture(drawCard(hex)); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = renderer.capabilities.getMaxAnisotropy(); const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, color: 0xd7d1bf, side: THREE.DoubleSide }); const geo = new THREE.PlaneGeometry(1.72, 2.3); const mesh = new THREE.Mesh(geo, mat); mesh.position.set(x, y, z); mesh.lookAt(x * 2, y, z * 2); // face outward, away from center const entry = { mesh, hex, angle, targetScale: 1, baseTex: tex, hiTex: null }; mesh.userData = entry; cards.push(entry); group.add(mesh); }); } function addStars() { const count = 1400; const positions = new Float32Array(count * 3); for (let i = 0; i < count; i++) { const r = 40 + Math.random() * 55; const th = Math.random() * Math.PI * 2; const ph = Math.acos(2 * Math.random() - 1); positions[i * 3] = r * Math.sin(ph) * Math.cos(th); positions[i * 3 + 1] = r * Math.cos(ph); positions[i * 3 + 2] = r * Math.sin(ph) * Math.sin(th); } const geo = new THREE.BufferGeometry(); geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); const mat = new THREE.PointsMaterial({ color: 0xe9c46a, size: 0.35, transparent: true, opacity: 0.55, sizeAttenuation: true }); scene.add(new THREE.Points(geo, mat)); } /* soft glowing core in the centre of the ring */ function addGlow() { const canvas = document.createElement("canvas"); canvas.width = canvas.height = 256; const ctx = canvas.getContext("2d"); const g = ctx.createRadialGradient(128, 128, 0, 128, 128, 128); g.addColorStop(0, "rgba(244,213,141,0.55)"); g.addColorStop(0.4, "rgba(233,196,106,0.18)"); g.addColorStop(1, "rgba(233,196,106,0)"); ctx.fillStyle = g; ctx.fillRect(0, 0, 256, 256); const tex = new THREE.CanvasTexture(canvas); tex.colorSpace = THREE.SRGBColorSpace; const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending })); sprite.scale.set(9, 9, 1); scene.add(sprite); } /* ============================================================ Interaction ============================================================ */ function setPointer(e) { pointer.x = (e.clientX / window.innerWidth) * 2 - 1; pointer.y = -(e.clientY / window.innerHeight) * 2 + 1; } function pick() { raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(group.children, false); return hits.length ? hits[0].object.userData : null; } function onPointerMove(e) { setPointer(e); const entry = pick(); if (entry !== hovered) { hovered = entry; renderer.domElement.style.cursor = entry ? "pointer" : "grab"; refreshStates(); } } let downPos = null; function onPointerDown(e) { downPos = { x: e.clientX, y: e.clientY }; const startEntry = pick(); const up = (ev) => { window.removeEventListener("pointerup", up); if (!downPos) return; const moved = Math.hypot(ev.clientX - downPos.x, ev.clientY - downPos.y); downPos = null; if (moved < 6 && startEntry) selectHex(startEntry); }; window.addEventListener("pointerup", up); } function selectHex(entry) { selected = entry; controls.autoRotate = false; hideHint(); openPanel(entry.hex); refreshStates(); } /* apply scale/color based on hover, selection and search */ function refreshStates() { cards.forEach((c) => { const isMatch = !matchSet || matchSet.has(c.hex.n); let scale = 1, color = 0xd7d1bf; if (!isMatch) { scale = 0.9; color = 0x2f3040; } else { if (c === selected) { scale = 1.22; color = 0xffffff; } else if (c === hovered) { scale = 1.14; color = 0xffffff; } else if (matchSet) { scale = 1.05; color = 0xffffff; } } c.targetScale = scale; c.mesh.material.color.setHex(color); c.mesh.material.opacity = isMatch ? 1 : 0.35; }); } /* ============================================================ Detail panel (bilingual — both languages rendered, CSS toggles) ============================================================ */ function trigramCells(hex) { const lower = TRIGRAMS[hex.lines.slice(0, 3)]; const upper = TRIGRAMS[hex.lines.slice(3, 6)]; const cell = (t, roleCn, roleEn) => `
${hex.dc}
${hex.de}
${d.gua}
${d.guaEn}
${d.xiang}
${d.xiangEn}
${d.gc}
${d.ge}