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 ``; }).join(""); } function buildWentianBagua(svg) { if (!svg || svg.children.length) return; const trigrams = ["乾", "兑", "离", "震", "巽", "坎", "艮", "坤"]; let characters = ""; let ticks = ""; for (let index = 0; index < 8; index += 1) { const angle = (index * 45 - 90) * Math.PI / 180; const x = 150 + 129 * Math.cos(angle); const y = 150 + 129 * Math.sin(angle); characters += `${trigrams[index]}`; } for (let index = 0; index < 24; index += 1) { const angle = (index * 15 - 90) * Math.PI / 180; ticks += ``; } svg.innerHTML = `${characters}${ticks}`; } function buildWentianFortuneOrbit(svg) { if (!svg || svg.children.length) return; const sixQi = ["厥阴木", "少阴火", "少阳火", "太阴土", "阳明金", "太阳水"]; const movements = ["木运", "火运", "土运", "金运", "水运"]; const polarText = (items, radius, fontSize, offset = -90) => items.map((label, index) => { const degrees = offset + index * 360 / items.length; const angle = degrees * Math.PI / 180; const x = 150 + radius * Math.cos(angle); const y = 150 + radius * Math.sin(angle); return `${label}`; }).join(""); const ticks = Array.from({ length: 30 }, (_, index) => { const angle = (index * 12 - 90) * Math.PI / 180; const inner = index % 5 === 0 ? 96 : 101; return ``; }).join(""); svg.innerHTML = `${polarText(sixQi, 132, 8.5)}${ticks}${polarText(movements, 70, 10)}五运六气`; } function initializeWentianV2Atmosphere() { buildWentianStars("stars", 90); buildWentianStars("fortuneStars", 100); buildWentianStars("heartStars", 110); buildWentianBagua(document.querySelector("#baguaSvg")); buildWentianFortuneOrbit(document.querySelector("#fortuneBagua")); buildWentianBagua(document.querySelector("#heartBagua")); } function renderCompactHexagrams(hexagram) { const original = document.querySelector("#heavenOriginalHexLines"); const changed = document.querySelector("#heavenChangedHexLines"); if (!original || !changed) return; if (!hexagram?.lines?.length) { original.innerHTML = ""; changed.innerHTML = ""; setText("heavenOriginalHexName", "待定"); setText("heavenChangedHexName", "待定"); setText("heavenOriginalHexDetail", "六爻尚未齐备"); setText("heavenChangedHexDetail", "待动爻化变"); return; } const values = hexagram.lines.map((line) => number(line.value)); const changedValues = values.map((value) => value === 6 ? 7 : value === 9 ? 8 : value); const lines = (items, showMoving) => [...items].reverse().map((value) => { const moving = showMoving && [6, 9].includes(value); return `
${value % 2 ? "" : ""}
`; }).join(""); original.innerHTML = lines(values, true); changed.innerHTML = lines(changedValues, false); setText("heavenOriginalHexName", hexagram.name || "--"); setText("heavenChangedHexName", hexagram.transformed?.name || "--"); setText("heavenOriginalHexDetail", `${hexagram.outer_trigram || "--"}上 · ${hexagram.inner_trigram || "--"}下`); setText("heavenChangedHexDetail", `${hexagram.transformed?.outer_trigram || "--"}上 · ${hexagram.transformed?.inner_trigram || "--"}下`); } function cancelHeavenPerformance() { heavenPerformanceToken += 1; state.heavenPerformanceActive = ""; document.querySelectorAll("#heavenTrendPanel, #heavenFortunePanel").forEach((panel) => { panel.classList.remove("heaven-performance-pending", "heaven-performance-running"); panel.classList.add("heaven-performance-complete"); }); } function queueHeavenPerformance(panel) { if (!state.heavenSetup || !["trend", "fortune"].includes(panel)) return; const performanceId = `${state.heavenPerformanceKey}:${panel}`; if ( state.heavenPerformancePanels.has(panel) || state.heavenPerformanceActive === performanceId || state.heavenPanel !== panel ) return; const token = ++heavenPerformanceToken; state.heavenPerformanceActive = performanceId; const runner = panel === "trend" ? playTrendPerformance(state.heavenSetup.chart, token) : playFortunePerformance(state.heavenSetup.field, token); runner.then((completed) => { if (!completed || token !== heavenPerformanceToken) return; state.heavenPerformancePanels.add(panel); state.heavenPerformanceActive = ""; }); } function heavenPerformanceDelay(duration, token) { return new Promise((resolve) => { setTimeout(() => resolve(token === heavenPerformanceToken), motionEnabled() ? duration : 0); }); } async function typeHeavenText(element, text, token, speed = 38) { if (!element) return false; if (!motionEnabled()) { element.textContent = text; return token === heavenPerformanceToken; } element.textContent = ""; element.classList.add("heaven-typing"); for (const character of text) { if (token !== heavenPerformanceToken) return false; element.append(document.createTextNode(character)); if (!await heavenPerformanceDelay(speed, token)) return false; } element.classList.remove("heaven-typing"); return true; } function countHeavenNumber(element, target, token, duration = 1300, suffix = "") { return new Promise((resolve) => { if (!element || !motionEnabled()) { if (element) element.textContent = `${target > 0 ? "+" : ""}${target}${suffix}`; resolve(token === heavenPerformanceToken); return; } const startedAt = performance.now(); const step = (now) => { if (token !== heavenPerformanceToken) { resolve(false); return; } const progress = Math.min(1, (now - startedAt) / duration); const eased = 1 - (1 - progress) ** 3; const value = Math.round(target * eased); element.textContent = `${value > 0 ? "+" : ""}${value}${suffix}`; if (progress < 1) requestAnimationFrame(step); else resolve(true); }; requestAnimationFrame(step); }); } async function playTrendPerformance(chart, token) { const panel = document.querySelector("#heavenTrendPanel"); if (!panel || state.heavenPanel !== "trend") return false; panel.classList.remove( "heaven-performance-complete", "performance-title-ready", "performance-change-ready", "performance-score-ready", "performance-text-ready", ); panel.classList.add("heaven-performance-pending", "heaven-performance-running"); panel.querySelectorAll(".talent-line-group, .hexagram-line-row, .talent-reading, .heaven-index-strip > *").forEach((item) => { item.classList.remove("is-ready"); }); if (!chart?.available) { panel.classList.remove("heaven-performance-pending", "heaven-performance-running"); panel.classList.add("heaven-performance-complete"); return true; } const guaci = chart.hexagram.text || ""; const scoreElement = document.querySelector("#heavenMomentumScore"); const guaciElement = document.querySelector("#marketHexagramText"); if (scoreElement) scoreElement.textContent = "0"; if (guaciElement) guaciElement.textContent = ""; if (!await heavenPerformanceDelay(220, token)) return false; const groups = [...panel.querySelectorAll(".talent-line-group")].reverse(); const readings = [...panel.querySelectorAll(".talent-reading")]; for (let index = 0; index < groups.length; index += 1) { const group = groups[index]; group.classList.add("is-ready"); if (!await heavenPerformanceDelay(280, token)) return false; const rows = [...group.querySelectorAll(".hexagram-line-row")].reverse(); for (const row of rows) { row.classList.add("is-ready"); if (!await heavenPerformanceDelay(560, token)) return false; } readings[index]?.classList.add("is-ready"); if (!await heavenPerformanceDelay(220, token)) return false; } panel.classList.add("performance-title-ready"); if (!await heavenPerformanceDelay(650, token)) return false; panel.classList.add("performance-change-ready"); if (!await heavenPerformanceDelay(420, token)) return false; panel.classList.add("performance-score-ready"); if (!await countHeavenNumber(scoreElement, number(chart.momentum_score), token)) return false; panel.querySelectorAll(".heaven-index-strip > *").forEach((item, index) => { setTimeout(() => { if (token === heavenPerformanceToken) item.classList.add("is-ready"); }, motionEnabled() ? index * 90 : 0); }); if (!await heavenPerformanceDelay(620, token)) return false; if (!await typeHeavenText(guaciElement, guaci, token, 30)) return false; panel.classList.add("performance-text-ready"); panel.classList.remove("heaven-performance-pending", "heaven-performance-running"); panel.classList.add("heaven-performance-complete"); return true; } async function playFortunePerformance(field, token) { const panel = document.querySelector("#heavenFortunePanel"); if (!panel || !field || state.heavenPanel !== "fortune") return false; panel.classList.remove("heaven-performance-complete", "performance-climate-ready", "performance-use-ready"); panel.classList.add("heaven-performance-pending", "heaven-performance-running"); panel.querySelectorAll(".phase-balance-row, .qi-framework-layer, .human-field-grid > div, .personal-fortune-panel").forEach((item) => { item.classList.remove("is-ready"); }); const climateTone = document.querySelector("#qiClimateTone"); const climateText = climateTone?.textContent || ""; if (climateTone) climateTone.textContent = ""; renderQiFieldCanvas(field.balance || [], { intro: true }); 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" ? `` : field.type === "text" ? `` : ``; return ``; }).join(""); const reasons = (check.reasons || []).map((reason) => `
  • ${escapeHtml(reason)}
  • `).join(""); return `
    ${escapeHtml(stateLabel)} ${escapeHtml(check.position)} · ${escapeHtml(check.layer)}${escapeHtml(check.formula)} ${check.line_value ? escapeHtml(lineValueLabel[check.line_value] || check.line_value) : "待定"}得分 ${escapeHtml(score)}
    ${reasons ? `
      ${reasons}
    ` : `

    ${(check.evidence || []).map(escapeHtml).join(";") || "数据已通过安全门"}

    `}
    ${fields}
    `; }).join(""); document.querySelector("#heavenCalibrationNote").value = chart.manual_data?.note || ""; window.lucide?.createIcons(); } function renderMarketHexagram(chart) { const stockInput = document.querySelector("#heavenStockInput"); if (document.activeElement !== stockInput) stockInput.value = chart.stock.code || ""; const selectionRequired = Boolean(chart.selection_required); const emptyState = document.querySelector("#heavenTrendEmpty"); const trendLayout = document.querySelector("#heavenTrendPanel .heaven-trend-layout"); const calibrationPanel = document.querySelector("#heavenCalibrationPanel"); const stockIdentity = document.querySelector("#heavenStockIdentity"); if (emptyState) emptyState.hidden = !selectionRequired; if (trendLayout) trendLayout.hidden = selectionRequired; if (calibrationPanel) calibrationPanel.hidden = selectionRequired; if (stockIdentity) stockIdentity.hidden = selectionRequired; if (selectionRequired) { document.querySelector("#interpretTrendButton").disabled = true; setText("heavenStockName", "--"); setText("heavenStockSector", "--"); renderHeavenInterpretation("trend", ""); return; } setText("heavenStockName", chart.stock.name || "--"); setText( "heavenStockSector", chart.sector || "--", ); setText("heavenStockTaxonomy", chart.sector_taxonomy === "sw_l2" ? "申万二级 ·" : "所属行业 ·"); renderHeavenLineChecks(chart); const interpretButton = document.querySelector("#interpretTrendButton"); const scoreMeter = document.querySelector(".trend-score-meter"); const scoreNeedle = document.querySelector("#heavenMomentumNeedle"); const renderTrendEvidence = () => { const rows = chart.quality?.sources || []; document.querySelector("#heavenTrendEvidence").innerHTML = rows.length ? rows.map((item) => `
    ${escapeHtml(item.lines)} · ${escapeHtml(item.layer)} ${escapeHtml(heavenSourcePhrase(item))} ${escapeHtml(item.detail || "")}
    `).join("") : '

    暂无可核验的数据来源。

    '; }; renderTrendEvidence(); if (!chart.available) { interpretButton.disabled = true; setText("marketHexagramName", "暂不成卦"); setText("marketTransformedName", "--"); setText("marketHexagramText", chart.quality?.principle || "六爻数据尚未齐备。"); setText("marketMovementSummary", (chart.quality?.issues || []).join(";") || "等待有效行情数据"); setText("heavenMomentumScore", "--"); setText("heavenMomentumLabel", "数据未齐"); renderCompactHexagrams(null); scoreMeter?.setAttribute("aria-valuenow", "0"); if (scoreNeedle) scoreNeedle.style.setProperty("--momentum-position", "50%"); document.querySelector("#marketHexagramLines").innerHTML = ""; const sourceRows = chart.quality?.sources || []; document.querySelector("#threeTalentReadings").innerHTML = [ ...(chart.quality?.issues || []).map((issue) => `
    未通过${escapeHtml(issue)}
    `), ...sourceRows.map((item) => `
    ${escapeHtml(item.lines)} · ${escapeHtml(item.layer)} ${escapeHtml(heavenSourcePhrase(item))} ${escapeHtml(item.detail || "")}
    `), ].join(""); document.querySelector("#heavenIndexStrip").innerHTML = "

    天象尚未应时,待三才数据齐备后再观。

    "; renderHeavenInterpretation("trend", ""); return; } interpretButton.disabled = false; setText("marketHexagramName", `${chart.hexagram.outer_trigram}上${chart.hexagram.inner_trigram}下 · ${chart.hexagram.name}`); setText("marketTransformedName", chart.hexagram.transformed.name); renderCompactHexagrams(chart.hexagram); setText("marketHexagramText", chart.hexagram.text); setText("marketMovementSummary", `${chart.movement.label}。${chart.movement.explanation}`); setText("heavenMomentumScore", `${chart.momentum_score > 0 ? "+" : ""}${chart.momentum_score}`); setText("heavenMomentumLabel", chart.momentum_label); const momentumPosition = clamp((number(chart.momentum_score) + 100) / 2, 0, 100); scoreMeter?.setAttribute("aria-valuenow", String(number(chart.momentum_score))); if (scoreNeedle) scoreNeedle.style.setProperty("--momentum-position", `${momentumPosition}%`); renderMarketHexagramLines(chart.hexagram.lines); document.querySelector("#threeTalentReadings").innerHTML = chart.pair_readings.map((item) => `
    ${escapeHtml(item.level)}${escapeHtml(item.state)}
    内 ${signedScore(item.inner)} 外 ${signedScore(item.outer)}
    `).join(""); const indexContext = chart.index_context || {}; document.querySelector("#heavenIndexStrip").innerHTML = (indexContext.indices || []).length ? indexContext.indices.map((item) => `
    ${escapeHtml(item.name)}${signed(item.pct_chg)}%5日 ${signed(item.return_5d)}%
    `).join("") : `

    ${escapeHtml(indexContext.notice || "指数数据暂不可用")}

    `; renderHeavenInterpretation("trend", state.heavenInterpretations.trend); } function renderMarketHexagramLines(lines) { const groups = [ { talent: "天", caption: "指数 · 外显为上,内核为下", lines: [lines[5], lines[4]] }, { talent: "人", caption: "行业 · 外显为上,内核为下", lines: [lines[3], lines[2]] }, { talent: "地", caption: "个股 · 外显为上,内核为下", lines: [lines[1], lines[0]] }, ]; document.querySelector("#marketHexagramLines").innerHTML = groups.map((group, groupIndex) => `

    ${group.caption}

    ${group.lines.map((line) => `
    ${escapeHtml(line.position_name)} ${hexagramLineGraphic(line.value)}
    ${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""} ${(line.evidence || []).map(escapeHtml).join(";")}
    `).join("")}
    `).join(""); } function stopQiFieldCanvas() { if (qiFieldAnimationFrame) cancelAnimationFrame(qiFieldAnimationFrame); qiFieldAnimationFrame = 0; } function renderQiFieldCanvas(balance, options = {}) { stopQiFieldCanvas(); const canvas = document.querySelector("#qiFieldCanvas"); const shell = canvas?.parentElement; if (!canvas || !shell || !shell.clientWidth || !shell.clientHeight) return; const context = canvas.getContext("2d"); const ratio = Math.min(2, window.devicePixelRatio || 1); const width = shell.clientWidth; const height = shell.clientHeight; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); context.setTransform(ratio, 0, 0, ratio, 0, 0); const phaseColors = { 木: "#4a7c59", 火: "#b53a30", 土: "#b08a3e", 金: "#9c7c3c", 水: "#31505f" }; const positions = { 水: [0.50, 0.23], 火: [0.50, 0.77], 金: [0.28, 0.50], 木: [0.72, 0.50], 土: [0.50, 0.50], }; const introStartedAt = options.intro && motionEnabled() ? performance.now() : 0; const items = balance.map((item, index) => ({ ...item, color: phaseColors[item.element] || "#6d685b", x: positions[item.element]?.[0] || 0.5, y: positions[item.element]?.[1] || 0.5, phase: index * 1.7, alpha: introStartedAt ? 0 : 1, })); const draw = (now = 0) => { context.clearRect(0, 0, width, height); context.globalCompositeOperation = "multiply"; items.forEach((item, index) => { const strength = Math.max(0.14, number(item.percent) / 100); const introProgress = introStartedAt ? clamp((now - introStartedAt) / 2600, 0, 1) : 1; const introEase = 1 - (1 - introProgress) ** 3; const breath = motionEnabled() ? Math.sin(now * 0.00055 + item.phase) : 0; const radius = Math.min(width, height) * (0.13 + Math.sqrt(strength) * 0.12) * (1 + breath * 0.06); const targetAlpha = qiFieldSoloElement ? (qiFieldSoloElement === item.element ? 1 : 0.1) : 1; item.alpha += (targetAlpha - item.alpha) * 0.06; const targetX = width * item.x + (motionEnabled() ? Math.sin(now * (0.00012 + index * 0.000015) + item.phase) * 10 : 0); const targetY = height * item.y + (motionEnabled() ? Math.cos(now * (0.0001 + index * 0.000013) + item.phase) * 8 : 0); const centerX = width * 0.5; const centerY = height * 0.47; const x = centerX + (targetX - centerX) * introEase; const y = centerY + (targetY - centerY) * introEase; const gradient = context.createRadialGradient(x, y, 0, x, y, radius); const rgb = item.color.match(/[a-f\d]{2}/gi).map((part) => parseInt(part, 16)); const alpha = item.alpha * introEase; gradient.addColorStop(0, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(0.28 + strength * 0.22) * alpha})`); gradient.addColorStop(0.5, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(0.13 + strength * 0.12) * alpha})`); gradient.addColorStop(1, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0)`); context.fillStyle = gradient; context.fillRect(x - radius, y - radius, radius * 2, radius * 2); }); context.globalCompositeOperation = "source-over"; if (motionEnabled() && state.activeView === "heavenView" && state.heavenPanel === "fortune") { qiFieldAnimationFrame = requestAnimationFrame(draw); } }; draw(performance.now()); } function wentianClimateVerdict(field) { const balance = field?.balance || []; const dominant = balance[0]?.element; const secondary = balance[1]?.element; const tertiary = balance[2]?.element; const pair = [dominant, secondary].filter(Boolean).sort().join(""); const primary = { 木火: "风火相煽", 木土: "风湿相搏", 木金: "风燥相激", 木水: "风寒相薄", 土火: "湿热交蒸", 火金: "燥热相煽", 水火: "寒热相争", 土金: "燥湿相搏", 土水: "寒湿交织", 水金: "寒燥相参", }[pair] || ({ 木: "风木疏展", 火: "热火升明", 土: "湿滞偏重", 金: "燥金肃降", 水: "寒水潜藏" }[dominant] || "气机交会"); const following = { 木: "风象暗动", 火: "热象内蕴", 土: "湿滞内结", 金: "燥气相参", 水: "寒意潜行" }[tertiary] || ({ 木: "风象相随", 火: "热象相随", 土: "湿象相随", 金: "燥象相随", 水: "寒象相随" }[secondary] || "诸气相参"); return `${primary} · ${following}`; } function renderFivePhaseField(field) { if (!field) return; setText("fortuneLunarDate", `${field.date} · ${field.lunar_date}`); setText("fortunePillars", `${field.pillars.year}年 · ${field.pillars.month}月 · ${field.pillars.day}日`); const metrics = [ ["中运", field.movement.label, field.movement.basis], ["司天", field.six_qi.sitian, "岁半以前主气候背景"], ["在泉", field.six_qi.zaiquan, "岁半以后主气候背景"], [field.six_qi.step_name, `主 ${field.six_qi.host_qi}`, `客 ${field.six_qi.guest_qi}`], ["当前节气", field.solar_terms.current, field.solar_terms.current_at], ["下一节气", field.solar_terms.next, field.solar_terms.next_at], ]; document.querySelector("#fortuneMetrics").innerHTML = metrics.map(([label, value, detail]) => `
    ${escapeHtml(label)}${escapeHtml(value)}${escapeHtml(detail)}
    `).join(""); const framework = field.framework || {}; setText("qiFrameworkPrinciple", framework.principle || "--"); const layerLabels = { year: "年运与岁气", current: "客主加临", day: "日辰触发" }; document.querySelector("#qiFrameworkLayers").innerHTML = (framework.layers || []).map((layer) => `
    ${escapeHtml(layerLabels[layer.id] || layer.label)} ${escapeHtml(layer.dominant)}气 ${escapeHtml(layer.summary)}
    ${(layer.balance || []).map((item) => ``).join("")}
    `).join(""); const human = field.human_field || {}; const dominantPhase = (field.balance || [])[0]; setText("qiClimateKeyword", wentianClimateVerdict(field)); setText("qiClimateTone", (human.emotional_tendency || [])[0] || "留意当下身心反应"); setText("humanFieldSummary", human.summary || "--"); setText("humanEmotionList", (human.emotional_tendency || []).join(";") || "--"); setText("humanBiasList", (human.decision_biases || []).join(";") || "--"); setText("humanOperation", human.operation_tendency || "--"); setText( "humanBalanceActions", [...(human.risk_reminders || []), ...(human.balancing_actions || [])].join(";") || "--", ); document.querySelector("#fivePhaseBalance").innerHTML = field.balance.map((item) => `
    ${escapeHtml(item.element)}
    ${escapeHtml(item.motion)} · ${escapeHtml(item.mind)}
    ${number(item.percent)}%
    `).join(""); document.querySelectorAll("#fivePhaseBalance .phase-balance-row").forEach((row) => { const focusPhase = () => { qiFieldSoloElement = row.dataset.phaseElement || ""; }; const clearPhase = () => { qiFieldSoloElement = ""; }; row.addEventListener("mouseenter", focusPhase); row.addEventListener("mouseleave", clearPhase); row.addEventListener("focus", focusPhase); row.addEventListener("blur", clearPhase); row.addEventListener("click", () => { qiFieldSoloElement = qiFieldSoloElement === row.dataset.phaseElement ? "" : row.dataset.phaseElement; }); }); setText("phaseSectorTitle", "五行行业归属"); setText("phaseSectorContext", "传统取象 · 手动归类优先"); renderQiUseMap(field); renderFortuneSectorCatalog(field); renderSectorPhaseOverrides(state.heavenSetup?.sector_phase_overrides || []); setText("fortuneNotice", field.notice); renderHeavenInterpretation("fortune", state.heavenInterpretations.fortune); } function renderFortuneSectorCatalog(field) { const container = document.querySelector("#fortuneSectorGroups"); if (!container) return; const phaseOrder = new Map((field.balance || []).map((item, index) => [item.element, index])); const canonical = { 木: 0, 火: 1, 土: 2, 金: 3, 水: 4 }; const catalog = [...(field.sector_catalog || [])].sort((left, right) => ( (canonical[left.element] ?? phaseOrder.get(left.element) ?? 99) - (canonical[right.element] ?? phaseOrder.get(right.element) ?? 99) )); container.innerHTML = catalog.map((group) => `
    ${escapeHtml(group.element)}属性${number(group.count || group.industries?.length)} 类
      ${(group.industries || []).map((item) => `
    • ${escapeHtml(item.name)}
    • `).join("")}
    `).join("") || '

    行业五行归类尚未建立

    '; } function renderQiUseMap(field) { const sourceContainer = document.querySelector("#qiUseSources"); const sectorContainer = document.querySelector("#phaseSectorList"); if (!sourceContainer || !sectorContainer) return; const balance = field.balance || []; const phaseOrder = new Map(balance.map((item, index) => [item.element, index])); const catalog = [...(field.sector_catalog || [])].sort( (left, right) => (phaseOrder.get(left.element) ?? 99) - (phaseOrder.get(right.element) ?? 99), ); const catalogElements = new Set(catalog.map((item) => item.element)); sourceContainer.innerHTML = balance.map((item) => `
    ${escapeHtml(item.element)} ${escapeHtml(item.motion)}${number(item.percent)}%
    `).join(""); sectorContainer.innerHTML = catalog.length ? catalog.map((group) => { const element = group.element; const items = group.industries || []; return `
    ${escapeHtml(element)}属性 ${number(group.count)} 类
      ${items.map((item) => `
    • ${escapeHtml(item.name)}${item.classification_source === "manual" ? '手动' : ""}
    • `).join("")}
    `; }).join("") : '

    行业五行归类尚未建立。

    '; sectorContainer.querySelectorAll(".qi-sector-group").forEach((group) => { group.addEventListener("toggle", () => requestAnimationFrame(() => drawQiUseConnections(false))); }); refreshIcons(); requestAnimationFrame(() => drawQiUseConnections(false)); } function drawQiUseConnections(animate = false) { const map = document.querySelector("#qiUseMap"); const svg = document.querySelector("#qiUseConnections"); if (!map || !svg || !map.clientWidth || !map.clientHeight) return; const bounds = map.getBoundingClientRect(); svg.setAttribute("viewBox", `0 0 ${bounds.width} ${bounds.height}`); svg.innerHTML = ""; document.querySelectorAll("#phaseSectorList [data-qi-sector]").forEach((group) => { const element = group.dataset.qiSector; const source = document.querySelector(`#qiUseSources [data-qi-source="${CSS.escape(element)}"]`); const target = group.querySelector("summary"); if (!source || !target) return; const from = source.getBoundingClientRect(); const to = target.getBoundingClientRect(); const x1 = from.right - bounds.left - 4; const y1 = from.top + from.height / 2 - bounds.top; const x2 = to.left - bounds.left + 2; const y2 = to.top + to.height / 2 - bounds.top; const bend = Math.max(46, (x2 - x1) * 0.42); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`); path.setAttribute("pathLength", "1"); path.classList.add(`phase-stroke-${phaseClass(element)}`); if (animate && motionEnabled()) path.classList.add("is-drawing"); else path.classList.add("is-flowing"); svg.appendChild(path); if (animate && motionEnabled()) { requestAnimationFrame(() => path.classList.add("is-visible")); setTimeout(() => { if (!path.isConnected) return; path.classList.remove("is-drawing", "is-visible"); path.classList.add("is-flowing"); }, 1900); } }); } function renderSectorPhaseOverrides(items) { const container = document.querySelector("#sectorPhaseOverrides"); const canManage = state.user?.role === "admin"; container.innerHTML = items.length ? items.map((item) => `
    ${escapeHtml(item.element)} ${escapeHtml(item.name)} ${canManage ? `` : ""}
    `).join("") : '

    暂无手动归类

    '; container.querySelectorAll("[data-sector-phase-delete]").forEach((button) => { button.addEventListener("click", () => deleteSectorPhaseOverride(button.dataset.sectorPhaseDelete)); }); refreshIcons(); } async function saveSectorPhaseOverride(event) { event.preventDefault(); const name = document.querySelector("#sectorPhaseName").value.trim(); const element = document.querySelector("#sectorPhaseElement").value; if (!name) return; const button = event.currentTarget.querySelector("button[type='submit']"); button.disabled = true; try { await apiRequest("/api/heaven/sector-phases", "POST", { name, element }); document.querySelector("#sectorPhaseName").value = ""; await loadHeavenSetup(true); showToast(`已将 ${name} 归为${element}`); } catch (error) { showToast(error.message || "手动归类保存失败"); } finally { button.disabled = false; } } async function deleteSectorPhaseOverride(name) { try { await apiRequest(`/api/heaven/sector-phases/${encodeURIComponent(name)}`, "DELETE"); await loadHeavenSetup(true); showToast(`已删除 ${name} 的手动归类`); } catch (error) { showToast(error.message || "手动归类删除失败"); } } async function saveAccountBirthProfile(event) { event.preventDefault(); const birthDate = document.querySelector("#accountBirthDate").value; const birthTime = document.querySelector("#accountBirthTime").value; if (!birthDate || !birthTime) { showToast("请填写完整出生日期和时间"); return; } const button = event.currentTarget.querySelector("button[type='submit']"); button.disabled = true; const originalText = button.textContent; button.textContent = "正在排盘"; try { await apiRequest("/api/account/birth-profile", "POST", { trade_date: document.querySelector("#qiObservationDate").value || elements.tradeDate.value, birth_datetime: `${birthDate}T${birthTime}`, gender: document.querySelector("#accountBirthGender").value, }); event.currentTarget.reset(); setText("birthProfileStatus", "已加密保存"); document.querySelector("#deleteBirthProfileButton").disabled = false; state.heavenInterpretations.fortune = ""; await loadHeavenSetup(true); showToast("个人命理资料已保存到当前账号"); } catch (error) { showToast(error.message || "个人命理资料保存失败"); } finally { button.disabled = false; button.textContent = originalText; } } async function deleteAccountBirthProfile() { if (!window.confirm("确定删除当前账号保存的个人命理资料吗?")) return; try { await apiRequest("/api/account/birth-profile", "DELETE"); state.personalField = null; state.heavenInterpretations.fortune = ""; setText("birthProfileStatus", "尚未设置"); document.querySelector("#deleteBirthProfileButton").disabled = true; renderPersonalFortune(); showToast("个人命理资料已删除"); } catch (error) { showToast(error.message || "个人命理资料删除失败"); } } function renderPersonalFortune() { const container = document.querySelector("#personalFortuneResult"); const empty = document.querySelector("#personalProfileEmpty"); const personal = state.personalField; if (!personal) { empty.hidden = false; container.hidden = true; container.innerHTML = ""; return; } empty.hidden = true; container.hidden = false; const tenGods = personal.ten_god_tendency || { favorable: [], caution: [] }; const elementTendency = personal.balance_tendency || { favorable: [], caution: [] }; const preferenceTags = (items) => (items || []).map((item) => `${escapeHtml(item)}`).join("") || "--"; container.innerHTML = `
    日主 ${escapeHtml(personal.day_master?.stem || "--")} ${escapeHtml(personal.day_master?.element || "--")} ${escapeHtml(personal.day_master?.strength || "")}
    十神喜恶
    偏宜

    ${preferenceTags(tenGods.favorable)}

    偏慎

    ${preferenceTags(tenGods.caution)}

    五行喜忌
    偏喜

    ${preferenceTags(elementTendency.favorable)}

    偏忌

    ${preferenceTags(elementTendency.caution)}

    `; } function renderHexagramLines(containerId, lines, includeEvidence = false) { const container = document.querySelector(`#${containerId}`); container.innerHTML = [...lines].reverse().map((line) => `
    ${escapeHtml(line.position_name)} ${hexagramLineGraphic(line.value)}
    ${escapeHtml(line.role || line.line_name)} · ${line.value}${line.moving ? " 变" : ""} ${includeEvidence ? `${(line.evidence || []).map(escapeHtml).join(";")}` : `${escapeHtml(line.text || "")}`}
    `).join(""); } function hexagramLineGraphic(value) { const yang = value % 2 === 1; return ` ${yang ? "" : ""}${[6, 9].includes(value) ? `${value === 9 ? "○" : "×"}` : ""} `; } const HEAVEN_READING_META = { trend: { panel: "观势", action: "解势", done: "查看解势", status: "势已成" }, fortune: { panel: "观气", action: "解运", done: "已解运", status: "气已定" }, heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解" }, }; 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) => ` `).join("") || emptyStateHtml("暂无历史解读"); const selected = items.find((item) => number(item.id) === state.heavenReadingSelectedId); const detail = document.querySelector("#heavenReadingHistoryDetail"); detail.innerHTML = selected ? `
    ${escapeHtml(heavenReadingMeta(mode).status)}

    ${escapeHtml(selected.subject)}

    ${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}

    ${formatMentorAnswer(selected.answer || "")}
    ` : emptyStateHtml("选择一条记录查看完整解读"); refreshIcons(); } function handleHeavenHistorySelection(event) { const button = event.target.closest("[data-heaven-reading-id]"); if (!button) return; state.heavenReadingSelectedId = number(button.dataset.heavenReadingId); renderHeavenReadingHistory(); } async function handleHeavenHistoryAction(event) { const button = event.target.closest("[data-delete-heaven-reading]"); if (!button || !window.confirm("确定删除这条解读记录吗?")) return; const id = number(button.dataset.deleteHeavenReading); try { await apiRequest(`/api/heaven/readings/${id}`, "DELETE"); const mode = state.heavenReadingMode; state.heavenReadingHistory[mode] = (state.heavenReadingHistory[mode] || []).filter((item) => number(item.id) !== id); if (number(state.heavenInterpretations[mode]?.id) === id) { state.heavenInterpretations[mode] = ""; if (mode === "fortune" && state.heavenSetup) state.heavenSetup.daily_fortune_reading = null; updateHeavenInterpretationControls(); } state.heavenReadingSelectedId = number(state.heavenReadingHistory[mode][0]?.id); renderHeavenReadingHistory(); } catch (error) { showToast(error.message || "解读记录删除失败"); } } async function interpretHeaven(mode) { const existing = state.heavenInterpretations[mode]; if (existing) { openHeavenReading(mode, { loading: false }); return; } const button = document.querySelector(mode === "trend" ? "#interpretTrendButton" : mode === "fortune" ? "#interpretFortuneButton" : "#interpretHeartButton"); if (button.disabled) return; state.heavenReadingMode = mode; state.heavenReadingLoading = true; state.heavenReadingError = ""; openHeavenReading(mode, { loading: true }); updateHeavenInterpretationControls(); hideHeavenNotice(); try { const payload = { mode, trade_date: document.querySelector("#qiObservationDate").value || elements.tradeDate.value, sector: state.heavenSetup?.chart?.sector || "", stock_code: state.heavenSetup?.chart?.stock?.code || "", }; if (mode === "trend" && state.heavenManualData) payload.manual_data = state.heavenManualData; if (mode === "heart") payload.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]) => ` ${escapeHtml(text)} `).join(""); } activateHeartRises(document.querySelector(".heart-stage.active-heart-stage")); } function toggleHeartSound() { heartSound.enabled = !heartSound.enabled; const button = document.querySelector("#heartSoundToggle"); button.setAttribute("aria-pressed", String(heartSound.enabled)); button.setAttribute("aria-label", heartSound.enabled ? "关闭观心声音" : "开启观心声音"); button.innerHTML = `${heartSound.enabled ? "有声" : "静音"}`; if (heartSound.enabled) { heartSound.ensure(); heartSound.chime(520); } refreshIcons(); } function setHeartLamp(stage) { const lamp = document.querySelector("#heartLamp"); if (lamp) lamp.dataset.heartStage = stage; } function startHeartDust() { stopHeartDust(); const canvas = document.querySelector("#heartDustCanvas"); const panel = document.querySelector("#heavenHeartPanel"); if (!canvas || !panel || !panel.clientWidth || !panel.clientHeight) return; const context = canvas.getContext("2d"); const ratio = Math.min(2, window.devicePixelRatio || 1); const width = panel.clientWidth; const height = panel.clientHeight; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); canvas.style.height = `${height}px`; context.setTransform(ratio, 0, 0, ratio, 0, 0); if (!heartDustParticles.length) { heartDustParticles = Array.from({ length: 60 }, (_, index) => ({ x: Math.random(), y: Math.random(), radius: 0.6 + Math.random() * 1.5, alpha: 0.03 + Math.random() * 0.09, vx: (Math.random() - 0.5) * 0.00006, vy: -(0.00002 + Math.random() * 0.00008), phase: Math.random() * Math.PI * 2, gold: index % 2 === 0, })); } const draw = (now) => { context.clearRect(0, 0, width, height); heartDustParticles.forEach((particle) => { if (motionEnabled()) { particle.x += particle.vx; particle.y += particle.vy; particle.phase += 0.006; } if (particle.y < -0.02) { particle.y = 1.02; particle.x = Math.random(); } if (particle.x < -0.02) particle.x = 1.02; if (particle.x > 1.02) particle.x = -0.02; const alpha = particle.alpha * (0.65 + 0.35 * Math.sin(particle.phase)); context.beginPath(); context.arc(particle.x * width, particle.y * height, particle.radius, 0, Math.PI * 2); context.fillStyle = particle.gold ? `rgba(220,195,140,${alpha})` : `rgba(190,200,225,${alpha * 0.8})`; context.fill(); }); if (motionEnabled() && state.activeView === "heavenView" && state.heavenPanel === "heart") { heartDustAnimationFrame = requestAnimationFrame(draw); } else { heartDustAnimationFrame = 0; } }; heartDustAnimationFrame = requestAnimationFrame(draw); } function stopHeartDust() { if (heartDustAnimationFrame) cancelAnimationFrame(heartDustAnimationFrame); heartDustAnimationFrame = 0; } function activateHeartRises(stage) { if (!stage) return; stage.querySelectorAll(".heart-rise").forEach((item) => { item.classList.remove("is-visible"); const delay = motionEnabled() ? number(item.dataset.heartDelay) : 0; setTimeout(() => { if (stage.classList.contains("active-heart-stage")) item.classList.add("is-visible"); }, delay); }); } async function transitionHeartStage(nextStage) { const token = ++state.heartStageToken; state.heartRevealToken += 1; const current = document.querySelector(".heart-stage.active-heart-stage"); current?.classList.add("is-leaving"); if (current && !await waitForHeartMotion(1050, token)) return false; state.heartStage = nextStage; renderHeartStage(); return token === state.heartStageToken; } function waitForHeartMotion(duration, token = state.heartStageToken) { return new Promise((resolve) => { setTimeout(() => resolve(token === state.heartStageToken), motionEnabled() ? duration : 0); }); } async function startHeartBreathing() { 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 = '按住
    重新成卦
    '; heartCastingBusy = false; } } function renderHeartStage() { document.querySelectorAll(".heart-stage").forEach((stage) => stage.classList.remove("active-heart-stage")); const stageMap = { intro: "heartIntro", breathing: "heartBreathing", casting: "heartCasting", reveal: "heartReveal", interpretation: "heartInterpretationStage", }; document.querySelectorAll(".heart-stage").forEach((stage) => stage.classList.remove("is-leaving")); const activeStage = document.querySelector(`#${stageMap[state.heartStage]}`); activeStage.classList.add("active-heart-stage"); document.querySelectorAll("[data-heart-step]").forEach((step) => { step.classList.toggle("active", step.dataset.heartStep === state.heartStage); }); setHeartLamp(state.heartStage); activateHeartRises(activeStage); if (state.heartStage === "breathing") { document.querySelector("#beginCastingButton").disabled = state.heartSeconds > 0; updateBreathingDisplay(); } if (state.heartStage === "casting") renderHeartCasting(); if (state.heartStage === "reveal" && state.heartHexagram) renderHeartReveal(); if (state.heartStage === "interpretation" && state.heartHexagram) renderHeartRead(); } function renderHeartCasting() { setText("castingProgress", `${state.heartLines.length} / 6`); const latestThrow = state.heartThrows[state.heartThrows.length - 1] || ["静", "静", "静"]; document.querySelectorAll(".heart-coin").forEach((coin, index) => { coin.setAttribute("aria-label", latestThrow[index] === "静" ? `第 ${index + 1} 枚铜钱待掷` : `第 ${index + 1} 枚铜钱${latestThrow[index]}`); }); const nextPosition = LINE_POSITIONS_CLIENT[state.heartLines.length] || "成卦"; setText( "castingPrompt", state.heartLines.length < 6 ? `心中默念所问之事,然后掷出${nextPosition}` : "六爻已具,正在成卦", ); const button = document.querySelector("#tossCoinsButton"); button.innerHTML = state.heartLines.length < 6 ? `按住
    摇${nextPosition}
    ` : '正在
    成卦
    '; button.disabled = heartCastingBusy || state.heartLines.length >= 6; const rows = []; for (let index = 5; index >= 0; index -= 1) { const value = state.heartLines[index]; rows.push(`
    ${LINE_POSITIONS_CLIENT[index]} ${value ? hexagramLineGraphic(value) : ''}
    ${value ? `${lineValueName(value)} · ${value}` : "未得"}
    `); } document.querySelector("#heartCastingLines").innerHTML = rows.join(""); } function renderHeartReveal() { const hexagram = state.heartHexagram; setText("heartHexagramName", `${hexagram.outer_trigram}上${hexagram.inner_trigram}下 · ${hexagram.name}`); setText("heartTransformedName", hexagram.transformed.name); setText("heartHexagramText", hexagram.text); renderHexagramLines("heartHexagramLines", hexagram.lines, false); document.querySelector("#heartHexagramLines").querySelectorAll(".hexagram-line-row").forEach((row) => row.classList.add("heart-reveal-line")); document.querySelector("#heartReveal").classList.remove("is-sequence-ready", "is-title-ready", "is-thought-typing", "is-thought-ready"); const prompt = document.querySelector("#heartFirstThoughtPrompt"); prompt.dataset.fullText = "看见卦象与爻辞后,心里升起的第一念是什么?"; prompt.textContent = ""; const button = document.querySelector("#interpretHeartButton"); button.disabled = true; button.classList.remove("is-ready"); } async function playHeartRevealSequence() { const token = ++state.heartRevealToken; const stageToken = state.heartStageToken; const stage = document.querySelector("#heartReveal"); const lines = [...stage.querySelectorAll(".heart-reveal-line")].reverse(); lines.forEach((line) => line.classList.remove("is-revealed")); if (!await waitForHeartMotion(280, stageToken)) return; for (const line of lines) { if (token !== state.heartRevealToken || state.heartStage !== "reveal") return; line.classList.add("is-revealed"); if (!await waitForHeartMotion(520, stageToken)) return; } stage.classList.add("is-title-ready", "is-sequence-ready"); heartSound.chime(520); if (!await waitForHeartMotion(1200, stageToken)) return; const prompt = document.querySelector("#heartFirstThoughtPrompt"); stage.classList.add("is-thought-typing"); if (!await typeHeartText(prompt, prompt.dataset.fullText, token, 72)) return; stage.classList.add("is-thought-ready"); if (!await waitForHeartMotion(2400, stageToken)) return; const button = document.querySelector("#interpretHeartButton"); button.disabled = false; button.classList.add("is-ready"); } function initializeHeartLineInspection() { const container = document.querySelector("#heartLineTexts"); container.addEventListener("click", (event) => { const item = event.target.closest(".heart-line-text"); if (!item) return; const inspected = item.classList.toggle("is-inspected"); item.setAttribute("aria-expanded", String(inspected)); }); } async function typeHeartText(element, text, token, speed = 72) { if (!element) return false; if (!motionEnabled()) { element.textContent = text; return true; } element.textContent = ""; element.classList.add("heart-typing"); for (const character of text) { if (token !== state.heartRevealToken || state.heartStage !== "reveal") return false; element.append(document.createTextNode(character)); await new Promise((resolve) => setTimeout(resolve, speed)); } element.classList.remove("heart-typing"); return true; } function renderHeartRead() { const hexagram = state.heartHexagram; setText("heartReadTitle", hexagram.name); setText("heartReadChange", hexagram.transformed.name === hexagram.name ? "六爻安静,无之卦" : `之卦 · ${hexagram.transformed.name}`); setText("heartReadGuaci", hexagram.text); document.querySelector("#heartReadLines").innerHTML = [...hexagram.lines].reverse().map((line) => `
    ${escapeHtml(line.position_name)}${hexagramLineGraphic(line.value)}
    `).join(""); document.querySelector("#heartReadTexts").innerHTML = hexagram.lines.map((line) => `
    ${escapeHtml(line.line_name)}${line.moving ? " · 动" : ""}

    ${escapeHtml(line.text)}

    `).join(""); renderHeavenInterpretation("heart", state.heavenInterpretations.heart); const stage = document.querySelector("#heartInterpretationStage"); stage.classList.remove("is-read-heading-ready", "is-read-complete"); } async function playHeartReadSequence() { const token = state.heartStageToken; const stage = document.querySelector("#heartInterpretationStage"); if (!await waitForHeartMotion(420, token)) return; stage.classList.add("is-read-heading-ready"); const lines = [...stage.querySelectorAll(".heart-read-line")].reverse(); const texts = [...stage.querySelectorAll(".heart-read-text")]; for (let index = 0; index < 6; index += 1) { lines[index]?.classList.add("is-visible"); texts[index]?.classList.add("is-visible"); if (!await waitForHeartMotion(680, token)) return; } stage.classList.add("is-read-complete"); } function resetHeartCoins() { heartCoinRotations.fill(0); document.querySelectorAll(".heart-coin").forEach((coin) => { coin.getAnimations().forEach((animation) => animation.cancel()); const inner = coin.querySelector(".heart-coin-inner"); inner.getAnimations().forEach((animation) => animation.cancel()); inner.style.transform = ""; coin.style.transform = ""; coin.dataset.face = ""; coin.querySelector(".heart-coin-ring").classList.remove("is-bursting"); }); const shell = document.querySelector(".heart-hexagram-shell"); shell?.classList.remove("is-complete"); } async function resetHeartRitual() { if (state.heartTimer) clearInterval(state.heartTimer); state.heartTimer = null; state.heartSeconds = HEART_BREATH_TOTAL_MS / 1000; state.heartBreathingEndsAt = 0; state.heartLines = []; state.heartThrows = []; state.heartHexagram = null; state.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); }); }