diff --git a/static/heaven-loading.js b/static/heaven-loading.js index 44460ad..ce816a7 100644 --- a/static/heaven-loading.js +++ b/static/heaven-loading.js @@ -47,6 +47,16 @@ "复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临", "损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾", ]; + const HEX_TOTAL = 12500; + const FORTUNE_TOTAL = 12800; + const HEX_STAGES = [ + [0, 1800, "太 极", "无极而太极,动而生阳"], + [1800, 3300, "两 仪", "一阴一阳之谓道"], + [3300, 4700, "四 象", "阴阳消长,太少相生"], + [4700, 6800, "八 卦", "天地定位,山泽通气"], + [6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"], + [10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"], + ]; 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)); @@ -107,10 +117,11 @@ this.running = true; this.canvas.dataset.scene = this.scene; this.canvas.dataset.running = "true"; + this.canvas.dataset.looping = "true"; this.resizeObserver.observe(this.canvas); this.resize(); if (this.reducedMotion) { - this.draw(this.scene === "fortune" ? 11000 : 10800, performance.now()); + this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now()); } else { this.frameId = requestAnimationFrame((now) => this.frame(now)); } @@ -137,6 +148,7 @@ this.completionTimer = 0; this.resizeObserver.disconnect(); this.canvas.dataset.running = "false"; + this.canvas.dataset.looping = "false"; if (this.completionResolve) this.completionResolve(); this.completionResolve = null; this.completionPromise = null; @@ -154,7 +166,7 @@ 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()); + this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now()); } } @@ -169,8 +181,11 @@ return; } } else { - const holdAt = this.scene === "fortune" ? 11000 : 10800; - this.draw(Math.min(now - this.startedAt, holdAt), now); + const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL; + const elapsed = Math.max(0, now - this.startedAt); + const timeline = elapsed % total; + this.canvas.dataset.cycle = String(Math.floor(elapsed / total)); + this.draw(timeline, now); } this.frameId = requestAnimationFrame((time) => this.frame(time)); } @@ -295,6 +310,71 @@ return hold ? enter : enter * (1 - smooth(end - fade, end, time)); } + fortuneStages() { + const sixQi = this.data.sixQi || {}; + const pillar = this.data.yearPillar || "岁运"; + const movement = this.data.movement || "中运合参"; + const sitian = sixQi.sitian || "司天气候"; + return [ + [0, 2100, "五 运", "木火土金水,五运相袭,周而复始"], + [2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"], + [3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"], + [5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"], + [7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`], + [11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"], + ]; + } + + drawFooter(time, now, total, stages, scene) { + const { context: ctx, width, height } = this; + const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0]; + const labelAlpha = smooth(stage[0], stage[0] + 300, time) + * (1 - smooth(stage[1] - 250, stage[1], time)); + this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600"); + this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32); + + const baseSlotWidth = 34; + const baseSlotHeight = 5; + const baseSlotGap = 12; + const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5; + const fit = Math.min(1, (width - 28) / baseTotalWidth); + const slotWidth = baseSlotWidth * fit; + const slotHeight = baseSlotHeight * fit; + const slotGap = baseSlotGap * fit; + const totalWidth = slotWidth * 6 + slotGap * 5; + const filled = Math.min(6, Math.floor(time / (total / 6))); + for (let index = 0; index < 6; index += 1) { + const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap); + const y = height - 56; + const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD; + ctx.save(); + ctx.globalAlpha = 0.16; + ctx.strokeStyle = GOLD; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, slotWidth, slotHeight); + ctx.restore(); + if (index < filled) { + ctx.save(); + ctx.globalAlpha = 0.9; + ctx.fillStyle = color; + ctx.shadowColor = color; + ctx.shadowBlur = 8; + ctx.fillRect(x, y, slotWidth, slotHeight); + ctx.restore(); + } else if (index === filled) { + ctx.save(); + ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200); + ctx.fillStyle = color; + const progress = (time % (total / 6)) / (total / 6); + ctx.fillRect(x, y, slotWidth * progress, slotHeight); + ctx.restore(); + } + } + const dots = ".".repeat(1 + Math.floor(now / 450) % 3); + const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中"; + this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75); + } + drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) { const ctx = this.context; ctx.save(); @@ -364,8 +444,8 @@ 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); + if (time >= 6800 && time < 10800) { + const alpha = this.stageAlpha(time, 6800, 10800, 350); this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time); const ringRadius = scale * 0.365; const hexWidth = Math.max(scale * 0.026, 13); @@ -395,6 +475,17 @@ this.label(`第 ${current + 1} 卦`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55); } } + if (time >= 10800) { + const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420); + const progress = easeOut((time - 10850) / 1150); + const radius = scale * 0.365 * (1 - progress); + for (let index = 0; index < 64 && radius >= 8; index += 1) { + const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64); + this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha); + } + this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40); + } + this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram"); } drawFortune(time, now) { @@ -406,7 +497,18 @@ 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); + if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale); + if (time >= 11000) { + const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420); + const progress = easeOut((time - 11050) / 1200); + const radius = scale * 0.30 * (1 - progress); + QI6.forEach((qi, index) => { + const [x, y] = point(cx, cy, radius, -90 + index * 60); + if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8); + }); + this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40); + } + this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune"); } drawFiveMovements(time, now, cx, cy, scale) { @@ -512,7 +614,7 @@ } drawAnnualQi(time, now, cx, cy, scale) { - const alpha = this.stageAlpha(time, 7900, 11000, 350, true); + const alpha = this.stageAlpha(time, 7900, 11000, 350); const sixQi = this.data.sixQi || {}; const pillar = this.data.yearPillar || "岁运"; const movement = this.data.movement || "中运合参"; @@ -554,25 +656,11 @@ drawCompletion(progress, now) { this.drawBackground(now); - const { width, height } = this; - const cx = width / 2; - const cy = height * 0.44; - const scale = Math.min(width, height); - const eased = easeOut(progress); if (this.scene === "fortune") { - const radius = scale * 0.30 * (1 - eased); - QI6.forEach((qi, index) => { - const [x, y] = point(cx, cy, radius, -90 + index * 60); - if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - eased) * 0.8, 8); - }); + this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now); } else { - const radius = scale * 0.365 * (1 - eased); - for (let index = 0; index < 64 && radius > 8; index += 1) { - const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64); - this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - eased) * 0.7); - } + this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now); } - this.node(cx, cy, 3 + eased * 6, GOLD_BRIGHT, 0.3 + eased * 0.7, 12 + eased * 40); } } diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 365c1d9..3534a5a 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -354,6 +354,7 @@ test("heaven reading loading uses the matching canvas scene and stops cleanly", await expect(canvas).toBeVisible(); await expect(canvas).toHaveAttribute("data-scene", scene); await expect(canvas).toHaveAttribute("data-running", "true"); + await expect(canvas).toHaveAttribute("data-looping", "true"); await page.waitForTimeout(180); const pixels = await canvas.evaluate((element) => { const context = element.getContext("2d"); @@ -368,6 +369,9 @@ test("heaven reading loading uses the matching canvas scene and stops cleanly", expect(pixels.width).toBeGreaterThan(300); expect(pixels.height).toBeGreaterThan(300); expect(pixels.colors).toBeGreaterThan(3); + await page.evaluate(() => { heavenReadingAnimation.startedAt = performance.now() - 13_100; }); + await expect(canvas).toHaveAttribute("data-cycle", "1"); + await expect(canvas).toHaveAttribute("data-running", "true"); if (mode === "trend") { const completionMs = await page.evaluate(async () => { const started = performance.now(); @@ -380,6 +384,7 @@ test("heaven reading loading uses the matching canvas scene and stops cleanly", } await page.locator("#closeHeavenReadingDialog").click(); await expect(canvas).toHaveAttribute("data-running", "false"); + await expect(canvas).toHaveAttribute("data-looping", "false"); } });