Files
xiaobaifupan/app/frontend/pages/heaven/page.js
T

1966 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
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 `<i style="left:${(Math.random() * 100).toFixed(1)}%;top:${(Math.random() * 100).toFixed(1)}%;width:${size}px;height:${size}px;animation-delay:${(Math.random() * 4).toFixed(1)}s;animation-duration:${(3 + Math.random() * 4).toFixed(1)}s"></i>`;
}).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 += `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" fill="#c9a55c" font-size="17" text-anchor="middle" dominant-baseline="middle" transform="rotate(${index * 45} ${x.toFixed(1)} ${y.toFixed(1)})">${trigrams[index]}</text>`;
}
for (let index = 0; index < 24; index += 1) {
const angle = (index * 15 - 90) * Math.PI / 180;
ticks += `<line x1="${(150 + 100 * Math.cos(angle)).toFixed(1)}" y1="${(150 + 100 * Math.sin(angle)).toFixed(1)}" x2="${(150 + 108 * Math.cos(angle)).toFixed(1)}" y2="${(150 + 108 * Math.sin(angle)).toFixed(1)}" stroke="#c9a55c" stroke-width="1"/>`;
}
svg.innerHTML = `<g class="ring"><circle cx="150" cy="150" r="146" fill="none" stroke="#c9a55c" stroke-width="1" stroke-dasharray="2 6"/><circle cx="150" cy="150" r="112" fill="none" stroke="#c9a55c" stroke-width="1" stroke-dasharray="10 5"/>${characters}</g><g class="ring2">${ticks}</g>`;
}
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 `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" fill="#c9a55c" font-size="${fontSize}" text-anchor="middle" dominant-baseline="middle" transform="rotate(${degrees + 90} ${x.toFixed(1)} ${y.toFixed(1)})">${label}</text>`;
}).join("");
const ticks = Array.from({ length: 30 }, (_, index) => {
const angle = (index * 12 - 90) * Math.PI / 180;
const inner = index % 5 === 0 ? 96 : 101;
return `<line x1="${(150 + inner * Math.cos(angle)).toFixed(1)}" y1="${(150 + inner * Math.sin(angle)).toFixed(1)}" x2="${(150 + 108 * Math.cos(angle)).toFixed(1)}" y2="${(150 + 108 * Math.sin(angle)).toFixed(1)}" stroke="#c9a55c" stroke-width="${index % 5 === 0 ? 1.4 : 0.7}"/>`;
}).join("");
svg.innerHTML = `<g class="ring"><circle cx="150" cy="150" r="146" fill="none" stroke="#c9a55c" stroke-width="1" stroke-dasharray="2 6"/><circle cx="150" cy="150" r="116" fill="none" stroke="#c9a55c" stroke-width="1" stroke-dasharray="10 5"/>${polarText(sixQi, 132, 8.5)}</g><g class="ring2"><circle cx="150" cy="150" r="88" fill="none" stroke="#c9a55c" stroke-width="1" stroke-dasharray="4 5"/>${ticks}${polarText(movements, 70, 10)}<text x="150" y="150" fill="#c9a55c" font-size="11" text-anchor="middle" dominant-baseline="middle">五运六气</text></g>`;
}
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 `<div class="compact-hex-line ${value % 2 ? "yang" : "yin"} ${moving ? "moving" : ""}"><i></i>${value % 2 ? "" : "<i></i>"}</div>`;
}).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 });
if (!await heavenPerformanceDelay(900, token)) return false;
panel.classList.add("performance-climate-ready");
if (!await heavenPerformanceDelay(720, token)) return false;
if (!await typeHeavenText(climateTone, climateText, token, 58)) 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;
}
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(820, token)) return false;
panel.querySelector(".personal-fortune-panel")?.classList.add("is-ready");
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"
? `<select ${common}>${(field.options || []).map((option) => `<option value="${escapeHtml(option)}" ${option === rawValue ? "selected" : ""}>${escapeHtml(option)}</option>`).join("")}</select>`
: field.type === "text"
? `<input type="text" maxlength="50" value="${escapeHtml(rawValue)}" placeholder="待补充" ${common}>`
: `<input type="number" inputmode="decimal" value="${escapeHtml(rawValue)}" placeholder="待补充" min="${field.min ?? ""}" max="${field.max ?? ""}" step="${field.integer ? "1" : "any"}" ${common}>`;
return `<label class="heaven-manual-field ${field.manual ? "is-manual" : ""}">
<span>${escapeHtml(field.label)}<small>${escapeHtml(source)}</small></span>
<span class="heaven-field-control">${control}${field.unit ? `<b>${escapeHtml(field.unit)}</b>` : ""}</span>
</label>`;
}).join("");
const reasons = (check.reasons || []).map((reason) => `<li>${escapeHtml(reason)}</li>`).join("");
return `<details class="heaven-line-check is-${escapeHtml(check.status)}" ${check.passed ? "" : "open"}>
<summary>
<span class="heaven-check-state"><i class="status-dot ${escapeHtml(check.status)}"></i><b>${escapeHtml(stateLabel)}</b></span>
<span class="heaven-check-name"><strong>${escapeHtml(check.position)} · ${escapeHtml(check.layer)}</strong><small>${escapeHtml(check.formula)}</small></span>
<span class="heaven-check-result"><b>${check.line_value ? escapeHtml(lineValueLabel[check.line_value] || check.line_value) : "待定"}</b><small>得分 ${escapeHtml(score)}</small></span>
<i data-lucide="chevron-down" aria-hidden="true"></i>
</summary>
<div class="heaven-line-check-body">
${reasons ? `<ul class="heaven-check-reasons">${reasons}</ul>` : `<p class="heaven-check-evidence">${(check.evidence || []).map(escapeHtml).join("") || "数据已通过安全门"}</p>`}
<div class="heaven-manual-fields">${fields}</div>
</div>
</details>`;
}).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) => `
<div>
<strong>${escapeHtml(item.lines)} · ${escapeHtml(item.layer)}</strong>
<span>${escapeHtml(heavenSourcePhrase(item))}</span>
<small>${escapeHtml(item.detail || "")}</small>
</div>
`).join("")
: '<p>暂无可核验的数据来源。</p>';
};
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) => `
<div class="talent-reading"><strong>未通过</strong><span>${escapeHtml(issue)}</span></div>
`),
...sourceRows.map((item) => `
<div class="talent-reading">
<strong>${escapeHtml(item.lines)} · ${escapeHtml(item.layer)}</strong>
<span>${escapeHtml(heavenSourcePhrase(item))}</span>
<small>${escapeHtml(item.detail || "")}</small>
</div>
`),
].join("");
document.querySelector("#heavenIndexStrip").innerHTML = "<p>天象尚未应时,待三才数据齐备后再观。</p>";
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) => `
<div class="talent-reading">
<strong>${escapeHtml(item.level)}</strong><span>${escapeHtml(item.state)}</span>
<div class="talent-balance">
<small>内 ${signedScore(item.inner)}</small><i><b style="--talent-value:${clamp((number(item.inner) + 1) * 50, 0, 100)}%"></b></i>
<small>外 ${signedScore(item.outer)}</small><i><b style="--talent-value:${clamp((number(item.outer) + 1) * 50, 0, 100)}%"></b></i>
</div>
</div>
`).join("");
const indexContext = chart.index_context || {};
document.querySelector("#heavenIndexStrip").innerHTML = (indexContext.indices || []).length
? indexContext.indices.map((item) => `
<div><span>${escapeHtml(item.name)}</span><strong class="${changeClass(item.pct_chg)}">${signed(item.pct_chg)}%</strong><small>5日 ${signed(item.return_5d)}%</small></div>
`).join("")
: `<p>${escapeHtml(indexContext.notice || "指数数据暂不可用")}</p>`;
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) => `
<section class="talent-line-group" style="--group-delay:${groupIndex * 70}ms">
<div class="talent-seal" aria-hidden="true">${group.talent}</div>
<div class="talent-line-content">
<p>${group.caption}</p>
${group.lines.map((line) => `
<div class="hexagram-line-row ${line.moving ? "moving" : ""}">
<span class="hexagram-position">${escapeHtml(line.position_name)}</span>
${hexagramLineGraphic(line.value)}
<div class="hexagram-line-detail">
<strong>${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""}</strong>
<small>${(line.evidence || []).map(escapeHtml).join("")}</small>
</div>
</div>
`).join("")}
</div>
</section>
`).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]) => `
<div class="fortune-metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong><small>${escapeHtml(detail)}</small></div>
`).join("");
const framework = field.framework || {};
setText("qiFrameworkPrinciple", framework.principle || "--");
const layerLabels = { year: "年运与岁气", current: "客主加临", day: "日辰触发" };
document.querySelector("#qiFrameworkLayers").innerHTML = (framework.layers || []).map((layer) => `
<div class="qi-framework-layer" data-qi-layer="${escapeHtml(layer.id)}">
<span>${escapeHtml(layerLabels[layer.id] || layer.label)}</span>
<strong>${escapeHtml(layer.dominant)}气</strong>
<small>${escapeHtml(layer.summary)}</small>
<div>${(layer.balance || []).map((item) => `<i class="phase-${phaseClass(item.element)}" style="--qi-segment:${number(item.percent)}%" title="${escapeHtml(item.element)}"></i>`).join("")}</div>
</div>
`).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) => `
<div class="phase-balance-row" data-phase-element="${escapeHtml(item.element)}" data-phase-percent="${number(item.percent)}" tabindex="0">
<strong class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</strong>
<div><div class="phase-track"><span class="phase-${phaseClass(item.element)}" style="--phase-width:${number(item.percent)}%"></span></div><small>${escapeHtml(item.motion)} · ${escapeHtml(item.mind)}</small></div>
<b>${number(item.percent)}%</b>
</div>
`).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) => `
<section class="fortune-sector-group">
<header><i class="phase-${phaseClass(group.element)}"></i><strong class="phase-text-${phaseClass(group.element)}">${escapeHtml(group.element)}属性</strong><small>${number(group.count || group.industries?.length)} 类</small></header>
<ul>${(group.industries || []).map((item) => `<li>${escapeHtml(item.name)}</li>`).join("")}</ul>
</section>
`).join("") || '<p class="personal-profile-empty">行业五行归类尚未建立</p>';
}
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) => `
<div class="qi-use-source ${catalogElements.has(item.element) ? "has-catalog" : "no-catalog"}" data-qi-source="${escapeHtml(item.element)}">
<strong class="phase-text-${phaseClass(item.element)}">${escapeHtml(item.element)}</strong>
<span><b>${escapeHtml(item.motion)}</b><small>${number(item.percent)}%</small></span>
</div>
`).join("");
sectorContainer.innerHTML = catalog.length ? catalog.map((group) => {
const element = group.element;
const items = group.industries || [];
return `
<details class="qi-sector-group" data-qi-sector="${escapeHtml(element)}">
<summary>
<span class="qi-sector-group-title"><i class="phase-${phaseClass(element)}"></i><strong>${escapeHtml(element)}属性</strong></span>
<span class="qi-sector-group-count">${number(group.count)} 类</span>
<i class="qi-sector-chevron" data-lucide="chevron-down" aria-hidden="true"></i>
</summary>
<div class="qi-sector-fold">
<ul class="qi-sector-tags" aria-label="${escapeHtml(element)}属性行业">
${items.map((item) => `<li class="phase-border-${phaseClass(element)}">${escapeHtml(item.name)}${item.classification_source === "manual" ? '<small>手动</small>' : ""}</li>`).join("")}
</ul>
</div>
</details>
`;
}).join("") : '<p class="qi-use-empty">行业五行归类尚未建立。</p>';
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) => `
<div class="sector-phase-override-row">
<span class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</span>
<strong>${escapeHtml(item.name)}</strong>
${canManage ? `<button class="icon-button" type="button" data-sector-phase-delete="${escapeHtml(item.name)}" title="删除手动归类" aria-label="删除 ${escapeHtml(item.name)} 的手动归类"><i data-lucide="trash-2"></i></button>` : ""}
</div>
`).join("") : '<p class="sector-phase-empty">暂无手动归类</p>';
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) => `<em>${escapeHtml(item)}</em>`).join("") || "--";
container.innerHTML = `
<div class="personal-primary-grid">
<div class="personal-day-master">
<span>日主</span>
<strong class="personal-day-master-character phase-text-${phaseClass(personal.day_master?.element)}">${escapeHtml(personal.day_master?.stem || "--")}</strong>
<b class="personal-day-master-element phase-text-${phaseClass(personal.day_master?.element)}">${escapeHtml(personal.day_master?.element || "--")}</b>
<small>${escapeHtml(personal.day_master?.strength || "")}</small>
</div>
<div class="personal-preferences">
<section><span>十神喜恶</span><div class="personal-preference-line"><strong>偏宜</strong><p>${preferenceTags(tenGods.favorable)}</p></div><div class="personal-preference-line"><strong>偏慎</strong><p>${preferenceTags(tenGods.caution)}</p></div></section>
<section><span>五行喜忌</span><div class="personal-preference-line"><strong>偏喜</strong><p>${preferenceTags(elementTendency.favorable)}</p></div><div class="personal-preference-line"><strong>偏忌</strong><p>${preferenceTags(elementTendency.caution)}</p></div></section>
</div>
</div>`;
}
function renderHexagramLines(containerId, lines, includeEvidence = false) {
const container = document.querySelector(`#${containerId}`);
container.innerHTML = [...lines].reverse().map((line) => `
<div class="hexagram-line-row ${line.moving ? "moving" : ""}">
<span class="hexagram-position">${escapeHtml(line.position_name)}</span>
${hexagramLineGraphic(line.value)}
<div class="hexagram-line-detail">
<strong>${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""}</strong>
${includeEvidence ? `<small>${(line.evidence || []).map(escapeHtml).join("")}</small>` : `<small>${escapeHtml(line.text || "")}</small>`}
</div>
</div>
`).join("");
}
function hexagramLineGraphic(value) {
const yang = value % 2 === 1;
return `
<span class="hex-line ${yang ? "yang-line" : "yin-line"}">
<i></i>${yang ? "" : "<i></i>"}${[6, 9].includes(value) ? `<b>${value === 9 ? "○" : "×"}</b>` : ""}
</span>
`;
}
const HEAVEN_READING_META = {
trend: { panel: "观势", action: "解势", done: "查看解势", status: "势已成" },
fortune: { panel: "观气", action: "解运", done: "已解运", status: "气已定" },
heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解" },
};
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 = formatMentorAnswer(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) => `
<button class="heaven-reading-history-item ${number(item.id) === state.heavenReadingSelectedId ? "active" : ""}" type="button" data-heaven-reading-id="${number(item.id)}">
<span>${escapeHtml(item.subject)}</span>
<small>${escapeHtml(item.subject_detail || displayCompactDate(item.context_date))}</small>
<time>${formatTimestamp(item.created_at)}</time>
</button>
`).join("") || emptyStateHtml("暂无历史解读");
const selected = items.find((item) => number(item.id) === state.heavenReadingSelectedId);
const detail = document.querySelector("#heavenReadingHistoryDetail");
detail.innerHTML = selected ? `
<header><div><span>${escapeHtml(heavenReadingMeta(mode).status)}</span><h3>${escapeHtml(selected.subject)}</h3></div><time>${formatTimestamp(selected.created_at)}</time></header>
<p>${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}</p>
<div class="heaven-reading-answer">${formatMentorAnswer(selected.answer || "")}</div>
<footer><button class="button" type="button" data-delete-heaven-reading="${number(selected.id)}"><i data-lucide="trash-2"></i><span>删除记录</span></button></footer>
` : 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.lines = state.heartLines;
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]) => `
<span style="--whisper-x:${x}%;--whisper-y:${y}%;--whisper-duration:${16 + index * 1.4}s;--whisper-delay:${index * -2.1}s">${escapeHtml(text)}</span>
`).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 = `<i data-lucide="${heartSound.enabled ? "volume-2" : "volume-x"}"></i><span>${heartSound.enabled ? "有声" : "静音"}</span>`;
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() {
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.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;
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 = '<i class="heart-hold-charge" aria-hidden="true"></i><span>按住<br>重新成卦</span>';
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
? `<i aria-hidden="true"></i><span>按住<br>摇${nextPosition}</span>`
: '<i aria-hidden="true"></i><span>正在<br>成卦</span>';
button.disabled = heartCastingBusy || state.heartLines.length >= 6;
const rows = [];
for (let index = 5; index >= 0; index -= 1) {
const value = state.heartLines[index];
rows.push(`
<div class="hexagram-line-row ${[6, 9].includes(value) ? "moving" : ""} ${value ? "" : "empty-line"} ${value && index === state.heartLines.length - 1 ? "new-line" : ""}">
<span class="hexagram-position">${LINE_POSITIONS_CLIENT[index]}</span>
${value ? hexagramLineGraphic(value) : '<span class="hex-line heart-yao-empty" aria-hidden="true"></span>'}
<div class="hexagram-line-detail"><strong>${value ? `${lineValueName(value)} · ${value}` : "未得"}</strong></div>
</div>
`);
}
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) => `
<div class="heart-read-line ${line.moving ? "moving" : ""}">
<span>${escapeHtml(line.position_name)}</span>${hexagramLineGraphic(line.value)}
</div>
`).join("");
document.querySelector("#heartReadTexts").innerHTML = hexagram.lines.map((line) => `
<article class="heart-read-text ${line.moving ? "moving" : ""}">
<strong>${escapeHtml(line.line_name)}${line.moving ? " · 动" : ""}</strong><p>${escapeHtml(line.text)}</p>
</article>
`).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.heavenInterpretations.heart = "";
heartIncenseAnimation?.cancel();
heartIncenseAnimation = null;
document.querySelector("#heartIncenseEmber")?.classList.remove("is-burning");
updateHeavenInterpretationControls();
state.heartRevealToken += 1;
heartCastingBusy = false;
resetHeartCoins();
hideHeavenNotice();
await transitionHeartStage("intro");
}
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 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("#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);
});
}