1831 lines
84 KiB
JavaScript
1831 lines
84 KiB
JavaScript
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
|
||
enter: ["loadHeaven"],
|
||
leave: ["stopHeaven"],
|
||
});
|
||
|
||
/* PRESERVATION-SOURCE-BEGIN app.js:4828-6667 */
|
||
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 = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"];
|
||
|
||
/* PRESERVATION-SOURCE-END app.js:4828-6667 */
|