From bc7521a591db0bcd0ff321f76983d56cb6a7aa28 Mon Sep 17 00:00:00 2001 From: leefer Date: Thu, 23 Jul 2026 23:11:51 +0800 Subject: [PATCH] feat: add contextual heaven loading rituals --- static/app.js | 56 +++- static/heaven-loading.js | 593 ++++++++++++++++++++++++++++++++++++ static/index.html | 9 +- static/styles.css | 51 ++-- tests/e2e/app-shell.spec.js | 42 +++ 5 files changed, 728 insertions(+), 23 deletions(-) create mode 100644 static/heaven-loading.js diff --git a/static/app.js b/static/app.js index f264955..3db3dec 100644 --- a/static/app.js +++ b/static/app.js @@ -140,6 +140,7 @@ const LIVE_REFRESH_DEFAULT_MS = 10 * 1000; let qiFieldAnimationFrame = 0; let qiFieldSoloElement = ""; let heavenPerformanceToken = 0; +let heavenReadingAnimation = null; let heartHoldTimer = null; let heartHoldTriggered = false; let heartHoldStartedAt = 0; @@ -623,6 +624,7 @@ function bindEvents() { 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)); }); @@ -3483,6 +3485,44 @@ 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"; @@ -3492,7 +3532,10 @@ function openHeavenReading(mode, options = {}) { : false; renderHeavenReadingDialog(); if (!elements.heavenReadingDialog.open) elements.heavenReadingDialog.showModal(); - requestAnimationFrame(() => document.querySelector("#closeHeavenReadingDialog").focus()); + requestAnimationFrame(() => { + syncHeavenReadingAnimation(); + document.querySelector("#closeHeavenReadingDialog").focus(); + }); } async function openHeavenHistory(mode) { @@ -3537,8 +3580,12 @@ function renderHeavenReadingDialog() { }); document.querySelector("#heavenReadingCurrent").hidden = state.heavenReadingTab !== "current"; document.querySelector("#heavenReadingHistory").hidden = state.heavenReadingTab !== "history"; - if (state.heavenReadingTab === "current") renderHeavenReadingCurrent(); - else renderHeavenReadingHistory(); + if (state.heavenReadingTab === "current") { + renderHeavenReadingCurrent(); + } else { + stopHeavenReadingAnimation(); + renderHeavenReadingHistory(); + } refreshIcons(); } @@ -3550,6 +3597,7 @@ function renderHeavenReadingCurrent() { const error = document.querySelector("#heavenReadingError"); loading.hidden = !state.heavenReadingLoading; setText("heavenReadingLoadingTitle", heavenReadingMeta().loading); + syncHeavenReadingAnimation(); error.hidden = !state.heavenReadingError; error.textContent = state.heavenReadingError; result.hidden = state.heavenReadingLoading || !reading; @@ -3644,6 +3692,7 @@ async function interpretHeaven(mode) { created_at: new Date().toISOString(), }; state.heavenReadingHistory[mode] = []; + await finishHeavenReadingAnimation(); state.heavenReadingLoading = false; renderHeavenReadingDialog(); if (result.notice) showHeavenNotice(result.notice); @@ -3653,6 +3702,7 @@ async function interpretHeaven(mode) { renderHeavenInterpretation(mode, state.heavenInterpretations[mode]); } } catch (error) { + stopHeavenReadingAnimation(); state.heavenReadingLoading = false; state.heavenReadingError = error.message || "问天解读失败"; renderHeavenReadingDialog(); diff --git a/static/heaven-loading.js b/static/heaven-loading.js new file mode 100644 index 0000000..f3b0f99 --- /dev/null +++ b/static/heaven-loading.js @@ -0,0 +1,593 @@ +(function exposeHeavenLoading(global) { + "use strict"; + + const PAPER = "#fdfcf8"; + const PAPER_CENTER = "#f4eee2"; + const INK = "#76513d"; + const INK_BRIGHT = "#914f3d"; + const GOLD = "#a97d43"; + const GOLD_BRIGHT = "#c69a58"; + const CINNABAR = "#b64e43"; + const DIM = "rgba(75, 72, 63, 0.58)"; + const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif'; + const ELEMENT_COLORS = { + 木: "#648b5b", + 火: "#b85043", + 土: "#a88038", + 金: "#8b8171", + 水: "#587a9f", + }; + const QI6 = [ + { name: "厥阴风木", element: "木" }, + { name: "少阴君火", element: "火" }, + { name: "少阳相火", element: "火" }, + { name: "太阴湿土", element: "土" }, + { name: "阳明燥金", element: "金" }, + { name: "太阳寒水", element: "水" }, + ]; + const TRIGRAMS = [ + { name: "乾", bits: [1, 1, 1], angle: -90 }, + { name: "兑", bits: [1, 1, 0], angle: -135 }, + { name: "离", bits: [1, 0, 1], angle: 180 }, + { name: "震", bits: [1, 0, 0], angle: 135 }, + { name: "巽", bits: [0, 1, 1], angle: -45 }, + { name: "坎", bits: [0, 1, 0], angle: 0 }, + { name: "艮", bits: [0, 0, 1], angle: 45 }, + { name: "坤", bits: [0, 0, 0], angle: 90 }, + ]; + const SIXIANG = [ + { name: "太阳", bits: [1, 1], dx: 0, dy: -1 }, + { name: "少阴", bits: [1, 0], dx: 1, dy: 0 }, + { name: "太阴", bits: [0, 0], dx: 0, dy: 1 }, + { name: "少阳", bits: [0, 1], dx: -1, dy: 0 }, + ]; + const HEXAGRAM_NAMES = [ + "坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁", + "师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤", + "复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临", + "损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾", + ]; + const HEX_STAGES = [ + [0, 1800, "太 极", "无极而太极,动而生阳"], + [1800, 3300, "两 仪", "一阴一阳之谓道"], + [3300, 4700, "四 象", "阴阳消长,太少相生"], + [4700, 6800, "八 卦", "天地定位,山泽通气"], + [6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"], + ]; + const FORTUNE_STAGES = [ + [0, 2100, "五 运", "木火土金水,五运相袭,周而复始"], + [2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"], + [3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明"], + [5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"], + [7900, 11000, "岁 运 合 参", "五运承岁,六气应时"], + ]; + + const clamp01 = (value) => Math.max(0, Math.min(1, value)); + const smooth = (start, end, value) => { + const progress = clamp01((value - start) / Math.max(1, end - start)); + return progress * progress * (3 - 2 * progress); + }; + const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3); + const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1); + const point = (cx, cy, radius, degrees) => { + const radians = degrees * Math.PI / 180; + return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius]; + }; + + class HeavenLoadingCanvas { + constructor(canvas) { + this.canvas = canvas; + this.context = canvas.getContext("2d"); + this.width = 0; + this.height = 0; + this.dpr = 1; + this.scene = "hexagram"; + this.data = {}; + this.startedAt = 0; + this.frameId = 0; + this.running = false; + this.completingAt = 0; + this.completionResolve = null; + this.completionTimer = 0; + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches; + this.stars = this.createStars(this.reducedMotion ? 48 : 110); + } + + createStars(count) { + let seed = 24681357; + const random = () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 4294967296; + }; + return Array.from({ length: count }, () => ({ + x: random(), + y: random(), + radius: 0.25 + random() * 1.05, + phase: random() * Math.PI * 2, + speed: 0.00002 + random() * 0.00005, + })); + } + + start(scene, data = {}) { + const nextScene = scene === "fortune" ? "fortune" : "hexagram"; + if (this.running && this.scene === nextScene) { + this.data = data; + return; + } + this.stop(); + this.scene = nextScene; + this.data = data; + this.startedAt = performance.now(); + this.running = true; + this.canvas.dataset.scene = this.scene; + this.canvas.dataset.running = "true"; + this.resizeObserver.observe(this.canvas); + this.resize(); + if (this.reducedMotion) { + this.draw(this.scene === "fortune" ? 11000 : 10800, performance.now()); + } else { + this.frameId = requestAnimationFrame((now) => this.frame(now)); + } + } + + complete() { + if (!this.running || this.reducedMotion) { + this.stop(); + return Promise.resolve(); + } + if (this.completionResolve) return this.completionPromise; + this.completingAt = performance.now(); + this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; }); + this.completionTimer = global.setTimeout(() => this.stop(), 900); + return this.completionPromise; + } + + stop() { + if (this.frameId) cancelAnimationFrame(this.frameId); + this.frameId = 0; + this.running = false; + this.completingAt = 0; + if (this.completionTimer) global.clearTimeout(this.completionTimer); + this.completionTimer = 0; + this.resizeObserver.disconnect(); + this.canvas.dataset.running = "false"; + if (this.completionResolve) this.completionResolve(); + this.completionResolve = null; + this.completionPromise = null; + } + + resize() { + const rect = this.canvas.getBoundingClientRect(); + const width = Math.max(1, Math.round(rect.width)); + const height = Math.max(1, Math.round(rect.height)); + if (width === this.width && height === this.height) return; + this.width = width; + this.height = height; + this.dpr = Math.min(global.devicePixelRatio || 1, 2); + this.canvas.width = Math.round(width * this.dpr); + this.canvas.height = Math.round(height * this.dpr); + this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + if (this.running && this.reducedMotion) { + this.draw(this.scene === "fortune" ? 11000 : 10800, performance.now()); + } + } + + frame(now) { + if (!this.running) return; + if (this.completingAt) { + const progress = clamp01((now - this.completingAt) / 620); + this.drawCompletion(progress, now); + if (progress >= 1) { + this.stop(); + return; + } + } else { + const holdAt = this.scene === "fortune" ? 11000 : 10800; + this.draw(Math.min(now - this.startedAt, holdAt), now); + } + this.frameId = requestAnimationFrame((time) => this.frame(time)); + } + + draw(time, now) { + if (this.width <= 1 || this.height <= 1) return; + this.drawBackground(now); + if (this.scene === "fortune") this.drawFortune(time, now); + else this.drawHexagram(time, now); + } + + drawBackground(now) { + const { context: ctx, width, height } = this; + const cx = width / 2; + const cy = height * 0.43; + const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75); + gradient.addColorStop(0, PAPER_CENTER); + gradient.addColorStop(0.58, "#faf7f0"); + gradient.addColorStop(1, PAPER); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + for (const star of this.stars) { + const alpha = 0.08 + 0.13 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.001)); + ctx.globalAlpha = alpha; + ctx.fillStyle = GOLD; + const y = ((star.y + now * star.speed) % 1) * height; + ctx.fillRect(star.x * width, y, star.radius, star.radius); + } + ctx.globalAlpha = 1; + } + + label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) { + if (!text || alpha <= 0) return; + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + if (maxWidth) ctx.fillText(text, x, y, maxWidth); + else ctx.fillText(text, x, y); + ctx.restore(); + } + + node(x, y, radius, color, alpha = 1, glow = 0) { + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.shadowColor = color; + ctx.shadowBlur = glow; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + line(x1, y1, x2, y2, color, alpha = 1, width = 1) { + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.restore(); + } + + drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) { + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = INK; + ctx.shadowColor = GOLD; + ctx.shadowBlur = glow; + if (yang) { + ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth); + } else { + const gap = width * 0.18; + ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth); + ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth); + } + ctx.restore(); + } + + drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) { + const gap = lineWidth * 1.7; + const top = cy - (bits.length - 1) * gap / 2; + bits.forEach((bit, index) => { + this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow); + }); + } + + stageAlpha(time, start, end, fade = 300, hold = false) { + const enter = smooth(start, start + fade, time); + return hold ? enter : enter * (1 - smooth(end - fade, end, time)); + } + + drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) { + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha * 0.13; + ctx.strokeStyle = GOLD; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + const breath = 1 + 0.005 * Math.sin(now / 620); + TRIGRAMS.forEach((trigram, index) => { + const progress = entering ? easeOut((time - 4700 - index * 120) / 650) : 1; + if (progress <= 0) return; + const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle); + this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 7); + this.label(trigram.name, x, y + lineWidth * 5.2, 11, INK, alpha * progress * 0.68); + }); + } + + drawHexagram(time, now) { + const { width, height } = this; + const cx = width / 2; + const cy = height * 0.41; + const scale = Math.min(width, height); + if (time < 1800) { + const alpha = this.stageAlpha(time, 0, 1800); + this.node(cx, cy, 5.5 * (1 + 0.1 * Math.sin(now / 260)), INK_BRIGHT, alpha, 24); + for (let ring = 0; ring < 3; ring += 1) { + const progress = ((now / 1500) + ring / 3) % 1; + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = (1 - progress) * 0.18 * alpha; + ctx.strokeStyle = GOLD; + ctx.beginPath(); + ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + } + if (time >= 1800 && time < 3300) { + const alpha = this.stageAlpha(time, 1800, 3300); + const progress = easeOut((time - 1850) / 850); + const yaoWidth = scale * 0.19 * progress; + const yaoLine = Math.max(scale * 0.013, 4); + this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 10); + this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 10); + } + if (time >= 3300 && time < 4700) { + const alpha = this.stageAlpha(time, 3300, 4700); + const distance = scale * 0.085; + const yaoWidth = Math.max(scale * 0.055, 26); + const yaoLine = Math.max(scale * 0.008, 3); + SIXIANG.forEach((symbol, index) => { + const progress = easeOut((time - 3330 - index * 150) / 520); + if (progress <= 0) return; + const x = cx + symbol.dx * distance; + const y = cy + symbol.dy * distance; + this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 7); + this.label(symbol.name, x, y + yaoLine * 5.3, 10, INK, alpha * progress * 0.7); + }); + } + const trigramRadius = scale * 0.205; + const trigramWidth = Math.max(scale * 0.05, 24); + const trigramLine = Math.max(scale * 0.0068, 2.6); + if (time >= 4700 && time < 6800) { + this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time); + } + if (time >= 6800) { + const alpha = this.stageAlpha(time, 6800, 10800, 350, true); + this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.82, trigramLine * 0.82, alpha * 0.46, now, false, time); + const ringRadius = scale * 0.34; + const hexWidth = Math.max(scale * 0.024, 11); + const hexLine = Math.max(scale * 0.0038, 1.35); + const count = Math.floor(clamp01((time - 7000) / 3600) * 64); + for (let index = 0; index < 64; index += 1) { + const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64); + this.node(x, y, 1.1, GOLD, alpha * 0.14); + if (index < count) { + const freshness = Math.max(0, 1 - (count - 1 - index) / 5); + if (freshness > 0) this.line(cx, cy, x, y, GOLD, alpha * freshness * 0.2); + this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.38 * freshness), freshness * 6); + } + } + if (count > 0) { + const current = count - 1; + this.drawGua(cx, cy - scale * 0.025, scale * 0.078, Math.max(scale * 0.0095, 4), hexBits(current), alpha, 11); + this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.058, Math.max(18, scale * 0.038), INK_BRIGHT, alpha, "600"); + this.label(`第 ${current + 1} 卦`, cx, cy + scale * 0.098, 11, DIM, alpha * 0.85); + } + } + this.drawChrome(time, now, "易 经 · 两 仪 裂 变 推 衍", "易", HEX_STAGES, 10800, "hexagram"); + } + + drawFortune(time, now) { + const { width, height } = this; + const cx = width / 2; + const cy = height * 0.41; + const scale = Math.min(width, height); + if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale); + if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale); + if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale); + if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale); + if (time >= 7900) this.drawAnnualQi(time, now, cx, cy, scale); + this.drawChrome(time, now, "黄 帝 内 经 · 五 运 六 气", "气", FORTUNE_STAGES, 11000, "fortune"); + } + + drawFiveMovements(time, now, cx, cy, scale) { + const alpha = this.stageAlpha(time, 0, 2100); + const radius = scale * 0.17; + const nodeRadius = Math.max(scale * 0.017, 8); + const elements = [ + ["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null], + ]; + const positions = {}; + this.node(cx, cy, 5 + Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 20); + elements.forEach(([element, degrees], index) => { + const progress = easeOut((time - 500 - index * 170) / 500); + if (progress <= 0) return; + const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress; + const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress; + positions[element] = [x, y]; + this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 10); + this.label(element, x, y, Math.max(10, nodeRadius * 1.1), PAPER, alpha * progress, "600"); + }); + const order = ["木", "火", "土", "金", "水"]; + order.forEach((element, index) => { + const from = positions[element]; + const to = positions[order[(index + 1) % order.length]]; + if (from && to) this.line(from[0], from[1], to[0], to[1], GOLD, alpha * smooth(1450 + index * 120, 1750 + index * 120, time) * 0.32); + }); + } + + drawStems(time, cx, cy, scale) { + const alpha = this.stageAlpha(time, 2100, 3900); + const stems = "甲乙丙丁戊己庚辛壬癸"; + const movements = ["土", "金", "水", "木", "火"]; + const radius = scale * 0.29; + for (let index = 0; index < 10; index += 1) { + const progress = smooth(2150 + index * 85, 2450 + index * 85, time); + if (progress <= 0) continue; + const [x, y] = point(cx, cy, radius, -90 + index * 36); + const element = movements[index % 5]; + this.node(x, y, 2.8, ELEMENT_COLORS[element], alpha * progress, 5); + this.label(stems[index], x, y - 13, 13, ELEMENT_COLORS[element], alpha * progress, "600"); + } + for (let index = 0; index < 5; index += 1) { + const progress = smooth(3100 + index * 100, 3400 + index * 100, time); + const [x1, y1] = point(cx, cy, radius, -90 + index * 36); + const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36); + this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.38); + this.label(movements[index], (x1 + x2) / 2, (y1 + y2) / 2, 14, ELEMENT_COLORS[movements[index]], alpha * progress, "600"); + } + } + + drawBranches(time, cx, cy, scale) { + const alpha = this.stageAlpha(time, 3900, 5800); + const branches = "子丑寅卯辰巳午未申酉戌亥"; + const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"]; + const radius = scale * 0.29; + const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30; + for (let index = 0; index < 12; index += 1) { + const progress = smooth(3950 + index * 65, 4220 + index * 65, time); + const [x, y] = point(cx, cy, radius, branchAngle(index)); + this.node(x, y, 2.3, GOLD, alpha * progress, 4); + this.label(branches[index], x, y - 12, 12, INK, alpha * progress); + } + qiNames.forEach((name, index) => { + const progress = smooth(4850 + index * 120, 5150 + index * 120, time); + const [x1, y1] = point(cx, cy, radius, branchAngle(index)); + const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6)); + const element = QI6.find((item) => item.name === name)?.element || "土"; + this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.34); + const [labelX, labelY] = point(cx, cy, radius + scale * 0.05, branchAngle(index)); + this.label(name, labelX, labelY, 10.5, ELEMENT_COLORS[element], alpha * progress, "600"); + }); + } + + drawSixQi(time, now, cx, cy, scale) { + const alpha = this.stageAlpha(time, 5800, 7900); + const radius = scale * 0.255; + const drift = now * 0.0015; + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha * 0.13; + ctx.strokeStyle = GOLD; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + QI6.forEach((qi, index) => { + const progress = easeOut((time - 5850 - index * 170) / 540); + const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift); + const nodeRadius = Math.max(scale * 0.014, 7) * progress; + this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 9); + this.label(qi.name, x, y - nodeRadius - 11, 11, ELEMENT_COLORS[qi.element], alpha * progress, "600"); + this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 11, 9, DIM, alpha * progress); + }); + this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 16); + } + + drawAnnualQi(time, now, cx, cy, scale) { + const alpha = this.stageAlpha(time, 7900, 11000, 350, true); + const sixQi = this.data.sixQi || {}; + const pillar = this.data.yearPillar || "岁运"; + const movement = this.data.movement || "中运合参"; + const sitian = sixQi.sitian || "司天气候"; + const zaiquan = sixQi.zaiquan || "在泉气化"; + const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1)); + this.label("司 天", cx, cy - scale * 0.205, 10, DIM, alpha * smooth(7950, 8400, time)); + this.label(sitian, cx, cy - scale * 0.172, 15, INK, alpha * smooth(7950, 8400, time), "600"); + this.label(zaiquan, cx, cy + scale * 0.172, 15, INK, alpha * smooth(8200, 8650, time), "600"); + this.label("在 泉", cx, cy + scale * 0.205, 10, DIM, alpha * smooth(8200, 8650, time)); + this.label(pillar, cx, cy - scale * 0.01, Math.max(21, scale * 0.047), INK_BRIGHT, alpha * smooth(8500, 9050, time), "600"); + this.label(movement, cx, cy + scale * 0.05, 12, GOLD, alpha * smooth(8500, 9050, time), "600", scale * 0.48); + const radius = scale * 0.285; + QI6.forEach((qi, index) => { + const progress = smooth(9150 + index * 240, 9440 + index * 240, time); + const [x, y] = point(cx, cy, radius, -90 + index * 60); + const current = index + 1 === currentStep; + const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0; + this.node(x, y, Math.max(scale * 0.01, 5.5) + (current ? 2 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 7 + pulse * 10); + if (current) { + const ctx = this.context; + ctx.save(); + ctx.globalAlpha = alpha * (0.28 + pulse * 0.3); + ctx.strokeStyle = CINNABAR; + ctx.beginPath(); + ctx.arc(x, y, Math.max(scale * 0.019, 10) + pulse * 2, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + this.label("当今", x, y - Math.max(scale * 0.035, 19), 9, CINNABAR, alpha * progress, "600"); + } + this.label(`${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`, x, y + Math.max(scale * 0.029, 16), 9.5, current ? INK_BRIGHT : DIM, alpha * progress, current ? "600" : ""); + }); + } + + drawChrome(time, now, title, seal, stages, holdAt, scene) { + const { width, height, context: ctx } = this; + this.label(title, width / 2, 26, width < 520 ? 11 : 13, INK, 0.58, "600"); + ctx.save(); + ctx.translate(width - (width < 520 ? 38 : 48), 14); + ctx.globalAlpha = 0.88; + ctx.fillStyle = CINNABAR; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(0, 0, 28, 28, 4); + else ctx.rect(0, 0, 28, 28); + ctx.fill(); + ctx.restore(); + this.label(seal, width - (width < 520 ? 24 : 34), 28, 17, "#fffaf0", 1, "600"); + const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0]; + const stageProgress = smooth(stage[0], stage[0] + 280, time); + this.label(stage[2], width / 2, height - 82, width < 520 ? 15 : 18, INK_BRIGHT, 0.55 + stageProgress * 0.4, "600"); + this.label(stage[3], width / 2, height - 60, width < 520 ? 9.5 : 11.5, DIM, 0.72, "", width - 48); + const slotWidth = Math.min(30, (width - 96) / 9); + const slotGap = Math.min(10, slotWidth * 0.36); + const totalWidth = slotWidth * 6 + slotGap * 5; + const filled = Math.min(6, Math.floor(clamp01(time / holdAt) * 6)); + for (let index = 0; index < 6; index += 1) { + const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap); + const y = height - 34; + ctx.save(); + ctx.globalAlpha = 0.2; + ctx.strokeStyle = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD; + ctx.strokeRect(x, y, slotWidth, 4); + if (index < filled) { + ctx.globalAlpha = 0.78; + ctx.fillStyle = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD; + ctx.fillRect(x, y, slotWidth, 4); + } else if (index === filled && time < holdAt) { + ctx.globalAlpha = 0.38 + 0.18 * Math.sin(now / 220); + ctx.fillStyle = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD; + ctx.fillRect(x, y, slotWidth * ((time % (holdAt / 6)) / (holdAt / 6)), 4); + } + ctx.restore(); + } + } + + drawCompletion(progress, now) { + this.drawBackground(now); + const { width, height } = this; + const cx = width / 2; + const cy = height * 0.41; + const scale = Math.min(width, height); + const eased = easeOut(progress); + if (this.scene === "fortune") { + const radius = scale * 0.285 * (1 - eased); + QI6.forEach((qi, index) => { + const [x, y] = point(cx, cy, radius, -90 + index * 60); + if (radius > 5) this.node(x, y, Math.max(scale * 0.01, 5), ELEMENT_COLORS[qi.element], (1 - eased) * 0.78, 6); + }); + this.drawChrome(11000, now, "黄 帝 内 经 · 五 运 六 气", "气", [[0, 1, "归 一", "谨守病机,无失气宜"]], 11000, "fortune"); + } else { + const radius = scale * 0.34 * (1 - eased); + for (let index = 0; index < 64 && radius > 5; index += 1) { + const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64); + this.drawGua(x, y, Math.max(scale * 0.023, 10), Math.max(scale * 0.0036, 1.3), hexBits(index), (1 - eased) * 0.68); + } + this.drawChrome(10800, now, "易 经 · 两 仪 裂 变 推 衍", "易", [[0, 1, "归 一", "万物负阴而抱阳,冲气以为和"]], 10800, "hexagram"); + } + this.node(cx, cy, 3 + eased * 6, INK_BRIGHT, 0.35 + eased * 0.65, 12 + eased * 28); + } + } + + global.HeavenLoadingCanvas = HeavenLoadingCanvas; +})(window); diff --git a/static/index.html b/static/index.html index c84c149..7eecab5 100644 --- a/static/index.html +++ b/static/index.html @@ -1024,9 +1024,11 @@
尚无本次解读