const HEART_BREATH_INHALE_MS = 3_000;
const HEART_BREATH_HOLD_MS = 2_000;
const HEART_BREATH_EXHALE_MS = 4_000;
const HEART_BREATH_PREPARE_MS = 1_000;
const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS;
const HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5;
const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS;
const HEART_QUESTION_PRESETS = {
trade: "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?",
mind: "此刻影响我交易判断的情绪、执念或盲点是什么?",
unthemed: "不设具体问题,只观此刻一念。",
};
let qiFieldAnimationFrame = 0;
let qiFieldSoloElement = "";
let heavenPerformanceToken = 0;
let heavenReadingAnimation = null;
let heartHoldTimer = null;
let heartHoldTriggered = false;
let heartHoldStartedAt = 0;
let heartHoldAnimationFrame = 0;
let heartCastingBusy = false;
let heartDustAnimationFrame = 0;
let heartDustParticles = [];
let heartIncenseAnimation = null;
const heartCoinRotations = [0, 0, 0];
let heavenResizeTimer = null;
const heartSound = {
enabled: false,
context: null,
ensure() {
if (!this.context) {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
this.context = new AudioContextClass();
}
if (this.context.state === "suspended") this.context.resume();
return this.context;
},
tone(frequency, duration, gain, type = "sine", delay = 0) {
if (!this.enabled) return;
const context = this.ensure();
if (!context) return;
const start = context.currentTime + delay;
const oscillator = context.createOscillator();
const volume = context.createGain();
oscillator.type = type;
oscillator.frequency.value = frequency;
volume.gain.setValueAtTime(0.0001, start);
volume.gain.linearRampToValueAtTime(gain, start + 0.015);
volume.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(volume).connect(context.destination);
oscillator.start(start);
oscillator.stop(start + duration + 0.05);
},
chime(frequency = 640) {
this.tone(frequency, 4.8, 0.12);
this.tone(frequency * 2.02, 3.6, 0.045);
this.tone(frequency * 3.96, 2.2, 0.018);
},
coin(delay = 0) {
this.tone(2350 + Math.random() * 260, 0.28, 0.055, "triangle", delay);
this.tone(3250 + Math.random() * 260, 0.18, 0.025, "triangle", delay + 0.01);
},
};
const HEART_WHISPERS = [
["应无所住,而生其心", 10, 12, 0],
["不是风动,不是幡动,仁者心动", 89, 8, 1],
["菩提本无树,明镜亦非台", 16, 52, 2],
["本来无一物,何处惹尘埃", 84, 54, 3],
["心外无物,心外无理", 22, 18, 4],
["知行合一", 78, 30, 5],
["此心光明,亦复何言", 90, 60, 6],
];
window.addEventListener("resize", () => {
clearTimeout(heavenResizeTimer);
heavenResizeTimer = setTimeout(() => {
if (state.activeView !== "heavenView") return;
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
drawQiUseConnections(false);
}
if (state.heavenPanel === "heart") startHeartDust();
}, 120);
});
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
bind: bindHeavenEvents,
enter: ["loadHeaven"],
leave: ["stopHeaven"],
});
async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
const calendarDate = document.querySelector("#qiObservationDate")?.value || elements.tradeDate.value;
const requestedDate = calendarDate.replaceAll("-", "");
const manualData = state.heavenManualData;
const calibrationKey = manualData ? JSON.stringify(manualData) : "auto";
const requestedKey = `${requestedDate}:${sector}:${stockCode}:${calibrationKey}`;
if (!force && state.heavenSetup?.requestedKey === requestedKey) {
renderHeavenWorkspace();
return;
}
const requestSequence = ++state.heavenRequestSequence;
const heavenView = document.querySelector("#heavenView");
const loadButton = document.querySelector("#loadHeavenSelectionButton");
const calibrationButtons = [
document.querySelector("#applyHeavenCalibrationButton"),
document.querySelector("#resetHeavenCalibrationButton"),
].filter(Boolean);
cancelHeavenPerformance();
heavenView?.classList.add("heaven-data-loading");
if (loadButton) loadButton.disabled = true;
calibrationButtons.forEach((button) => { button.disabled = true; });
try {
if (state.heavenSetup?.requestedKey && state.heavenSetup.requestedKey !== requestedKey) {
state.personalField = null;
}
const query = new URLSearchParams({
trade_date: calendarDate,
});
if (sector) query.set("sector", sector);
if (stockCode) query.set("stock_code", stockCode);
if (manualData) query.set("manual_data", JSON.stringify(manualData));
const payload = await apiRequest(`/api/heaven/setup?${query}`);
if (
requestSequence !== state.heavenRequestSequence
|| calendarDate !== document.querySelector("#qiObservationDate")?.value
) return;
const previousFocus = state.heavenSetup
? `${state.heavenSetup.chart?.sector || ""}:${state.heavenSetup.chart?.stock?.code || ""}`
: "";
payload.requestedDate = requestedDate;
payload.requestedKey = requestedKey;
state.heavenSetup = payload;
state.heavenInterpretations.fortune = payload.daily_fortune_reading || "";
state.heavenManualData = Object.keys(payload.chart?.manual_data || {}).length
? payload.chart.manual_data
: null;
state.personalField = payload.personal_profile || null;
state.heavenPerformanceKey = `${requestedKey}:${requestSequence}`;
state.heavenPerformancePanels = new Set();
state.heavenPerformanceActive = "";
const nextFocus = `${payload.chart?.sector || ""}:${payload.chart?.stock?.code || ""}`;
if (previousFocus && previousFocus !== nextFocus) state.heavenInterpretations.trend = "";
hideHeavenNotice();
renderHeavenWorkspace();
if (payload.chart.selection_notice) showHeavenNotice(payload.chart.selection_notice);
} catch (error) {
if (requestSequence !== state.heavenRequestSequence) return;
showHeavenNotice(error.message || "问天数据加载失败");
showToast(error.message || "问天数据加载失败");
} finally {
if (requestSequence === state.heavenRequestSequence) {
heavenView?.classList.remove("heaven-data-loading");
if (loadButton) loadButton.disabled = false;
calibrationButtons.forEach((button) => { button.disabled = false; });
}
}
}
function loadHeavenSelection() {
const stockCode = document.querySelector("#heavenStockInput").value.trim();
state.heavenManualData = null;
loadHeavenSetup(true, "", stockCode);
}
function applyHeavenCalibration(event) {
event.preventDefault();
const data = { ...(state.heavenManualData || {}) };
delete data.note;
document.querySelectorAll("[data-heaven-manual-field]").forEach((input) => {
const current = String(input.value || "").trim();
const original = String(input.dataset.originalValue || "").trim();
if (!current) return;
if (current !== original || input.dataset.manual === "true") {
data[input.dataset.heavenManualField] = input.type === "number" ? Number(current) : current;
}
});
const note = document.querySelector("#heavenCalibrationNote").value.trim();
if (note) data.note = note;
if (!Object.keys(data).some((key) => key !== "note")) {
showToast("请先补充或修改至少一项量化数据");
return;
}
state.heavenManualData = data;
loadHeavenSetup(true, "", document.querySelector("#heavenStockInput").value.trim());
}
function resetHeavenCalibration() {
state.heavenManualData = null;
document.querySelector("#heavenCalibrationNote").value = "";
loadHeavenSetup(true, "", document.querySelector("#heavenStockInput").value.trim());
}
function selectHeavenPanel(panel, updateUrl = false) {
state.heavenPanel = panel;
if (state.heavenSetup) {
const calendarDate = state.heavenSetup.calendar_date || state.heavenSetup.trade_date;
const dateLabel = panel === "trend"
? (calendarDate === state.heavenSetup.trade_date
? `行情 ${displayCompactDate(state.heavenSetup.trade_date)}`
: `行情 ${displayCompactDate(state.heavenSetup.trade_date)} · 历法 ${displayCompactDate(calendarDate)}`)
: `历法 ${displayCompactDate(calendarDate)}`;
setText("heavenDataDate", dateLabel);
}
document.querySelectorAll("[data-heaven-panel]").forEach((button) => {
const active = button.dataset.heavenPanel === panel;
button.classList.toggle("active", active);
button.classList.toggle("on", active);
button.setAttribute("aria-current", active ? "page" : "false");
});
document.querySelectorAll(".heaven-panel").forEach((item) => {
item.classList.toggle("active-heaven-panel", item.id === `heaven${capitalize(panel)}Panel`);
});
if (
panel === "fortune"
&& state.heavenSetup?.field
&& state.heavenPerformancePanels.has("fortune")
) {
requestAnimationFrame(() => renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false }));
} else {
stopQiFieldCanvas();
}
if (panel === "heart") {
initializeHeartAtmosphere();
setHeartLamp(state.heartStage);
} else {
stopHeartDust();
}
if (panel !== "heart") requestAnimationFrame(() => queueHeavenPerformance(panel));
if (updateUrl) {
const url = new URL(window.location.href);
url.searchParams.set("heaven", panel);
history.replaceState(null, "", url);
}
}
function renderHeavenWorkspace() {
const setup = state.heavenSetup;
if (!setup) return;
initializeWentianV2Atmosphere();
renderMarketHexagram(setup.chart);
renderFivePhaseField(setup.field);
renderPersonalFortune();
renderHeartStage();
selectHeavenPanel(state.heavenPanel);
}
function buildWentianStars(id, count) {
const element = document.getElementById(id);
if (!element || element.children.length) return;
element.innerHTML = Array.from({ length: count }, () => {
const size = (Math.random() * 1.6 + 0.8).toFixed(1);
return ``;
}).join("");
}
function buildWentianBagua(svg) {
if (!svg || svg.children.length) return;
const trigrams = ["乾", "兑", "离", "震", "巽", "坎", "艮", "坤"];
let characters = "";
let ticks = "";
for (let index = 0; index < 8; index += 1) {
const angle = (index * 45 - 90) * Math.PI / 180;
const x = 150 + 129 * Math.cos(angle);
const y = 150 + 129 * Math.sin(angle);
characters += `${trigrams[index]}`;
}
for (let index = 0; index < 24; index += 1) {
const angle = (index * 15 - 90) * Math.PI / 180;
ticks += ``;
}
svg.innerHTML = `${characters}${ticks}`;
}
function buildWentianFortuneOrbit(svg) {
if (!svg || svg.children.length) return;
const sixQi = ["厥阴木", "少阴火", "少阳火", "太阴土", "阳明金", "太阳水"];
const movements = ["木运", "火运", "土运", "金运", "水运"];
const polarText = (items, radius, fontSize, offset = -90) => items.map((label, index) => {
const degrees = offset + index * 360 / items.length;
const angle = degrees * Math.PI / 180;
const x = 150 + radius * Math.cos(angle);
const y = 150 + radius * Math.sin(angle);
return `${label}`;
}).join("");
const ticks = Array.from({ length: 30 }, (_, index) => {
const angle = (index * 12 - 90) * Math.PI / 180;
const inner = index % 5 === 0 ? 96 : 101;
return ``;
}).join("");
svg.innerHTML = `${polarText(sixQi, 132, 8.5)}${ticks}${polarText(movements, 70, 10)}五运六气`;
}
function initializeWentianV2Atmosphere() {
buildWentianStars("stars", 90);
buildWentianStars("fortuneStars", 100);
buildWentianStars("heartStars", 110);
buildWentianBagua(document.querySelector("#baguaSvg"));
buildWentianFortuneOrbit(document.querySelector("#fortuneBagua"));
buildWentianBagua(document.querySelector("#heartBagua"));
}
function renderCompactHexagrams(hexagram) {
const original = document.querySelector("#heavenOriginalHexLines");
const changed = document.querySelector("#heavenChangedHexLines");
if (!original || !changed) return;
if (!hexagram?.lines?.length) {
original.innerHTML = "";
changed.innerHTML = "";
setText("heavenOriginalHexName", "待定");
setText("heavenChangedHexName", "待定");
setText("heavenOriginalHexDetail", "六爻尚未齐备");
setText("heavenChangedHexDetail", "待动爻化变");
return;
}
const values = hexagram.lines.map((line) => number(line.value));
const changedValues = values.map((value) => value === 6 ? 7 : value === 9 ? 8 : value);
const lines = (items, showMoving) => [...items].reverse().map((value) => {
const moving = showMoving && [6, 9].includes(value);
return `
${value % 2 ? "" : ""}
`;
}).join("");
original.innerHTML = lines(values, true);
changed.innerHTML = lines(changedValues, false);
setText("heavenOriginalHexName", hexagram.name || "--");
setText("heavenChangedHexName", hexagram.transformed?.name || "--");
setText("heavenOriginalHexDetail", `${hexagram.outer_trigram || "--"}上 · ${hexagram.inner_trigram || "--"}下`);
setText("heavenChangedHexDetail", `${hexagram.transformed?.outer_trigram || "--"}上 · ${hexagram.transformed?.inner_trigram || "--"}下`);
}
function cancelHeavenPerformance() {
heavenPerformanceToken += 1;
state.heavenPerformanceActive = "";
document.querySelectorAll("#heavenTrendPanel, #heavenFortunePanel").forEach((panel) => {
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
panel.classList.add("heaven-performance-complete");
});
}
function queueHeavenPerformance(panel) {
if (!state.heavenSetup || !["trend", "fortune"].includes(panel)) return;
const performanceId = `${state.heavenPerformanceKey}:${panel}`;
if (
state.heavenPerformancePanels.has(panel)
|| state.heavenPerformanceActive === performanceId
|| state.heavenPanel !== panel
) return;
const token = ++heavenPerformanceToken;
state.heavenPerformanceActive = performanceId;
const runner = panel === "trend"
? playTrendPerformance(state.heavenSetup.chart, token)
: playFortunePerformance(state.heavenSetup.field, token);
runner.then((completed) => {
if (!completed || token !== heavenPerformanceToken) return;
state.heavenPerformancePanels.add(panel);
state.heavenPerformanceActive = "";
});
}
function heavenPerformanceDelay(duration, token) {
return new Promise((resolve) => {
setTimeout(() => resolve(token === heavenPerformanceToken), motionEnabled() ? duration : 0);
});
}
async function typeHeavenText(element, text, token, speed = 38) {
if (!element) return false;
if (!motionEnabled()) {
element.textContent = text;
return token === heavenPerformanceToken;
}
element.textContent = "";
element.classList.add("heaven-typing");
for (const character of text) {
if (token !== heavenPerformanceToken) return false;
element.append(document.createTextNode(character));
if (!await heavenPerformanceDelay(speed, token)) return false;
}
element.classList.remove("heaven-typing");
return true;
}
function countHeavenNumber(element, target, token, duration = 1300, suffix = "") {
return new Promise((resolve) => {
if (!element || !motionEnabled()) {
if (element) element.textContent = `${target > 0 ? "+" : ""}${target}${suffix}`;
resolve(token === heavenPerformanceToken);
return;
}
const startedAt = performance.now();
const step = (now) => {
if (token !== heavenPerformanceToken) {
resolve(false);
return;
}
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - (1 - progress) ** 3;
const value = Math.round(target * eased);
element.textContent = `${value > 0 ? "+" : ""}${value}${suffix}`;
if (progress < 1) requestAnimationFrame(step);
else resolve(true);
};
requestAnimationFrame(step);
});
}
async function playTrendPerformance(chart, token) {
const panel = document.querySelector("#heavenTrendPanel");
if (!panel || state.heavenPanel !== "trend") return false;
panel.classList.remove(
"heaven-performance-complete",
"performance-title-ready",
"performance-change-ready",
"performance-score-ready",
"performance-text-ready",
);
panel.classList.add("heaven-performance-pending", "heaven-performance-running");
panel.querySelectorAll(".talent-line-group, .hexagram-line-row, .talent-reading, .heaven-index-strip > *").forEach((item) => {
item.classList.remove("is-ready");
});
if (!chart?.available) {
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
panel.classList.add("heaven-performance-complete");
return true;
}
const guaci = chart.hexagram.text || "";
const scoreElement = document.querySelector("#heavenMomentumScore");
const guaciElement = document.querySelector("#marketHexagramText");
if (scoreElement) scoreElement.textContent = "0";
if (guaciElement) guaciElement.textContent = "";
if (!await heavenPerformanceDelay(220, token)) return false;
const groups = [...panel.querySelectorAll(".talent-line-group")].reverse();
const readings = [...panel.querySelectorAll(".talent-reading")];
for (let index = 0; index < groups.length; index += 1) {
const group = groups[index];
group.classList.add("is-ready");
if (!await heavenPerformanceDelay(280, token)) return false;
const rows = [...group.querySelectorAll(".hexagram-line-row")].reverse();
for (const row of rows) {
row.classList.add("is-ready");
if (!await heavenPerformanceDelay(560, token)) return false;
}
readings[index]?.classList.add("is-ready");
if (!await heavenPerformanceDelay(220, token)) return false;
}
panel.classList.add("performance-title-ready");
if (!await heavenPerformanceDelay(650, token)) return false;
panel.classList.add("performance-change-ready");
if (!await heavenPerformanceDelay(420, token)) return false;
panel.classList.add("performance-score-ready");
if (!await countHeavenNumber(scoreElement, number(chart.momentum_score), token)) return false;
panel.querySelectorAll(".heaven-index-strip > *").forEach((item, index) => {
setTimeout(() => {
if (token === heavenPerformanceToken) item.classList.add("is-ready");
}, motionEnabled() ? index * 90 : 0);
});
if (!await heavenPerformanceDelay(620, token)) return false;
if (!await typeHeavenText(guaciElement, guaci, token, 30)) return false;
panel.classList.add("performance-text-ready");
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
panel.classList.add("heaven-performance-complete");
return true;
}
async function playFortunePerformance(field, token) {
const panel = document.querySelector("#heavenFortunePanel");
if (!panel || !field || state.heavenPanel !== "fortune") return false;
panel.classList.remove("heaven-performance-complete", "performance-climate-ready", "performance-use-ready");
panel.classList.add("heaven-performance-pending", "heaven-performance-running");
panel.querySelectorAll(".phase-balance-row, .qi-framework-layer, .human-field-grid > div, .personal-fortune-panel").forEach((item) => {
item.classList.remove("is-ready");
});
const climateTone = document.querySelector("#qiClimateTone");
const climateText = climateTone?.textContent || "";
if (climateTone) climateTone.textContent = "";
renderQiFieldCanvas(field.balance || [], { intro: true });
const climateSequence = async () => {
if (!await heavenPerformanceDelay(900, token)) return false;
panel.classList.add("performance-climate-ready");
if (!await heavenPerformanceDelay(720, token)) return false;
return typeHeavenText(climateTone, climateText, token, 58);
};
const qiSequence = async () => {
if (!await heavenPerformanceDelay(900, token)) return false;
const balanceRows = [...panel.querySelectorAll(".phase-balance-row")];
for (const row of balanceRows) {
row.classList.add("is-ready");
const percent = number(row.dataset.phasePercent);
if (!await countHeavenNumber(row.querySelector(":scope > b"), percent, token, 520, "%")) return false;
if (!await heavenPerformanceDelay(90, token)) return false;
}
const layers = [...panel.querySelectorAll(".qi-framework-layer")];
for (const layer of layers) {
layer.classList.add("is-ready");
if (!await heavenPerformanceDelay(250, token)) return false;
}
return true;
};
const personalSequence = async () => {
if (!await heavenPerformanceDelay(900, token)) return false;
panel.querySelectorAll(".human-field-grid > div").forEach((item, index) => {
setTimeout(() => {
if (token === heavenPerformanceToken) item.classList.add("is-ready");
}, motionEnabled() ? index * 150 : 0);
});
if (!await heavenPerformanceDelay(250, token)) return false;
panel.querySelector(".personal-fortune-panel")?.classList.add("is-ready");
return true;
};
const sequences = await Promise.all([climateSequence(), qiSequence(), personalSequence()]);
if (sequences.some((completed) => !completed) || token !== heavenPerformanceToken) return false;
panel.classList.add("performance-use-ready");
drawQiUseConnections(true);
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
panel.classList.add("heaven-performance-complete");
return true;
}
function heavenSourcePhrase(item = {}) {
const stateLabel = item.realtime ? "当下之象" : "既成之象";
const layerLabel = {
指数: "天象合参",
行业: "人势同观",
个股: "地脉验真",
用户补充: "人工验数",
}[item.layer] || "三才合参";
return `${layerLabel} · ${stateLabel}`;
}
function renderHeavenLineChecks(chart) {
const checks = [...(chart.data_checks || [])].sort((left, right) => number(right.line) - number(left.line));
const container = document.querySelector("#heavenLineChecks");
const status = document.querySelector("#heavenCalibrationStatus");
const passedCount = checks.filter((item) => item.passed).length;
const manualCount = checks.filter((item) => item.status === "manual").length;
status.textContent = checks.length ? `${passedCount}/6 通过${manualCount ? ` · ${manualCount} 爻含补录` : ""}` : "等待载入";
status.className = passedCount === 6 ? (manualCount ? "is-manual" : "is-passed") : "is-failed";
if (!checks.length) {
renderEmptyState(container, "载入股票后查看六爻数据状态");
return;
}
const lineValueLabel = { 6: "老阴 · 动", 7: "少阳 · 静", 8: "少阴 · 静", 9: "老阳 · 动" };
container.innerHTML = checks.map((check) => {
const stateLabel = check.status === "manual" ? "补录通过" : check.passed ? "自动通过" : "未通过";
const score = check.score === null || check.score === undefined ? "--" : signedScore(check.score);
const fields = (check.fields || []).map((field) => {
const rawValue = field.value === null || field.value === undefined ? "" : String(field.value);
const source = field.manual ? "用户补录" : rawValue ? "自动行情" : "等待补充";
const common = `data-heaven-manual-field="${escapeHtml(field.key)}" data-original-value="${escapeHtml(rawValue)}" data-manual="${field.manual ? "true" : "false"}"`;
const control = field.type === "select"
? ``
: field.type === "text"
? ``
: ``;
return ``;
}).join("");
const reasons = (check.reasons || []).map((reason) => `${escapeHtml(reason)}`).join("");
return `
${escapeHtml(stateLabel)}
${escapeHtml(check.position)} · ${escapeHtml(check.layer)}${escapeHtml(check.formula)}
${check.line_value ? escapeHtml(lineValueLabel[check.line_value] || check.line_value) : "待定"}得分 ${escapeHtml(score)}
${reasons ? `
` : `
${(check.evidence || []).map(escapeHtml).join(";") || "数据已通过安全门"}
`}
${fields}
`;
}).join("");
document.querySelector("#heavenCalibrationNote").value = chart.manual_data?.note || "";
window.lucide?.createIcons();
}
function renderMarketHexagram(chart) {
const stockInput = document.querySelector("#heavenStockInput");
if (document.activeElement !== stockInput) stockInput.value = chart.stock.code || "";
const selectionRequired = Boolean(chart.selection_required);
const emptyState = document.querySelector("#heavenTrendEmpty");
const trendLayout = document.querySelector("#heavenTrendPanel .heaven-trend-layout");
const calibrationPanel = document.querySelector("#heavenCalibrationPanel");
const stockIdentity = document.querySelector("#heavenStockIdentity");
if (emptyState) emptyState.hidden = !selectionRequired;
if (trendLayout) trendLayout.hidden = selectionRequired;
if (calibrationPanel) calibrationPanel.hidden = selectionRequired;
if (stockIdentity) stockIdentity.hidden = selectionRequired;
if (selectionRequired) {
document.querySelector("#interpretTrendButton").disabled = true;
setText("heavenStockName", "--");
setText("heavenStockSector", "--");
renderHeavenInterpretation("trend", "");
return;
}
setText("heavenStockName", chart.stock.name || "--");
setText(
"heavenStockSector",
chart.sector || "--",
);
setText("heavenStockTaxonomy", chart.sector_taxonomy === "sw_l2" ? "申万二级 ·" : "所属行业 ·");
renderHeavenLineChecks(chart);
const interpretButton = document.querySelector("#interpretTrendButton");
const scoreMeter = document.querySelector(".trend-score-meter");
const scoreNeedle = document.querySelector("#heavenMomentumNeedle");
const renderTrendEvidence = () => {
const rows = chart.quality?.sources || [];
document.querySelector("#heavenTrendEvidence").innerHTML = rows.length
? rows.map((item) => `
${escapeHtml(item.lines)} · ${escapeHtml(item.layer)}
${escapeHtml(heavenSourcePhrase(item))}
${escapeHtml(item.detail || "")}
`).join("")
: '暂无可核验的数据来源。
';
};
renderTrendEvidence();
if (!chart.available) {
interpretButton.disabled = true;
setText("marketHexagramName", "暂不成卦");
setText("marketTransformedName", "--");
setText("marketHexagramText", chart.quality?.principle || "六爻数据尚未齐备。");
setText("marketMovementSummary", (chart.quality?.issues || []).join(";") || "等待有效行情数据");
setText("heavenMomentumScore", "--");
setText("heavenMomentumLabel", "数据未齐");
renderCompactHexagrams(null);
scoreMeter?.setAttribute("aria-valuenow", "0");
if (scoreNeedle) scoreNeedle.style.setProperty("--momentum-position", "50%");
document.querySelector("#marketHexagramLines").innerHTML = "";
const sourceRows = chart.quality?.sources || [];
document.querySelector("#threeTalentReadings").innerHTML = [
...(chart.quality?.issues || []).map((issue) => `
未通过${escapeHtml(issue)}
`),
...sourceRows.map((item) => `
${escapeHtml(item.lines)} · ${escapeHtml(item.layer)}
${escapeHtml(heavenSourcePhrase(item))}
${escapeHtml(item.detail || "")}
`),
].join("");
document.querySelector("#heavenIndexStrip").innerHTML = "天象尚未应时,待三才数据齐备后再观。
";
renderHeavenInterpretation("trend", "");
return;
}
interpretButton.disabled = false;
setText("marketHexagramName", `${chart.hexagram.outer_trigram}上${chart.hexagram.inner_trigram}下 · ${chart.hexagram.name}`);
setText("marketTransformedName", chart.hexagram.transformed.name);
renderCompactHexagrams(chart.hexagram);
setText("marketHexagramText", chart.hexagram.text);
setText("marketMovementSummary", `${chart.movement.label}。${chart.movement.explanation}`);
setText("heavenMomentumScore", `${chart.momentum_score > 0 ? "+" : ""}${chart.momentum_score}`);
setText("heavenMomentumLabel", chart.momentum_label);
const momentumPosition = clamp((number(chart.momentum_score) + 100) / 2, 0, 100);
scoreMeter?.setAttribute("aria-valuenow", String(number(chart.momentum_score)));
if (scoreNeedle) scoreNeedle.style.setProperty("--momentum-position", `${momentumPosition}%`);
renderMarketHexagramLines(chart.hexagram.lines);
document.querySelector("#threeTalentReadings").innerHTML = chart.pair_readings.map((item) => `
${escapeHtml(item.level)}${escapeHtml(item.state)}
内 ${signedScore(item.inner)}
外 ${signedScore(item.outer)}
`).join("");
const indexContext = chart.index_context || {};
document.querySelector("#heavenIndexStrip").innerHTML = (indexContext.indices || []).length
? indexContext.indices.map((item) => `
${escapeHtml(item.name)}${signed(item.pct_chg)}%5日 ${signed(item.return_5d)}%
`).join("")
: `${escapeHtml(indexContext.notice || "指数数据暂不可用")}
`;
renderHeavenInterpretation("trend", state.heavenInterpretations.trend);
}
function renderMarketHexagramLines(lines) {
const groups = [
{ talent: "天", caption: "指数 · 外显为上,内核为下", lines: [lines[5], lines[4]] },
{ talent: "人", caption: "行业 · 外显为上,内核为下", lines: [lines[3], lines[2]] },
{ talent: "地", caption: "个股 · 外显为上,内核为下", lines: [lines[1], lines[0]] },
];
document.querySelector("#marketHexagramLines").innerHTML = groups.map((group, groupIndex) => `
${group.talent}
${group.caption}
${group.lines.map((line) => `
${escapeHtml(line.position_name)}
${hexagramLineGraphic(line.value)}
${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""}
${(line.evidence || []).map(escapeHtml).join(";")}
`).join("")}
`).join("");
}
function stopQiFieldCanvas() {
if (qiFieldAnimationFrame) cancelAnimationFrame(qiFieldAnimationFrame);
qiFieldAnimationFrame = 0;
}
function renderQiFieldCanvas(balance, options = {}) {
stopQiFieldCanvas();
const canvas = document.querySelector("#qiFieldCanvas");
const shell = canvas?.parentElement;
if (!canvas || !shell || !shell.clientWidth || !shell.clientHeight) return;
const context = canvas.getContext("2d");
const ratio = Math.min(2, window.devicePixelRatio || 1);
const width = shell.clientWidth;
const height = shell.clientHeight;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
const phaseColors = { 木: "#4a7c59", 火: "#b53a30", 土: "#b08a3e", 金: "#9c7c3c", 水: "#31505f" };
const positions = {
水: [0.50, 0.23],
火: [0.50, 0.77],
金: [0.28, 0.50],
木: [0.72, 0.50],
土: [0.50, 0.50],
};
const introStartedAt = options.intro && motionEnabled() ? performance.now() : 0;
const items = balance.map((item, index) => ({
...item,
color: phaseColors[item.element] || "#6d685b",
x: positions[item.element]?.[0] || 0.5,
y: positions[item.element]?.[1] || 0.5,
phase: index * 1.7,
alpha: introStartedAt ? 0 : 1,
}));
const draw = (now = 0) => {
context.clearRect(0, 0, width, height);
context.globalCompositeOperation = "multiply";
items.forEach((item, index) => {
const strength = Math.max(0.14, number(item.percent) / 100);
const introProgress = introStartedAt ? clamp((now - introStartedAt) / 2600, 0, 1) : 1;
const introEase = 1 - (1 - introProgress) ** 3;
const breath = motionEnabled() ? Math.sin(now * 0.00055 + item.phase) : 0;
const radius = Math.min(width, height) * (0.13 + Math.sqrt(strength) * 0.12) * (1 + breath * 0.06);
const targetAlpha = qiFieldSoloElement ? (qiFieldSoloElement === item.element ? 1 : 0.1) : 1;
item.alpha += (targetAlpha - item.alpha) * 0.06;
const targetX = width * item.x + (motionEnabled() ? Math.sin(now * (0.00012 + index * 0.000015) + item.phase) * 10 : 0);
const targetY = height * item.y + (motionEnabled() ? Math.cos(now * (0.0001 + index * 0.000013) + item.phase) * 8 : 0);
const centerX = width * 0.5;
const centerY = height * 0.47;
const x = centerX + (targetX - centerX) * introEase;
const y = centerY + (targetY - centerY) * introEase;
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
const rgb = item.color.match(/[a-f\d]{2}/gi).map((part) => parseInt(part, 16));
const alpha = item.alpha * introEase;
gradient.addColorStop(0, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(0.28 + strength * 0.22) * alpha})`);
gradient.addColorStop(0.5, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(0.13 + strength * 0.12) * alpha})`);
gradient.addColorStop(1, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0)`);
context.fillStyle = gradient;
context.fillRect(x - radius, y - radius, radius * 2, radius * 2);
});
context.globalCompositeOperation = "source-over";
if (motionEnabled() && state.activeView === "heavenView" && state.heavenPanel === "fortune") {
qiFieldAnimationFrame = requestAnimationFrame(draw);
}
};
draw(performance.now());
}
function wentianClimateVerdict(field) {
const balance = field?.balance || [];
const dominant = balance[0]?.element;
const secondary = balance[1]?.element;
const tertiary = balance[2]?.element;
const pair = [dominant, secondary].filter(Boolean).sort().join("");
const primary = {
木火: "风火相煽", 木土: "风湿相搏", 木金: "风燥相激", 木水: "风寒相薄",
土火: "湿热交蒸", 火金: "燥热相煽", 水火: "寒热相争", 土金: "燥湿相搏",
土水: "寒湿交织", 水金: "寒燥相参",
}[pair] || ({ 木: "风木疏展", 火: "热火升明", 土: "湿滞偏重", 金: "燥金肃降", 水: "寒水潜藏" }[dominant] || "气机交会");
const following = { 木: "风象暗动", 火: "热象内蕴", 土: "湿滞内结", 金: "燥气相参", 水: "寒意潜行" }[tertiary]
|| ({ 木: "风象相随", 火: "热象相随", 土: "湿象相随", 金: "燥象相随", 水: "寒象相随" }[secondary] || "诸气相参");
return `${primary} · ${following}`;
}
function renderFivePhaseField(field) {
if (!field) return;
setText("fortuneLunarDate", `${field.date} · ${field.lunar_date}`);
setText("fortunePillars", `${field.pillars.year}年 · ${field.pillars.month}月 · ${field.pillars.day}日`);
const metrics = [
["中运", field.movement.label, field.movement.basis],
["司天", field.six_qi.sitian, "岁半以前主气候背景"],
["在泉", field.six_qi.zaiquan, "岁半以后主气候背景"],
[field.six_qi.step_name, `主 ${field.six_qi.host_qi}`, `客 ${field.six_qi.guest_qi}`],
["当前节气", field.solar_terms.current, field.solar_terms.current_at],
["下一节气", field.solar_terms.next, field.solar_terms.next_at],
];
document.querySelector("#fortuneMetrics").innerHTML = metrics.map(([label, value, detail]) => `
${escapeHtml(label)}${escapeHtml(value)}${escapeHtml(detail)}
`).join("");
const framework = field.framework || {};
setText("qiFrameworkPrinciple", framework.principle || "--");
const layerLabels = { year: "年运与岁气", current: "客主加临", day: "日辰触发" };
document.querySelector("#qiFrameworkLayers").innerHTML = (framework.layers || []).map((layer) => `
${escapeHtml(layerLabels[layer.id] || layer.label)}
${escapeHtml(layer.dominant)}气
${escapeHtml(layer.summary)}
${(layer.balance || []).map((item) => ``).join("")}
`).join("");
const human = field.human_field || {};
const dominantPhase = (field.balance || [])[0];
setText("qiClimateKeyword", wentianClimateVerdict(field));
setText("qiClimateTone", (human.emotional_tendency || [])[0] || "留意当下身心反应");
setText("humanFieldSummary", human.summary || "--");
setText("humanEmotionList", (human.emotional_tendency || []).join(";") || "--");
setText("humanBiasList", (human.decision_biases || []).join(";") || "--");
setText("humanOperation", human.operation_tendency || "--");
setText(
"humanBalanceActions",
[...(human.risk_reminders || []), ...(human.balancing_actions || [])].join(";") || "--",
);
document.querySelector("#fivePhaseBalance").innerHTML = field.balance.map((item) => `
${escapeHtml(item.element)}
${escapeHtml(item.motion)} · ${escapeHtml(item.mind)}
${number(item.percent)}%
`).join("");
document.querySelectorAll("#fivePhaseBalance .phase-balance-row").forEach((row) => {
const focusPhase = () => { qiFieldSoloElement = row.dataset.phaseElement || ""; };
const clearPhase = () => { qiFieldSoloElement = ""; };
row.addEventListener("mouseenter", focusPhase);
row.addEventListener("mouseleave", clearPhase);
row.addEventListener("focus", focusPhase);
row.addEventListener("blur", clearPhase);
row.addEventListener("click", () => {
qiFieldSoloElement = qiFieldSoloElement === row.dataset.phaseElement ? "" : row.dataset.phaseElement;
});
});
setText("phaseSectorTitle", "五行行业归属");
setText("phaseSectorContext", "传统取象 · 手动归类优先");
renderQiUseMap(field);
renderFortuneSectorCatalog(field);
renderSectorPhaseOverrides(state.heavenSetup?.sector_phase_overrides || []);
setText("fortuneNotice", field.notice);
renderHeavenInterpretation("fortune", state.heavenInterpretations.fortune);
}
function renderFortuneSectorCatalog(field) {
const container = document.querySelector("#fortuneSectorGroups");
if (!container) return;
const phaseOrder = new Map((field.balance || []).map((item, index) => [item.element, index]));
const canonical = { 木: 0, 火: 1, 土: 2, 金: 3, 水: 4 };
const catalog = [...(field.sector_catalog || [])].sort((left, right) => (
(canonical[left.element] ?? phaseOrder.get(left.element) ?? 99)
- (canonical[right.element] ?? phaseOrder.get(right.element) ?? 99)
));
container.innerHTML = catalog.map((group) => `
${escapeHtml(group.element)}属性${number(group.count || group.industries?.length)} 类
${(group.industries || []).map((item) => `- ${escapeHtml(item.name)}
`).join("")}
`).join("") || '行业五行归类尚未建立
';
}
function renderQiUseMap(field) {
const sourceContainer = document.querySelector("#qiUseSources");
const sectorContainer = document.querySelector("#phaseSectorList");
if (!sourceContainer || !sectorContainer) return;
const balance = field.balance || [];
const phaseOrder = new Map(balance.map((item, index) => [item.element, index]));
const catalog = [...(field.sector_catalog || [])].sort(
(left, right) => (phaseOrder.get(left.element) ?? 99) - (phaseOrder.get(right.element) ?? 99),
);
const catalogElements = new Set(catalog.map((item) => item.element));
sourceContainer.innerHTML = balance.map((item) => `
${escapeHtml(item.element)}
${escapeHtml(item.motion)}${number(item.percent)}%
`).join("");
sectorContainer.innerHTML = catalog.length ? catalog.map((group) => {
const element = group.element;
const items = group.industries || [];
return `
${escapeHtml(element)}属性
${number(group.count)} 类
`;
}).join("") : '行业五行归类尚未建立。
';
sectorContainer.querySelectorAll(".qi-sector-group").forEach((group) => {
group.addEventListener("toggle", () => requestAnimationFrame(() => drawQiUseConnections(false)));
});
refreshIcons();
requestAnimationFrame(() => drawQiUseConnections(false));
}
function drawQiUseConnections(animate = false) {
const map = document.querySelector("#qiUseMap");
const svg = document.querySelector("#qiUseConnections");
if (!map || !svg || !map.clientWidth || !map.clientHeight) return;
const bounds = map.getBoundingClientRect();
svg.setAttribute("viewBox", `0 0 ${bounds.width} ${bounds.height}`);
svg.innerHTML = "";
document.querySelectorAll("#phaseSectorList [data-qi-sector]").forEach((group) => {
const element = group.dataset.qiSector;
const source = document.querySelector(`#qiUseSources [data-qi-source="${CSS.escape(element)}"]`);
const target = group.querySelector("summary");
if (!source || !target) return;
const from = source.getBoundingClientRect();
const to = target.getBoundingClientRect();
const x1 = from.right - bounds.left - 4;
const y1 = from.top + from.height / 2 - bounds.top;
const x2 = to.left - bounds.left + 2;
const y2 = to.top + to.height / 2 - bounds.top;
const bend = Math.max(46, (x2 - x1) * 0.42);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`);
path.setAttribute("pathLength", "1");
path.classList.add(`phase-stroke-${phaseClass(element)}`);
if (animate && motionEnabled()) path.classList.add("is-drawing");
else path.classList.add("is-flowing");
svg.appendChild(path);
if (animate && motionEnabled()) {
requestAnimationFrame(() => path.classList.add("is-visible"));
setTimeout(() => {
if (!path.isConnected) return;
path.classList.remove("is-drawing", "is-visible");
path.classList.add("is-flowing");
}, 1900);
}
});
}
function renderSectorPhaseOverrides(items) {
const container = document.querySelector("#sectorPhaseOverrides");
const canManage = state.user?.role === "admin";
container.innerHTML = items.length ? items.map((item) => `
${escapeHtml(item.element)}
${escapeHtml(item.name)}
${canManage ? `` : ""}
`).join("") : '暂无手动归类
';
container.querySelectorAll("[data-sector-phase-delete]").forEach((button) => {
button.addEventListener("click", () => deleteSectorPhaseOverride(button.dataset.sectorPhaseDelete));
});
refreshIcons();
}
async function saveSectorPhaseOverride(event) {
event.preventDefault();
const name = document.querySelector("#sectorPhaseName").value.trim();
const element = document.querySelector("#sectorPhaseElement").value;
if (!name) return;
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/heaven/sector-phases", "POST", { name, element });
document.querySelector("#sectorPhaseName").value = "";
await loadHeavenSetup(true);
showToast(`已将 ${name} 归为${element}`);
} catch (error) {
showToast(error.message || "手动归类保存失败");
} finally {
button.disabled = false;
}
}
async function deleteSectorPhaseOverride(name) {
try {
await apiRequest(`/api/heaven/sector-phases/${encodeURIComponent(name)}`, "DELETE");
await loadHeavenSetup(true);
showToast(`已删除 ${name} 的手动归类`);
} catch (error) {
showToast(error.message || "手动归类删除失败");
}
}
async function saveAccountBirthProfile(event) {
event.preventDefault();
const birthDate = document.querySelector("#accountBirthDate").value;
const birthTime = document.querySelector("#accountBirthTime").value;
if (!birthDate || !birthTime) {
showToast("请填写完整出生日期和时间");
return;
}
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
const originalText = button.textContent;
button.textContent = "正在排盘";
try {
await apiRequest("/api/account/birth-profile", "POST", {
trade_date: document.querySelector("#qiObservationDate").value || elements.tradeDate.value,
birth_datetime: `${birthDate}T${birthTime}`,
gender: document.querySelector("#accountBirthGender").value,
});
event.currentTarget.reset();
setText("birthProfileStatus", "已加密保存");
document.querySelector("#deleteBirthProfileButton").disabled = false;
state.heavenInterpretations.fortune = "";
await loadHeavenSetup(true);
showToast("个人命理资料已保存到当前账号");
} catch (error) {
showToast(error.message || "个人命理资料保存失败");
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
async function deleteAccountBirthProfile() {
if (!window.confirm("确定删除当前账号保存的个人命理资料吗?")) return;
try {
await apiRequest("/api/account/birth-profile", "DELETE");
state.personalField = null;
state.heavenInterpretations.fortune = "";
setText("birthProfileStatus", "尚未设置");
document.querySelector("#deleteBirthProfileButton").disabled = true;
renderPersonalFortune();
showToast("个人命理资料已删除");
} catch (error) {
showToast(error.message || "个人命理资料删除失败");
}
}
function renderPersonalFortune() {
const container = document.querySelector("#personalFortuneResult");
const empty = document.querySelector("#personalProfileEmpty");
const personal = state.personalField;
if (!personal) {
empty.hidden = false;
container.hidden = true;
container.innerHTML = "";
return;
}
empty.hidden = true;
container.hidden = false;
const tenGods = personal.ten_god_tendency || { favorable: [], caution: [] };
const elementTendency = personal.balance_tendency || { favorable: [], caution: [] };
const preferenceTags = (items) => (items || []).map((item) => `${escapeHtml(item)}`).join("") || "--";
const dayMasterElement = personal.day_master?.element || "";
const dayMasterPhase = phaseClass(dayMasterElement);
container.innerHTML = `
本命日主
${escapeHtml(personal.day_master?.stem || "--")}${escapeHtml(dayMasterElement)}
日主${personal.day_master?.strength ? ` · ${escapeHtml(personal.day_master.strength)}` : ""}
十神喜恶偏宜${preferenceTags(tenGods.favorable)}
偏慎${preferenceTags(tenGods.caution)}
五行喜忌偏喜${preferenceTags(elementTendency.favorable)}
偏忌${preferenceTags(elementTendency.caution)}
`;
refreshIcons();
}
function renderHexagramLines(containerId, lines, includeEvidence = false) {
const container = document.querySelector(`#${containerId}`);
container.innerHTML = [...lines].reverse().map((line) => `
${escapeHtml(line.position_name)}
${hexagramLineGraphic(line.value)}
${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""}
${includeEvidence ? `${(line.evidence || []).map(escapeHtml).join(";")}` : `${escapeHtml(line.text || "")}`}
`).join("");
}
function hexagramLineGraphic(value) {
const yang = value % 2 === 1;
return `
${yang ? "" : ""}${[6, 9].includes(value) ? `${value === 9 ? "○" : "×"}` : ""}
`;
}
const HEAVEN_READING_META = {
trend: { panel: "观势", action: "解势", done: "查看解势", status: "势已成" },
fortune: { panel: "观气", action: "解运", done: "已解运", status: "气已定" },
heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解" },
};
const HEAVEN_ANSWER_HEADINGS = [
"核心判断", "卦势依据", "动爻转折", "之卦趋向", "决策映射",
"年纲", "客主加临", "日辰触发", "行业影响", "个人合参", "制衡动作",
"所问之答", "卦象依据", "动变与之卦", "可验证之处",
"主要矛盾", "变化方向", "交易映射", "可验证动作",
];
function formatHeavenAnswer(content) {
const labels = HEAVEN_ANSWER_HEADINGS.join("|");
let normalized = String(content || "").replace(/\r\n?/g, "\n");
normalized = normalized.replace(
new RegExp(`(^|\\n)\\s*\\*\\*(${labels})[::]?\\*\\*\\s*`, "g"),
(_match, prefix, heading) => `${prefix}## ${heading}\n\n`,
);
normalized = normalized.replace(
new RegExp(`(^|[。!?;]\\s*|\\n)(${labels})[::]\\s*`, "g"),
(_match, prefix, heading) => `${prefix}\n\n## ${heading}\n\n`,
);
return formatMentorAnswer(normalized.replace(/\n{3,}/g, "\n\n"));
}
function heavenReadingMeta(mode = state.heavenReadingMode) {
return HEAVEN_READING_META[mode] || HEAVEN_READING_META.trend;
}
function heavenReadingAnimationData() {
const field = state.heavenSetup?.field || {};
return {
yearPillar: field.pillars?.year || "",
movement: field.movement?.label || "",
sixQi: {
sitian: field.six_qi?.sitian || "",
zaiquan: field.six_qi?.zaiquan || "",
step: number(field.six_qi?.step) || 1,
},
};
}
function syncHeavenReadingAnimation() {
const canvas = document.querySelector("#heavenReadingCanvas");
const shouldRun = state.heavenReadingLoading && state.heavenReadingTab === "current";
if (!canvas || !window.HeavenLoadingCanvas) return;
if (!shouldRun) {
stopHeavenReadingAnimation();
return;
}
if (!heavenReadingAnimation) heavenReadingAnimation = new window.HeavenLoadingCanvas(canvas);
const scene = state.heavenReadingMode === "fortune" ? "fortune" : "hexagram";
heavenReadingAnimation.start(scene, heavenReadingAnimationData());
}
function stopHeavenReadingAnimation() {
heavenReadingAnimation?.stop();
}
function finishHeavenReadingAnimation() {
if (!elements.heavenReadingDialog.open || !heavenReadingAnimation?.running) {
stopHeavenReadingAnimation();
return Promise.resolve();
}
return heavenReadingAnimation.complete();
}
function openHeavenReading(mode, options = {}) {
state.heavenReadingMode = mode;
state.heavenReadingTab = "current";
state.heavenReadingError = options.error || "";
state.heavenReadingLoading = Object.hasOwn(options, "loading")
? Boolean(options.loading)
: false;
renderHeavenReadingDialog();
openModalDialog(elements.heavenReadingDialog);
requestAnimationFrame(() => {
syncHeavenReadingAnimation();
document.querySelector("#closeHeavenReadingDialog").focus();
});
}
async function openHeavenHistory(mode) {
state.heavenReadingMode = mode;
state.heavenReadingTab = "history";
state.heavenReadingSelectedId = 0;
renderHeavenReadingDialog();
openModalDialog(elements.heavenReadingDialog);
await loadHeavenReadingHistory(mode);
}
function selectHeavenReadingTab(tab) {
state.heavenReadingTab = tab === "history" ? "history" : "current";
renderHeavenReadingDialog();
if (state.heavenReadingTab === "history") loadHeavenReadingHistory(state.heavenReadingMode);
}
async function loadHeavenReadingHistory(mode) {
const list = document.querySelector("#heavenReadingHistoryList");
renderEmptyState(list, "正在读取历史记录");
try {
const query = new URLSearchParams({ mode, limit: "100" });
const payload = await apiRequest(`/api/heaven/readings?${query}`);
state.heavenReadingHistory[mode] = payload.items || [];
if (!state.heavenReadingHistory[mode].some((item) => number(item.id) === state.heavenReadingSelectedId)) {
state.heavenReadingSelectedId = number(state.heavenReadingHistory[mode][0]?.id);
}
renderHeavenReadingHistory();
} catch (error) {
renderEmptyState(list, error.message || "历史记录加载失败");
}
}
function renderHeavenReadingDialog() {
const meta = heavenReadingMeta();
setText("heavenReadingEyebrow", `问天 · ${meta.panel}`);
setText("heavenReadingDialogTitle", state.heavenReadingTab === "history" ? "历史记录" : meta.status);
document.querySelectorAll("[data-heaven-reading-tab]").forEach((button) => {
const active = button.dataset.heavenReadingTab === state.heavenReadingTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
document.querySelector("#heavenReadingCurrent").hidden = state.heavenReadingTab !== "current";
document.querySelector("#heavenReadingHistory").hidden = state.heavenReadingTab !== "history";
if (state.heavenReadingTab === "current") {
renderHeavenReadingCurrent();
} else {
stopHeavenReadingAnimation();
renderHeavenReadingHistory();
}
refreshIcons();
}
function renderHeavenReadingCurrent() {
const reading = state.heavenInterpretations[state.heavenReadingMode];
const loading = document.querySelector("#heavenReadingLoading");
const empty = document.querySelector("#heavenReadingEmpty");
const result = document.querySelector("#heavenReadingResult");
const error = document.querySelector("#heavenReadingError");
loading.hidden = !state.heavenReadingLoading;
syncHeavenReadingAnimation();
error.hidden = !state.heavenReadingError;
error.textContent = state.heavenReadingError;
result.hidden = state.heavenReadingLoading || !reading;
empty.hidden = state.heavenReadingLoading || Boolean(reading) || Boolean(state.heavenReadingError);
if (!reading || state.heavenReadingLoading) return;
setText("heavenReadingResultStatus", heavenReadingMeta().status);
setText("heavenReadingSubject", reading.subject || `${heavenReadingMeta().panel}解读`);
setText("heavenReadingSubjectDetail", reading.subject_detail || displayCompactDate(reading.context_date || ""));
setText("heavenReadingCreatedAt", reading.created_at ? formatTimestamp(reading.created_at) : "刚刚完成");
document.querySelector("#heavenReadingAnswer").innerHTML = formatHeavenAnswer(reading.answer || "");
}
function renderHeavenReadingHistory() {
const mode = state.heavenReadingMode;
const items = state.heavenReadingHistory[mode] || [];
setText("heavenReadingHistoryTitle", `${heavenReadingMeta(mode).panel}记录`);
setText("heavenReadingHistoryCount", `${items.length} 条`);
const list = document.querySelector("#heavenReadingHistoryList");
list.innerHTML = items.map((item) => `
`).join("") || emptyStateHtml("暂无历史解读");
const selected = items.find((item) => number(item.id) === state.heavenReadingSelectedId);
const detail = document.querySelector("#heavenReadingHistoryDetail");
detail.innerHTML = selected ? `
${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}
${formatHeavenAnswer(selected.answer || "")}
` : emptyStateHtml("选择一条记录查看完整解读");
refreshIcons();
}
function handleHeavenHistorySelection(event) {
const button = event.target.closest("[data-heaven-reading-id]");
if (!button) return;
state.heavenReadingSelectedId = number(button.dataset.heavenReadingId);
renderHeavenReadingHistory();
}
async function handleHeavenHistoryAction(event) {
const button = event.target.closest("[data-delete-heaven-reading]");
if (!button || !window.confirm("确定删除这条解读记录吗?")) return;
const id = number(button.dataset.deleteHeavenReading);
try {
await apiRequest(`/api/heaven/readings/${id}`, "DELETE");
const mode = state.heavenReadingMode;
state.heavenReadingHistory[mode] = (state.heavenReadingHistory[mode] || []).filter((item) => number(item.id) !== id);
if (number(state.heavenInterpretations[mode]?.id) === id) {
state.heavenInterpretations[mode] = "";
if (mode === "fortune" && state.heavenSetup) state.heavenSetup.daily_fortune_reading = null;
updateHeavenInterpretationControls();
}
state.heavenReadingSelectedId = number(state.heavenReadingHistory[mode][0]?.id);
renderHeavenReadingHistory();
} catch (error) {
showToast(error.message || "解读记录删除失败");
}
}
async function interpretHeaven(mode) {
const existing = state.heavenInterpretations[mode];
if (existing) {
openHeavenReading(mode, { loading: false });
return;
}
const button = document.querySelector(mode === "trend" ? "#interpretTrendButton" : mode === "fortune" ? "#interpretFortuneButton" : "#interpretHeartButton");
if (button.disabled) return;
state.heavenReadingMode = mode;
state.heavenReadingLoading = true;
state.heavenReadingError = "";
openHeavenReading(mode, { loading: true });
updateHeavenInterpretationControls();
hideHeavenNotice();
try {
const payload = {
mode,
trade_date: document.querySelector("#qiObservationDate").value || elements.tradeDate.value,
sector: state.heavenSetup?.chart?.sector || "",
stock_code: state.heavenSetup?.chart?.stock?.code || "",
};
if (mode === "trend" && state.heavenManualData) payload.manual_data = state.heavenManualData;
if (mode === "heart") {
payload.trade_date = todayString();
payload.lines = state.heartLines;
payload.question = state.heartQuestion;
payload.question_preset = state.heartQuestionPreset;
payload.cast_at = state.heartCastAt;
}
const result = await apiRequest("/api/heaven/interpret", "POST", payload);
state.heavenInterpretations[mode] = result.reading || {
answer: result.answer,
subject: `${heavenReadingMeta(mode).panel}解读`,
context_date: payload.trade_date,
created_at: new Date().toISOString(),
};
state.heavenReadingHistory[mode] = [];
await finishHeavenReadingAnimation();
state.heavenReadingLoading = false;
renderHeavenReadingDialog();
if (result.notice) showHeavenNotice(result.notice);
if (mode === "heart") {
if (await transitionHeartStage("interpretation")) await playHeartReadSequence();
} else {
renderHeavenInterpretation(mode, state.heavenInterpretations[mode]);
}
} catch (error) {
stopHeavenReadingAnimation();
state.heavenReadingLoading = false;
state.heavenReadingError = error.message || "问天解读失败";
renderHeavenReadingDialog();
showHeavenNotice(state.heavenReadingError);
showToast(state.heavenReadingError);
} finally {
state.heavenReadingLoading = false;
updateHeavenInterpretationControls();
}
}
function updateHeavenInterpretationControls() {
const loading = state.heavenReadingLoading;
const trendButton = document.querySelector("#interpretTrendButton");
const fortuneButton = document.querySelector("#interpretFortuneButton");
const heartButton = document.querySelector("#interpretHeartButton");
trendButton.disabled = loading || !state.heavenSetup?.chart?.available;
fortuneButton.disabled = loading || !state.heavenSetup?.field;
heartButton.disabled = loading || state.heartLines.length !== 6;
trendButton.textContent = loading && state.heavenReadingMode === "trend" ? "正在观势" : state.heavenInterpretations.trend ? HEAVEN_READING_META.trend.done : HEAVEN_READING_META.trend.action;
fortuneButton.textContent = loading && state.heavenReadingMode === "fortune" ? "正在察运" : state.heavenInterpretations.fortune ? HEAVEN_READING_META.fortune.done : HEAVEN_READING_META.fortune.action;
heartButton.textContent = loading && state.heavenReadingMode === "heart" ? "正在解卦" : state.heavenInterpretations.heart ? HEAVEN_READING_META.heart.done : HEAVEN_READING_META.heart.action;
document.querySelector("#viewHeartReadingButton").disabled = !state.heavenInterpretations.heart;
}
function renderHeavenInterpretation() {
updateHeavenInterpretationControls();
}
function initializeHeartAtmosphere() {
const whisperContainer = document.querySelector("#heartWhispers");
if (whisperContainer && !whisperContainer.children.length) {
whisperContainer.innerHTML = HEART_WHISPERS.map(([text, x, y, index]) => `
${escapeHtml(text)}
`).join("");
}
activateHeartRises(document.querySelector(".heart-stage.active-heart-stage"));
}
function toggleHeartSound() {
heartSound.enabled = !heartSound.enabled;
const button = document.querySelector("#heartSoundToggle");
button.setAttribute("aria-pressed", String(heartSound.enabled));
button.setAttribute("aria-label", heartSound.enabled ? "关闭观心声音" : "开启观心声音");
button.innerHTML = `${heartSound.enabled ? "有声" : "静音"}`;
if (heartSound.enabled) {
heartSound.ensure();
heartSound.chime(520);
}
refreshIcons();
}
function setHeartLamp(stage) {
const lamp = document.querySelector("#heartLamp");
if (lamp) lamp.dataset.heartStage = stage;
}
function startHeartDust() {
stopHeartDust();
const canvas = document.querySelector("#heartDustCanvas");
const panel = document.querySelector("#heavenHeartPanel");
if (!canvas || !panel || !panel.clientWidth || !panel.clientHeight) return;
const context = canvas.getContext("2d");
const ratio = Math.min(2, window.devicePixelRatio || 1);
const width = panel.clientWidth;
const height = panel.clientHeight;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
canvas.style.height = `${height}px`;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
if (!heartDustParticles.length) {
heartDustParticles = Array.from({ length: 60 }, (_, index) => ({
x: Math.random(),
y: Math.random(),
radius: 0.6 + Math.random() * 1.5,
alpha: 0.03 + Math.random() * 0.09,
vx: (Math.random() - 0.5) * 0.00006,
vy: -(0.00002 + Math.random() * 0.00008),
phase: Math.random() * Math.PI * 2,
gold: index % 2 === 0,
}));
}
const draw = (now) => {
context.clearRect(0, 0, width, height);
heartDustParticles.forEach((particle) => {
if (motionEnabled()) {
particle.x += particle.vx;
particle.y += particle.vy;
particle.phase += 0.006;
}
if (particle.y < -0.02) {
particle.y = 1.02;
particle.x = Math.random();
}
if (particle.x < -0.02) particle.x = 1.02;
if (particle.x > 1.02) particle.x = -0.02;
const alpha = particle.alpha * (0.65 + 0.35 * Math.sin(particle.phase));
context.beginPath();
context.arc(particle.x * width, particle.y * height, particle.radius, 0, Math.PI * 2);
context.fillStyle = particle.gold ? `rgba(220,195,140,${alpha})` : `rgba(190,200,225,${alpha * 0.8})`;
context.fill();
});
if (motionEnabled() && state.activeView === "heavenView" && state.heavenPanel === "heart") {
heartDustAnimationFrame = requestAnimationFrame(draw);
} else {
heartDustAnimationFrame = 0;
}
};
heartDustAnimationFrame = requestAnimationFrame(draw);
}
function stopHeartDust() {
if (heartDustAnimationFrame) cancelAnimationFrame(heartDustAnimationFrame);
heartDustAnimationFrame = 0;
}
function activateHeartRises(stage) {
if (!stage) return;
stage.querySelectorAll(".heart-rise").forEach((item) => {
item.classList.remove("is-visible");
const delay = motionEnabled() ? number(item.dataset.heartDelay) : 0;
setTimeout(() => {
if (stage.classList.contains("active-heart-stage")) item.classList.add("is-visible");
}, delay);
});
}
async function transitionHeartStage(nextStage) {
const token = ++state.heartStageToken;
state.heartRevealToken += 1;
const current = document.querySelector(".heart-stage.active-heart-stage");
current?.classList.add("is-leaving");
if (current && !await waitForHeartMotion(1050, token)) return false;
state.heartStage = nextStage;
renderHeartStage();
return token === state.heartStageToken;
}
function waitForHeartMotion(duration, token = state.heartStageToken) {
return new Promise((resolve) => {
setTimeout(() => resolve(token === state.heartStageToken), motionEnabled() ? duration : 0);
});
}
async function startHeartBreathing() {
syncHeartQuestion();
if (!state.heartQuestion) {
showToast("请先写下问题,或选择无题观心");
return;
}
setHeartQuestionLocked(true);
if (state.heartTimer) clearInterval(state.heartTimer);
state.heartSeconds = HEART_BREATH_TOTAL_MS / 1000;
state.heartBreathingEndsAt = 0;
document.querySelector("#beginCastingButton")?.classList.remove("is-ready");
if (!await transitionHeartStage("breathing")) return;
state.heartBreathingEndsAt = Date.now() + HEART_BREATH_TOTAL_MS;
const ember = document.querySelector("#heartIncenseEmber");
heartIncenseAnimation?.cancel();
ember?.classList.remove("is-burning");
if (ember) void ember.offsetWidth;
ember?.classList.add("is-burning");
heartIncenseAnimation = ember?.animate(
[{ top: "0%" }, { top: "100%" }],
{
duration: HEART_BREATH_ACTIVE_MS,
delay: HEART_BREATH_PREPARE_MS,
easing: "linear",
fill: "forwards",
},
) || null;
updateBreathingDisplay();
state.heartTimer = setInterval(() => {
state.heartSeconds = Math.max(0, Math.ceil((state.heartBreathingEndsAt - Date.now()) / 1000));
updateBreathingDisplay();
if (state.heartSeconds <= 0) finishHeartBreathing();
}, 200);
}
function finishHeartBreathing() {
if (state.heartTimer) clearInterval(state.heartTimer);
state.heartTimer = null;
state.heartBreathingEndsAt = 0;
state.heartSeconds = 0;
updateBreathingDisplay();
const button = document.querySelector("#beginCastingButton");
button.disabled = false;
button.classList.add("is-ready");
heartSound.chime(520);
}
function updateBreathingDisplay() {
const remainingMs = state.heartBreathingEndsAt
? Math.max(0, state.heartBreathingEndsAt - Date.now())
: Math.max(0, state.heartSeconds * 1000);
const elapsedMs = HEART_BREATH_TOTAL_MS - remainingMs;
const activeElapsedMs = Math.max(0, elapsedMs - HEART_BREATH_PREPARE_MS);
const cycleElapsedMs = activeElapsedMs % HEART_BREATH_CYCLE_MS;
const breathPhase = elapsedMs < HEART_BREATH_PREPARE_MS
? "prepare"
: cycleElapsedMs < HEART_BREATH_INHALE_MS
? "inhale"
: cycleElapsedMs < HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS
? "hold"
: "exhale";
const phase = state.heartSeconds <= 0 ? "settled" : breathPhase;
const scene = document.querySelector("#breathingScene");
scene.dataset.phase = phase;
setText("breathingPhase", phase === "settled" ? "静" : phase === "prepare" ? "静" : phase === "inhale" ? "吸" : phase === "hold" ? "顿" : "呼");
const prompt = state.heartSeconds <= 0
? "静心已成,可以起卦"
: phase === "prepare"
? "放松片刻,准备呼吸"
: phase === "hold"
? "停驻片刻,让念头自然沉下"
: activeElapsedMs < 18_000
? phase === "inhale" ? "缓慢吸气,放下对答案的预设" : "缓慢呼气,让预设随之松开"
: activeElapsedMs < 36_000
? phase === "inhale" ? "吸气,只留下真正想问的事" : "呼气,不急着寻找答案"
: phase === "inhale" ? "吸气,让心停在此刻" : "呼气,不追逐经过的念头";
setText("breathingPrompt", prompt);
}
async function beginHeartCasting() {
if (state.heartSeconds > 0) return;
state.heartLines = [];
state.heartThrows = [];
state.heartHexagram = null;
state.heartCastAt = "";
state.heavenInterpretations.heart = "";
heartCastingBusy = false;
resetHeartCoins();
await transitionHeartStage("casting");
}
function initializeHeartCoinHold() {
const button = document.querySelector("#tossCoinsButton");
const coins = [...document.querySelectorAll(".heart-coin")];
const cancelHold = (cancelled = true) => {
if (heartHoldTimer) clearTimeout(heartHoldTimer);
heartHoldTimer = null;
cancelAnimationFrame(heartHoldAnimationFrame);
heartHoldAnimationFrame = 0;
button.classList.remove("is-holding");
button.style.setProperty("--hold-progress", "0turn");
coins.forEach((coin) => coin.classList.remove("is-shaking"));
if (cancelled) heartHoldStartedAt = 0;
};
button.addEventListener("pointerdown", (event) => {
if (button.disabled || heartCastingBusy || (event.button !== 0 && event.pointerType !== "touch")) return;
event.preventDefault();
heartSound.ensure();
heartHoldTriggered = false;
heartHoldStartedAt = performance.now();
button.setPointerCapture?.(event.pointerId);
button.classList.add("is-holding");
coins.forEach((coin) => coin.classList.add("is-shaking"));
const charge = () => {
if (!heartHoldStartedAt) return;
const progress = Math.min(1, (performance.now() - heartHoldStartedAt) / 1400);
button.style.setProperty("--hold-progress", `${progress}turn`);
if (progress < 1) heartHoldAnimationFrame = requestAnimationFrame(charge);
};
heartHoldAnimationFrame = requestAnimationFrame(charge);
});
button.addEventListener("pointerup", async () => {
if (!heartHoldStartedAt) return;
const heldFor = performance.now() - heartHoldStartedAt;
heartHoldStartedAt = 0;
cancelHold(false);
heartHoldTriggered = true;
if (heldFor < 550) await waitForMotion(550 - heldFor);
await tossHeartCoins();
});
button.addEventListener("pointercancel", () => cancelHold(true));
button.addEventListener("click", (event) => {
if (heartHoldTriggered) {
heartHoldTriggered = false;
event.preventDefault();
return;
}
if (event.detail === 0 && !heartCastingBusy) tossHeartCoins();
});
}
async function tossHeartCoins() {
if (heartCastingBusy) return;
if (state.heartLines.length >= 6) {
heartCastingBusy = true;
await finalizeHeartHexagram();
return;
}
const stageToken = state.heartStageToken;
if (!state.heartCastAt) state.heartCastAt = new Date().toISOString();
const button = document.querySelector("#tossCoinsButton");
heartCastingBusy = true;
button.disabled = true;
const random = new Uint32Array(3);
crypto.getRandomValues(random);
const coins = [...random].map((value) => value % 2 === 1);
await animateHeartCoins(coins);
if (stageToken !== state.heartStageToken || state.heartStage !== "casting") {
heartCastingBusy = false;
return;
}
const heads = coins.filter(Boolean).length;
const lineValue = 6 + heads;
state.heartLines.push(lineValue);
state.heartThrows.push(coins.map((head) => head ? "正" : "背"));
renderHeartCasting();
if (state.heartLines.length === 6) {
await finalizeHeartHexagram();
} else {
await waitForMotion(720);
heartCastingBusy = false;
button.disabled = false;
}
}
async function animateHeartCoins(results) {
const coinElements = [...document.querySelectorAll(".heart-coin")];
setText("castingPrompt", "铜钱离手");
const animations = coinElements.map((coin, index) => {
coin.getAnimations().forEach((animation) => animation.cancel());
const inner = coin.querySelector(".heart-coin-inner");
inner.getAnimations().forEach((animation) => animation.cancel());
const current = heartCoinRotations[index];
const faceRotation = results[index] ? 0 : 180;
const delta = ((faceRotation - (current % 360)) + 360) % 360;
const target = current + 1440 + index * 360 + delta;
heartCoinRotations[index] = target;
const duration = motionEnabled() ? 1500 + index * 160 : 10;
const delay = motionEnabled() ? index * 150 : 0;
coin.dataset.face = results[index] ? "front" : "back";
const spin = inner.animate(
[{ transform: `rotateY(${current}deg)` }, { transform: `rotateY(${target}deg)` }],
{ duration, delay, easing: "cubic-bezier(.25,.55,.3,1)", fill: "forwards" },
);
const tilt = Math.random() * 10 - 5;
const flight = coin.animate([
{ transform: "translateY(0) rotateZ(0deg)" },
{ transform: `translateY(-30vh) rotateZ(${tilt}deg)`, offset: 0.42 },
{ transform: `translateY(0) rotateZ(${tilt}deg)`, offset: 0.78 },
{ transform: "translateY(-13px) rotateZ(0deg)", offset: 0.9 },
{ transform: "translateY(0) rotateZ(0deg)" },
], { duration, delay, easing: "cubic-bezier(.3,.6,.35,1)", fill: "forwards" });
setTimeout(() => {
const ring = coin.querySelector(".heart-coin-ring");
ring.classList.remove("is-bursting");
void ring.offsetWidth;
ring.classList.add("is-bursting");
heartSound.coin();
}, delay + duration * 0.79);
return Promise.allSettled([spin.finished, flight.finished]);
});
await Promise.all(animations);
setText("castingPrompt", "听其落定");
await waitForMotion(420);
}
async function finalizeHeartHexagram() {
const stageToken = state.heartStageToken;
const button = document.querySelector("#tossCoinsButton");
button.disabled = true;
button.textContent = "正在成卦";
try {
const payload = await apiRequest("/api/heaven/hexagram", "POST", { lines: state.heartLines });
if (stageToken !== state.heartStageToken || state.heartStage !== "casting") return;
state.heartHexagram = payload.hexagram;
updateHeavenInterpretationControls();
document.querySelector(".heart-hexagram-shell")?.classList.add("is-complete");
setText("castingPrompt", "卦成了");
heartSound.chime(660);
await waitForMotion(2200);
if (!await transitionHeartStage("reveal")) return;
await playHeartRevealSequence();
} catch (error) {
showHeavenNotice(error.message || "成卦失败");
button.disabled = false;
button.innerHTML = '按住
重新成卦';
heartCastingBusy = false;
}
}
function renderHeartStage() {
document.querySelectorAll(".heart-stage").forEach((stage) => stage.classList.remove("active-heart-stage"));
const stageMap = {
intro: "heartIntro",
breathing: "heartBreathing",
casting: "heartCasting",
reveal: "heartReveal",
interpretation: "heartInterpretationStage",
};
document.querySelectorAll(".heart-stage").forEach((stage) => stage.classList.remove("is-leaving"));
const activeStage = document.querySelector(`#${stageMap[state.heartStage]}`);
activeStage.classList.add("active-heart-stage");
document.querySelectorAll("[data-heart-step]").forEach((step) => {
step.classList.toggle("active", step.dataset.heartStep === state.heartStage);
});
setHeartLamp(state.heartStage);
activateHeartRises(activeStage);
if (state.heartStage === "breathing") {
document.querySelector("#beginCastingButton").disabled = state.heartSeconds > 0;
updateBreathingDisplay();
}
if (state.heartStage === "casting") renderHeartCasting();
if (state.heartStage === "reveal" && state.heartHexagram) renderHeartReveal();
if (state.heartStage === "interpretation" && state.heartHexagram) renderHeartRead();
}
function renderHeartCasting() {
setText("castingProgress", `${state.heartLines.length} / 6`);
const latestThrow = state.heartThrows[state.heartThrows.length - 1] || ["静", "静", "静"];
document.querySelectorAll(".heart-coin").forEach((coin, index) => {
coin.setAttribute("aria-label", latestThrow[index] === "静" ? `第 ${index + 1} 枚铜钱待掷` : `第 ${index + 1} 枚铜钱${latestThrow[index]}`);
});
const nextPosition = LINE_POSITIONS_CLIENT[state.heartLines.length] || "成卦";
setText(
"castingPrompt",
state.heartLines.length < 6
? `心中默念所问之事,然后掷出${nextPosition}`
: "六爻已具,正在成卦",
);
const button = document.querySelector("#tossCoinsButton");
button.innerHTML = state.heartLines.length < 6
? `按住
摇${nextPosition}`
: '正在
成卦';
button.disabled = heartCastingBusy || state.heartLines.length >= 6;
const rows = [];
for (let index = 5; index >= 0; index -= 1) {
const value = state.heartLines[index];
rows.push(`
${LINE_POSITIONS_CLIENT[index]}
${value ? hexagramLineGraphic(value) : '
'}
${value ? `${lineValueName(value)} · ${value}` : "未得"}
`);
}
document.querySelector("#heartCastingLines").innerHTML = rows.join("");
}
function renderHeartReveal() {
const hexagram = state.heartHexagram;
setText("heartHexagramName", `${hexagram.outer_trigram}上${hexagram.inner_trigram}下 · ${hexagram.name}`);
setText("heartTransformedName", hexagram.transformed.name);
setText("heartHexagramText", hexagram.text);
renderHexagramLines("heartHexagramLines", hexagram.lines, false);
document.querySelector("#heartHexagramLines").querySelectorAll(".hexagram-line-row").forEach((row) => row.classList.add("heart-reveal-line"));
document.querySelector("#heartReveal").classList.remove("is-sequence-ready", "is-title-ready", "is-thought-typing", "is-thought-ready");
const prompt = document.querySelector("#heartFirstThoughtPrompt");
prompt.dataset.fullText = "看见卦象与爻辞后,心里升起的第一念是什么?";
prompt.textContent = "";
const button = document.querySelector("#interpretHeartButton");
button.disabled = true;
button.classList.remove("is-ready");
}
async function playHeartRevealSequence() {
const token = ++state.heartRevealToken;
const stageToken = state.heartStageToken;
const stage = document.querySelector("#heartReveal");
const lines = [...stage.querySelectorAll(".heart-reveal-line")].reverse();
lines.forEach((line) => line.classList.remove("is-revealed"));
if (!await waitForHeartMotion(280, stageToken)) return;
for (const line of lines) {
if (token !== state.heartRevealToken || state.heartStage !== "reveal") return;
line.classList.add("is-revealed");
if (!await waitForHeartMotion(520, stageToken)) return;
}
stage.classList.add("is-title-ready", "is-sequence-ready");
heartSound.chime(520);
if (!await waitForHeartMotion(1200, stageToken)) return;
const prompt = document.querySelector("#heartFirstThoughtPrompt");
stage.classList.add("is-thought-typing");
if (!await typeHeartText(prompt, prompt.dataset.fullText, token, 72)) return;
stage.classList.add("is-thought-ready");
if (!await waitForHeartMotion(2400, stageToken)) return;
const button = document.querySelector("#interpretHeartButton");
button.disabled = false;
button.classList.add("is-ready");
}
function initializeHeartLineInspection() {
const container = document.querySelector("#heartLineTexts");
container.addEventListener("click", (event) => {
const item = event.target.closest(".heart-line-text");
if (!item) return;
const inspected = item.classList.toggle("is-inspected");
item.setAttribute("aria-expanded", String(inspected));
});
}
async function typeHeartText(element, text, token, speed = 72) {
if (!element) return false;
if (!motionEnabled()) {
element.textContent = text;
return true;
}
element.textContent = "";
element.classList.add("heart-typing");
for (const character of text) {
if (token !== state.heartRevealToken || state.heartStage !== "reveal") return false;
element.append(document.createTextNode(character));
await new Promise((resolve) => setTimeout(resolve, speed));
}
element.classList.remove("heart-typing");
return true;
}
function renderHeartRead() {
const hexagram = state.heartHexagram;
setText("heartReadTitle", hexagram.name);
setText("heartReadChange", hexagram.transformed.name === hexagram.name ? "六爻安静,无之卦" : `之卦 · ${hexagram.transformed.name}`);
setText("heartReadGuaci", hexagram.text);
document.querySelector("#heartReadLines").innerHTML = [...hexagram.lines].reverse().map((line) => `
${escapeHtml(line.position_name)}${hexagramLineGraphic(line.value)}
`).join("");
document.querySelector("#heartReadTexts").innerHTML = hexagram.lines.map((line) => `
${escapeHtml(line.line_name)}${line.moving ? " · 动" : ""}${escapeHtml(line.text)}
`).join("");
renderHeavenInterpretation("heart", state.heavenInterpretations.heart);
const stage = document.querySelector("#heartInterpretationStage");
stage.classList.remove("is-read-heading-ready", "is-read-complete");
}
async function playHeartReadSequence() {
const token = state.heartStageToken;
const stage = document.querySelector("#heartInterpretationStage");
if (!await waitForHeartMotion(420, token)) return;
stage.classList.add("is-read-heading-ready");
const lines = [...stage.querySelectorAll(".heart-read-line")].reverse();
const texts = [...stage.querySelectorAll(".heart-read-text")];
for (let index = 0; index < 6; index += 1) {
lines[index]?.classList.add("is-visible");
texts[index]?.classList.add("is-visible");
if (!await waitForHeartMotion(680, token)) return;
}
stage.classList.add("is-read-complete");
}
function resetHeartCoins() {
heartCoinRotations.fill(0);
document.querySelectorAll(".heart-coin").forEach((coin) => {
coin.getAnimations().forEach((animation) => animation.cancel());
const inner = coin.querySelector(".heart-coin-inner");
inner.getAnimations().forEach((animation) => animation.cancel());
inner.style.transform = "";
coin.style.transform = "";
coin.dataset.face = "";
coin.querySelector(".heart-coin-ring").classList.remove("is-bursting");
});
const shell = document.querySelector(".heart-hexagram-shell");
shell?.classList.remove("is-complete");
}
async function resetHeartRitual() {
if (state.heartTimer) clearInterval(state.heartTimer);
state.heartTimer = null;
state.heartSeconds = HEART_BREATH_TOTAL_MS / 1000;
state.heartBreathingEndsAt = 0;
state.heartLines = [];
state.heartThrows = [];
state.heartHexagram = null;
state.heartCastAt = "";
state.heavenInterpretations.heart = "";
heartIncenseAnimation?.cancel();
heartIncenseAnimation = null;
document.querySelector("#heartIncenseEmber")?.classList.remove("is-burning");
updateHeavenInterpretationControls();
state.heartRevealToken += 1;
heartCastingBusy = false;
resetHeartCoins();
hideHeavenNotice();
setHeartQuestionLocked(false);
syncHeartQuestion();
await transitionHeartStage("intro");
}
function applyHeartQuestionPreset(preset) {
if (!(preset in HEART_QUESTION_PRESETS) || state.heartStage !== "intro") return;
const input = document.querySelector("#heartQuestionInput");
state.heartQuestionPreset = preset;
input.value = HEART_QUESTION_PRESETS[preset];
syncHeartQuestion();
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.heartQuestionPreset === preset));
});
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
function syncHeartQuestion() {
const input = document.querySelector("#heartQuestionInput");
if (!input) return;
state.heartQuestion = input.value.trim();
const matchedPreset = Object.entries(HEART_QUESTION_PRESETS)
.find(([, question]) => question === state.heartQuestion)?.[0];
state.heartQuestionPreset = matchedPreset || "custom";
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.heartQuestionPreset === matchedPreset));
});
document.querySelector("#startBreathingButton").disabled = !state.heartQuestion;
}
function setHeartQuestionLocked(locked) {
const input = document.querySelector("#heartQuestionInput");
if (input) input.disabled = locked;
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
button.disabled = locked;
});
}
function showHeavenNotice(message) {
const notice = document.querySelector("#heavenNotice");
notice.textContent = message;
notice.hidden = false;
}
function hideHeavenNotice() {
document.querySelector("#heavenNotice").hidden = true;
}
function phaseClass(element) {
return { 木: "wood", 火: "fire", 土: "earth", 金: "metal", 水: "water" }[element] || "earth";
}
function phaseIcon(element) {
return { 木: "sprout", 火: "flame", 土: "mountain", 金: "gem", 水: "waves" }[element] || "circle-dot";
}
function signedScore(value) {
const parsed = number(value);
return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`;
}
function lineValueName(value) {
return { 6: "老阴", 7: "少阳", 8: "少阴", 9: "老阳" }[value] || "";
}
function capitalize(value) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
const LINE_POSITIONS_CLIENT = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"];
function bindHeavenEvents() {
document.querySelectorAll("[data-heaven-panel]").forEach((button) => {
button.addEventListener("click", () => selectHeavenPanel(button.dataset.heavenPanel, true));
});
document.querySelector("#loadHeavenSelectionButton").addEventListener("click", loadHeavenSelection);
document.querySelector("#heavenCalibrationForm").addEventListener("submit", applyHeavenCalibration);
document.querySelector("#resetHeavenCalibrationButton").addEventListener("click", resetHeavenCalibration);
document.querySelector("#heavenStockInput").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
loadHeavenSelection();
}
});
document.querySelector("#interpretTrendButton").addEventListener("click", () => interpretHeaven("trend"));
document.querySelector("#interpretFortuneButton").addEventListener("click", () => interpretHeaven("fortune"));
document.querySelector("#historyTrendButton").addEventListener("click", () => openHeavenHistory("trend"));
document.querySelector("#historyFortuneButton").addEventListener("click", () => openHeavenHistory("fortune"));
document.querySelector("#qiObservationDate").addEventListener("change", () => {
state.personalField = null;
state.heavenManualData = null;
state.heavenInterpretations.fortune = "";
loadHeavenSetup(
true,
"",
document.querySelector("#heavenStockInput").value.trim(),
);
});
document.querySelector("#openPersonalSettingsButton").addEventListener("click", () => openSettings("profile"));
document.querySelector("#sectorPhaseForm").addEventListener("submit", saveSectorPhaseOverride);
document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing);
document.querySelector("#heartQuestionInput").addEventListener("input", syncHeartQuestion);
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
button.addEventListener("click", () => applyHeartQuestionPreset(button.dataset.heartQuestionPreset));
});
document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting);
document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound);
document.querySelector("#historyHeartButton").addEventListener("click", () => openHeavenHistory("heart"));
initializeHeartCoinHold();
initializeHeartLineInspection();
document.querySelector("#interpretHeartButton").addEventListener("click", () => interpretHeaven("heart"));
document.querySelector("#viewHeartReadingButton").addEventListener("click", () => openHeavenReading("heart"));
document.querySelector("#restartHeartButton").addEventListener("click", resetHeartRitual);
document.querySelector("#closeHeavenReadingDialog").addEventListener("click", () => elements.heavenReadingDialog.close());
elements.heavenReadingDialog.addEventListener("close", stopHeavenReadingAnimation);
document.querySelectorAll("[data-heaven-reading-tab]").forEach((button) => {
button.addEventListener("click", () => selectHeavenReadingTab(button.dataset.heavenReadingTab));
});
document.querySelector("#heavenReadingHistoryList").addEventListener("click", handleHeavenHistorySelection);
document.querySelector("#heavenReadingHistoryDetail").addEventListener("click", handleHeavenHistoryAction);
document.querySelectorAll("[data-heart-return]").forEach((button) => {
button.addEventListener("click", resetHeartRitual);
});
syncHeartQuestion();
}