/* Cosmic Bagua compass — dark, glowing, with light-beam effects */
const COSMIC_DATA = {
trigrams: [
{ sym: "☰", name: "乾" }, { sym: "☱", name: "兑" },
{ sym: "☲", name: "离" }, { sym: "☳", name: "震" },
{ sym: "☴", name: "巽" }, { sym: "☵", name: "坎" },
{ sym: "☶", name: "艮" }, { sym: "☷", name: "坤" },
],
branches: ["子","丑","寅","卯","辰","巳","午","未","申","酉","戌","亥"],
solarTerms: [
"冬至","小寒","大寒","立春","雨水","惊蛰",
"春分","清明","谷雨","立夏","小满","芒种",
"夏至","小暑","大暑","立秋","处暑","白露",
"秋分","寒露","霜降","立冬","小雪","大雪"
],
mountains: [
"壬","子","癸","丑","艮","寅","甲","卯","乙","辰","巽","巳",
"丙","午","丁","未","坤","申","庚","酉","辛","戌","乾","亥"
],
hexagrams: [
"乾","坤","屯","蒙","需","讼","师","比",
"小畜","履","泰","否","同人","大有","谦","豫",
"随","蛊","临","观","噬嗑","贲","剥","复",
"无妄","大畜","颐","大过","坎","离","咸","恒",
"遁","大壮","晋","明夷","家人","睽","蹇","解",
"损","益","夬","姤","萃","升","困","井",
"革","鼎","震","艮","渐","归妹","丰","旅",
"巽","兑","涣","节","中孚","小过","既济","未济"
],
starSigils: [
"角","亢","氐","房","心","尾","箕",
"斗","牛","女","虚","危","室","壁",
"奎","娄","胃","昴","毕","觜","参",
"井","鬼","柳","星","张","翼","轸"
],
};
/* Cosmic Ring */
function CosmicRing({
items, innerR, outerR, rotation, onRotate, fontSize,
glowColor, textColor, divider, fillEven, fillOdd,
zIndex = 1, renderItem, accentIndices = [],
}) {
const ref = React.useRef(null);
const drag = React.useRef(null);
const n = items.length;
const step = 360 / n;
const cx = outerR, cy = outerR;
const onPointerDown = (e) => {
const rect = ref.current.getBoundingClientRect();
const ox = rect.left + rect.width / 2;
const oy = rect.top + rect.height / 2;
const a0 = Math.atan2(e.clientY - oy, e.clientX - ox) * 180 / Math.PI;
drag.current = { a0, r0: rotation, ox, oy };
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e) => {
if (!drag.current) return;
const { a0, r0 } = drag.current;
const a = Math.atan2(e.clientY - drag.current.oy, e.clientX - drag.current.ox) * 180 / Math.PI;
onRotate(r0 + (a - a0));
};
const onPointerUp = (e) => {
drag.current = null;
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
};
const sectors = [];
for (let i = 0; i < n; i++) {
const a1 = (i * step - 90) * Math.PI / 180;
const a2 = ((i + 1) * step - 90) * Math.PI / 180;
const x1o = cx + outerR * Math.cos(a1), y1o = cy + outerR * Math.sin(a1);
const x2o = cx + outerR * Math.cos(a2), y2o = cy + outerR * Math.sin(a2);
const x1i = cx + innerR * Math.cos(a1), y1i = cy + innerR * Math.sin(a1);
const x2i = cx + innerR * Math.cos(a2), y2i = cy + innerR * Math.sin(a2);
const large = step > 180 ? 1 : 0;
const d = `M ${x1o} ${y1o} A ${outerR} ${outerR} 0 ${large} 1 ${x2o} ${y2o} L ${x2i} ${y2i} A ${innerR} ${innerR} 0 ${large} 0 ${x1i} ${y1i} Z`;
sectors.push(
);
}
const midR = (innerR + outerR) / 2;
const labels = items.map((it, i) => {
const ang = i * step + step / 2 - 90;
const rad = ang * Math.PI / 180;
const x = cx + midR * Math.cos(rad);
const y = cy + midR * Math.sin(rad);
const rot = ang + 90;
const isAccent = accentIndices.includes(i);
const content = renderItem ? renderItem(it, i, isAccent) : it;
return (
{typeof content === "string" ? {content} : content}
);
});
return (
);
}
/* Glowing Taiji */
function CosmicTaiji({ size, rotation = 0, hue = "gold" }) {
const colors = hue === "cyan"
? { light: "#9ff5ff", dark: "#0a1a26", glow: "#5ed8ff" }
: { light: "#ffe9a8", dark: "#1a0e05", glow: "#ffce5c" };
return (
);
}
/* Vertical light beams rising and descending */
function LightBeams({ baseR, hue = "gold" }) {
const beams = React.useMemo(() => {
const arr = [];
const N = 24;
for (let i = 0; i < N; i++) {
const ang = (i * 360 / N) * Math.PI / 180;
const r = baseR + 20 + Math.random() * 30;
arr.push({
x: r * Math.cos(ang - Math.PI / 2),
y: r * Math.sin(ang - Math.PI / 2),
delay: Math.random() * 4,
dur: 2.5 + Math.random() * 2.5,
height: 80 + Math.random() * 220,
width: 1.5 + Math.random() * 2.5,
});
}
return arr;
}, [baseR]);
const beamColor = hue === "cyan" ? "#5ed8ff" : "#ffce5c";
return (
{beams.map((b, i) => (
))}
);
}
/* Constellation dots orbiting outside */
function OrbitingStars({ baseR, hue = "gold" }) {
const dots = React.useMemo(() => {
return Array.from({ length: 60 }, () => ({
ang: Math.random() * 360,
r: baseR + 60 + Math.random() * 200,
size: 1 + Math.random() * 2.5,
blink: Math.random() * 4,
blinkDur: 2 + Math.random() * 4,
}));
}, [baseR]);
const c = hue === "cyan" ? "#9ff5ff" : "#ffe9a8";
return (
{dots.map((d, i) => {
const rad = d.ang * Math.PI / 180;
return (
);
})}
);
}
/* ── Gear Sound (Web Audio API) ── */
function playGearSound(totalMs) {
try {
const actx = new (window.AudioContext || window.webkitAudioContext)();
const dur = totalMs / 1000;
const accelSec = 3;
const now = actx.currentTime;
// Pink noise buffer — mechanical rumble
const noiseFrames = Math.min(Math.ceil(actx.sampleRate * dur), actx.sampleRate * 12);
const noiseBuf = actx.createBuffer(1, noiseFrames, actx.sampleRate);
const nd = noiseBuf.getChannelData(0);
let b0=0,b1=0,b2=0,b3=0,b4=0,b5=0;
for (let i = 0; i < noiseFrames; i++) {
const w = Math.random()*2-1;
b0=0.99886*b0+w*0.0555179; b1=0.99332*b1+w*0.0750759;
b2=0.96900*b2+w*0.1538520; b3=0.86650*b3+w*0.3104856;
b4=0.55000*b4+w*0.5329522; b5=-0.7616*b5-w*0.0168980;
nd[i] = (b0+b1+b2+b3+b4+b5+w*0.5362)*0.05;
}
const noiseSrc = actx.createBufferSource();
noiseSrc.buffer = noiseBuf;
const rumbleF = actx.createBiquadFilter();
rumbleF.type = 'bandpass'; rumbleF.Q.value = 1.5;
rumbleF.frequency.setValueAtTime(80, now);
rumbleF.frequency.linearRampToValueAtTime(320, now + accelSec);
rumbleF.frequency.linearRampToValueAtTime(50, now + dur);
const rumbleG = actx.createGain();
rumbleG.gain.setValueAtTime(0, now);
rumbleG.gain.linearRampToValueAtTime(0.55, now + accelSec);
rumbleG.gain.linearRampToValueAtTime(0, now + dur);
noiseSrc.connect(rumbleF); rumbleF.connect(rumbleG); rumbleG.connect(actx.destination);
noiseSrc.start(now); noiseSrc.stop(now + dur);
// Metallic click buffer — gear teeth
const clickFrames = Math.floor(actx.sampleRate * 0.02);
const clickBuf = actx.createBuffer(1, clickFrames, actx.sampleRate);
const cd = clickBuf.getChannelData(0);
for (let i = 0; i < clickFrames; i++) {
cd[i] = (Math.random()*2-1) * Math.exp(-i/(actx.sampleRate*0.003)) * 1.5;
}
let t = now + 0.05;
const end = now + dur - 0.1;
while (t < end) {
const el = t - now;
let ivl;
if (el < accelSec) {
const k = Math.pow(el / accelSec, 1.8);
ivl = 0.30 - 0.26 * k; // 0.30s → 0.04s
} else {
const k = Math.pow((el - accelSec) / (dur - accelSec), 1.5);
ivl = 0.04 + 0.28 * k; // 0.04s → 0.32s
}
ivl = Math.max(0.02, ivl);
const env = el < accelSec ? el/accelSec : 1-(el-accelSec)/(dur-accelSec);
const src = actx.createBufferSource();
src.buffer = clickBuf;
const g = actx.createGain();
g.gain.value = 0.4 * Math.max(0.05, env);
src.connect(g); g.connect(actx.destination);
try { src.start(t); } catch {}
t += ivl;
}
setTimeout(() => { try { actx.close(); } catch {} }, (dur + 1) * 1000);
} catch(e) { /* audio unavailable */ }
}
/* The Cosmic Compass */
function CosmicCompass({ tweaks }) {
const baseR = 360;
const accentColors = tweaks.hue === "cyan"
? { glow: "#5ed8ff", text: "#cfeeff", divider: "rgba(94,216,255,0.55)", fillA: "rgba(20,60,90,0.55)", fillB: "rgba(8,28,48,0.65)" }
: { glow: "#ffce5c", text: "#f1dba0", divider: "rgba(255,206,92,0.5)", fillA: "rgba(80,40,8,0.55)", fillB: "rgba(40,20,4,0.65)" };
// 4 cardinal indices for mountains: 子 卯 午 酉 → indices 1, 7, 13, 19
const cardinalsMountains = [1, 7, 13, 19];
const ringSpec = [
{ key: "trigrams", inner: 80, outer: 115, items: COSMIC_DATA.trigrams.map(t => t.name), font: 18 },
{ key: "branches", inner: 115, outer: 150, items: COSMIC_DATA.branches, font: 20 },
{ key: "terms", inner: 150, outer: 190, items: COSMIC_DATA.solarTerms, font: 11,
renderItem: (s) => (
{s[0]}
{s[1]}
)
},
{ key: "mountains", inner: 190, outer: 232, items: COSMIC_DATA.mountains, font: 22, accentIndices: cardinalsMountains },
{ key: "hexagrams", inner: 232, outer: 295, items: COSMIC_DATA.hexagrams, font: 13,
renderItem: (s) => s.length === 1 ? {s} : (
{s[0]}
{s[1]}
)
},
{ key: "stars", inner: 295, outer: baseR, items: [...COSMIC_DATA.starSigils, ...COSMIC_DATA.starSigils.slice(0, 36)].slice(0, 36), font: 16 },
];
const [rotations, setRotations] = React.useState(
Object.fromEntries(ringSpec.map(r => [r.key, 0]))
);
const burstRef = React.useRef(null);
const stoppedRef = React.useRef(false); // compass-stop 事件设为 true,burst 时清除
// 用 ref 持有最新的 tweaks,避免 RAF 闭包捕获旧值
const autoSpinRef = React.useRef(tweaks.autoSpin);
const spinSpeedRef = React.useRef(tweaks.spinSpeed);
React.useEffect(() => { autoSpinRef.current = tweaks.autoSpin; }, [tweaks.autoSpin]);
React.useEffect(() => { spinSpeedRef.current = tweaks.spinSpeed; }, [tweaks.spinSpeed]);
React.useEffect(() => {
const onBurst = (e) => {
stoppedRef.current = false; // 新一轮起卦,清除停止标记
const dur = (e.detail && e.detail.duration) || 30000;
const maxM = (e.detail && e.detail.maxMult) || 80;
burstRef.current = { start: performance.now(), duration: dur, maxMult: maxM };
playGearSound(dur); // 齿轮声音
};
const onStop = () => {
stoppedRef.current = true; // 起卦结束,标记停止
burstRef.current = null;
};
window.addEventListener('compass-burst', onBurst);
window.addEventListener('compass-stop', onStop);
return () => {
window.removeEventListener('compass-burst', onBurst);
window.removeEventListener('compass-stop', onStop);
};
}, []);
React.useEffect(() => {
let raf, last = performance.now();
const tick = (t) => {
const dt = Math.min(0.05, (t - last) / 1000);
last = t;
const burst = burstRef.current;
let burstMult = 1;
if (burst) {
const elapsed = t - burst.start;
if (elapsed < burst.duration) {
const ACCEL = 3000; // 前 3s 加速
const maxM = burst.maxMult || 80;
if (elapsed < ACCEL) {
// 加速阶段:ease-in,从 1x → maxMult
const k = elapsed / ACCEL;
burstMult = 1 + maxM * Math.pow(k, 2);
} else {
// 减速阶段:ease-out,从 maxMult → 1
const k = (elapsed - ACCEL) / (burst.duration - ACCEL);
burstMult = 1 + maxM * Math.pow(1 - k, 3.2);
}
} else {
burstRef.current = null;
}
}
// stopped 优先级最高;其次 burst;最后 autoSpin
const active = !stoppedRef.current && (burstRef.current || autoSpinRef.current);
if (active) {
setRotations(prev => {
const next = { ...prev };
ringSpec.forEach((r, i) => {
const dir = i % 2 === 0 ? 1 : -1;
const base = (spinSpeedRef.current || 6) * (1 - i * 0.08);
const speed = base * burstMult;
next[r.key] = (prev[r.key] || 0) + dir * speed * dt;
});
return next;
});
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []); // 依赖为空——通过 ref 读取最新值,不需要重建闭包
// Pulsing aura
const [pulse, setPulse] = React.useState(0);
React.useEffect(() => {
let raf;
const tick = () => {
setPulse(performance.now() / 1000);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
const pulseScale = 1 + Math.sin(pulse * 1.4) * 0.04;
const pulseOpacity = 0.45 + Math.sin(pulse * 1.4) * 0.2;
return (
{/* Pulsing aura behind everything */}
{/* Orbiting starfield */}
{tweaks.showStars &&
}
{/* Light beams rising/descending */}
{tweaks.showBeams &&
}
{/* Outer glowing rings */}
{/* Tick marks */}
{ringSpec.map((r, i) => (
setRotations(p => ({ ...p, [r.key]: v }))}
fontSize={r.font}
glowColor={accentColors.glow}
textColor={accentColors.text}
divider={accentColors.divider}
fillEven={accentColors.fillA}
fillOdd={accentColors.fillB}
renderItem={r.renderItem}
accentIndices={r.accentIndices || []}
zIndex={10 + (ringSpec.length - i)}
/>
))}
{/* Cardinal cross */}
);
}
window.CosmicCompass = CosmicCompass;