| import * as THREE from "three";
|
| import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
| import { HEXAGRAMS, TRIGRAMS } from "./hexagrams.js";
|
| import { DETAILS } from "./details.js";
|
|
|
| |
| |
|
|
| let scene, camera, renderer, controls, raycaster, pointer;
|
| let group;
|
| const cards = [];
|
| let hovered = null;
|
| let selected = null;
|
| let matchSet = 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;
|
|
|
| |
| |
|
|
| 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");
|
|
|
|
|
| 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();
|
|
|
|
|
| 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);
|
|
|
|
|
| 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);
|
| }
|
| }
|
|
|
|
|
| ctx.fillStyle = "#f4d58d";
|
| ctx.font = "600 46px 'Noto Serif SC', serif";
|
| ctx.fillText(hex.cn, W / 2, 270);
|
|
|
|
|
| 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();
|
| }
|
|
|
| |
| |
|
|
| 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();
|
| }
|
|
|
|
|
| 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);
|
|
|
| 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));
|
| }
|
|
|
|
|
| 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);
|
| }
|
|
|
| |
| |
|
|
| 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();
|
| }
|
|
|
|
|
| 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;
|
| });
|
| }
|
|
|
| |
| |
|
|
| function trigramCells(hex) {
|
| const lower = TRIGRAMS[hex.lines.slice(0, 3)];
|
| const upper = TRIGRAMS[hex.lines.slice(3, 6)];
|
| const cell = (t, roleCn, roleEn) => `
|
| <div class="tg">
|
| <div class="tg-role"><span class="cn-only">${roleCn}</span><span class="en-only">${roleEn}</span></div>
|
| <div class="tg-name">${t.cn} · ${t.nature}</div>
|
| <div class="tg-en">${t.en}</div>
|
| </div>`;
|
| return `<div class="hx-trigrams">
|
| ${cell(upper, "上卦", "Upper")}
|
| ${cell(lower, "下卦", "Lower")}
|
| </div>`;
|
| }
|
|
|
| function glyphHtml(hex) {
|
| let rows = "";
|
| for (let i = 0; i < 6; i++) {
|
| const yang = hex.lines[i] === "1";
|
| rows += yang
|
| ? `<div class="hx-line yang"><span class="hx-seg"></span></div>`
|
| : `<div class="hx-line yin"><span class="hx-seg"></span><span class="hx-seg"></span></div>`;
|
| }
|
| return `<div class="hx-glyph">${rows}</div>`;
|
| }
|
|
|
| function openPanel(hex) {
|
| els.panelContent.innerHTML = `
|
| <div class="hx-head">
|
| <div class="hx-number">${String(hex.n).padStart(2, "0")}
|
| <small><span class="cn-only">第${hex.n}卦</span><span class="en-only">HEXAGRAM</span></small>
|
| </div>
|
| <div class="hx-names">
|
| <div class="hx-cn">${hex.cn}</div>
|
| <div class="hx-py">${hex.py}</div>
|
| <div class="hx-en">${hex.en}</div>
|
| </div>
|
| </div>
|
| ${glyphHtml(hex)}
|
| ${trigramCells(hex)}
|
| <button class="details-btn" id="details-btn">Details · 詳解</button>
|
| <div class="hx-desc">
|
| <div class="lang-block">
|
| <h4>卦辭釋義 · 中文</h4>
|
| <p lang="zh">${hex.dc}</p>
|
| </div>
|
| <div class="lang-block">
|
| <h4>Interpretation · English</h4>
|
| <p lang="en">${hex.de}</p>
|
| </div>
|
| </div>
|
| `;
|
| els.panel.classList.remove("hidden");
|
| els.panel.setAttribute("aria-hidden", "false");
|
|
|
| const btn = document.getElementById("details-btn");
|
| if (btn) btn.addEventListener("click", () => openDetails(hex));
|
| }
|
|
|
| |
| |
|
|
| function openDetails(hex) {
|
| const d = DETAILS[hex.n];
|
| if (!d) return;
|
| els.modalContent.innerHTML = `
|
| <div class="md-head">
|
| <div class="md-num">${String(hex.n).padStart(2, "0")}</div>
|
| <div>
|
| <div class="md-cn">${hex.cn}</div>
|
| <div class="md-py">${hex.py}</div>
|
| <div class="md-en">${hex.en}</div>
|
| </div>
|
| </div>
|
|
|
| <div class="md-section">
|
| <h3>卦辭 · The Judgment</h3>
|
| <p class="md-classic" lang="zh">${d.gua}</p>
|
| <p class="md-trans" lang="en">${d.guaEn}</p>
|
| </div>
|
|
|
| <div class="md-section">
|
| <h3>大象傳 · The Image</h3>
|
| <p class="md-classic" lang="zh">${d.xiang}</p>
|
| <p class="md-trans" lang="en">${d.xiangEn}</p>
|
| </div>
|
|
|
| <div class="md-section">
|
| <h3>啟示與應用 · Guidance</h3>
|
| <p class="md-cnpara" lang="zh">${d.gc}</p>
|
| <p class="md-enpara" lang="en">${d.ge}</p>
|
| </div>
|
| `;
|
| els.modal.classList.remove("hidden");
|
| els.modal.setAttribute("aria-hidden", "false");
|
| els.modalContent.scrollTop = 0;
|
| }
|
|
|
| function closeModal() {
|
| els.modal.classList.add("hidden");
|
| els.modal.setAttribute("aria-hidden", "true");
|
| }
|
|
|
| function closePanel() {
|
| els.panel.classList.add("hidden");
|
| els.panel.setAttribute("aria-hidden", "true");
|
| selected = null;
|
| controls.autoRotate = true;
|
| refreshStates();
|
| }
|
|
|
| |
| |
|
|
| function onSearch() {
|
| const q = els.search.value.trim().toLowerCase();
|
| if (!q) { matchSet = null; refreshStates(); return; }
|
| matchSet = new Set();
|
| HEXAGRAMS.forEach((h) => {
|
| if (
|
| String(h.n) === q ||
|
| h.cn.includes(q) ||
|
| h.py.toLowerCase().includes(q) ||
|
| h.en.toLowerCase().includes(q)
|
| ) matchSet.add(h.n);
|
| });
|
| refreshStates();
|
| }
|
|
|
| |
| |
|
|
| function pickOne() {
|
|
|
| els.search.value = "";
|
| matchSet = null;
|
|
|
| const entry = cards[Math.floor(Math.random() * cards.length)];
|
|
|
|
|
| const camAz = Math.atan2(
|
| camera.position.x - controls.target.x,
|
| camera.position.z - controls.target.z
|
| );
|
| targetRotY = nearestAngle(camAz - entry.angle, group.rotation.y);
|
|
|
| selectHex(entry);
|
| }
|
|
|
|
|
| function nearestAngle(target, current) {
|
| let d = target - current;
|
| d = ((d % (Math.PI * 2)) + Math.PI * 3) % (Math.PI * 2) - Math.PI;
|
| return current + d;
|
| }
|
|
|
| |
| |
|
|
| function toggleLang() {
|
| const cur = document.body.getAttribute("data-lang");
|
| document.body.setAttribute("data-lang", cur === "zh" ? "en" : "zh");
|
| }
|
|
|
| |
| |
|
|
| function animate() {
|
| requestAnimationFrame(animate);
|
| for (const c of cards) {
|
| const s = c.mesh.scale.x + (c.targetScale - c.mesh.scale.x) * 0.15;
|
| c.mesh.scale.set(s, s, s);
|
| }
|
| if (targetRotY !== null) {
|
| group.rotation.y += (targetRotY - group.rotation.y) * 0.1;
|
| if (Math.abs(targetRotY - group.rotation.y) < 0.001) {
|
| group.rotation.y = targetRotY;
|
| targetRotY = null;
|
| }
|
| }
|
| controls.update();
|
| renderer.render(scene, camera);
|
| }
|
|
|
| function onResize() {
|
| camera.aspect = window.innerWidth / window.innerHeight;
|
| camera.updateProjectionMatrix();
|
| renderer.setSize(window.innerWidth, window.innerHeight);
|
| }
|
|
|
| function hideHint() {
|
| els.hint.classList.add("hidden");
|
| clearTimeout(hintTimer);
|
| }
|
|
|
| |
| |
|
|
| async function boot() {
|
| document.body.setAttribute("data-lang", "zh");
|
|
|
|
|
| if (document.fonts && document.fonts.ready) {
|
| try { await document.fonts.ready; } catch (e) { }
|
| }
|
|
|
| init();
|
|
|
|
|
| els.panelClose.addEventListener("click", closePanel);
|
| els.modalClose.addEventListener("click", closeModal);
|
| els.modal.querySelector(".modal-backdrop").addEventListener("click", closeModal);
|
| els.langToggle.addEventListener("click", toggleLang);
|
| els.pickOne.addEventListener("click", pickOne);
|
| els.search.addEventListener("input", onSearch);
|
| document.addEventListener("keydown", (e) => {
|
| if (e.key !== "Escape") return;
|
| if (!els.modal.classList.contains("hidden")) closeModal();
|
| else closePanel();
|
| });
|
|
|
|
|
| els.loader.classList.add("gone");
|
| setTimeout(() => els.loader.remove(), 900);
|
|
|
|
|
| hintTimer = setTimeout(hideHint, 7000);
|
| }
|
|
|
| boot();
|
|
|