I_Ching / js /main.js
AK51's picture
Upload 6 files
d841cb5 verified
Raw
History Blame Contribute Delete
17 kB
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) => `
<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));
}
/* ============================================================
Details modal — classical Judgment, Image and guidance
============================================================ */
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();
}
/* ============================================================
Search
============================================================ */
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();
}
/* ============================================================
Pick one (random)
============================================================ */
function pickOne() {
// clear any active search so the pick stands out
els.search.value = "";
matchSet = null;
const entry = cards[Math.floor(Math.random() * cards.length)];
// rotate the ring so the chosen card faces the current camera azimuth
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);
}
// return the value of `target` (mod 2π) closest to `current`
function nearestAngle(target, current) {
let d = target - current;
d = ((d % (Math.PI * 2)) + Math.PI * 3) % (Math.PI * 2) - Math.PI;
return current + d;
}
/* ============================================================
Language toggle
============================================================ */
function toggleLang() {
const cur = document.body.getAttribute("data-lang");
document.body.setAttribute("data-lang", cur === "zh" ? "en" : "zh");
}
/* ============================================================
Loop & resize
============================================================ */
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);
}
/* ============================================================
Boot
============================================================ */
async function boot() {
document.body.setAttribute("data-lang", "zh");
// make sure web fonts are ready before rasterising cards
if (document.fonts && document.fonts.ready) {
try { await document.fonts.ready; } catch (e) { /* ignore */ }
}
init();
// wire UI
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();
});
// fade out loader
els.loader.classList.add("gone");
setTimeout(() => els.loader.remove(), 900);
// auto-hide the hint
hintTimer = setTimeout(hideHint, 7000);
}
boot();