migration: establish exact preserved app baseline

This commit is contained in:
leefer
2026-07-30 23:51:48 +08:00
parent 41329943c4
commit 4083dceba3
399 changed files with 129967 additions and 23 deletions
+9283
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+723
View File
@@ -0,0 +1,723 @@
(function exposeHeavenLoading(global) {
"use strict";
// Theme palettes share the original animation geometry and timing.
const LOADING_PALETTES = {
dark: {
paper: "#05060d",
paperCenter: "#10142a",
paperMiddle: "#0b0e1e",
nodeText: "#f7e3b4",
ink: "#e6c37a",
inkBright: "#f7e3b4",
gold: "#e6c37a",
goldBright: "#f7e3b4",
cinnabar: "#d8564a",
dim: "rgba(216,205,180,0.55)",
particles: ["#e6c37a", "#d8564a", "#6d7fa8"],
},
light: {
paper: "#eef1f4",
paperCenter: "#fffefa",
paperMiddle: "#f4f2eb",
nodeText: "#493a20",
ink: "#8a641d",
inkBright: "#624612",
gold: "#946b1d",
goldBright: "#765315",
cinnabar: "#b94f46",
dim: "rgba(52,58,67,0.62)",
particles: ["#946b1d", "#b94f46", "#73859c"],
},
};
let PAPER;
let PAPER_CENTER;
let PAPER_MIDDLE;
let NODE_TEXT;
let INK;
let INK_BRIGHT;
let GOLD;
let GOLD_BRIGHT;
let CINNABAR;
let DIM;
let PARTICLE_COLORS;
const applyLoadingPalette = () => {
const theme = document.documentElement.dataset.theme === "light" ? "light" : "dark";
const palette = LOADING_PALETTES[theme];
PAPER = palette.paper;
PAPER_CENTER = palette.paperCenter;
PAPER_MIDDLE = palette.paperMiddle;
NODE_TEXT = palette.nodeText;
INK = palette.ink;
INK_BRIGHT = palette.inkBright;
GOLD = palette.gold;
GOLD_BRIGHT = palette.goldBright;
CINNABAR = palette.cinnabar;
DIM = palette.dim;
PARTICLE_COLORS = palette.particles;
return theme;
};
applyLoadingPalette();
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
const ELEMENT_COLORS = {
: "#4f7a4a",
: "#b3483d",
: "#96702c",
: "#70685b",
: "#496d92",
};
const QI6 = [
{ name: "厥阴风木", element: "木" },
{ name: "少阴君火", element: "火" },
{ name: "少阳相火", element: "火" },
{ name: "太阴湿土", element: "土" },
{ name: "阳明燥金", element: "金" },
{ name: "太阳寒水", element: "水" },
];
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
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_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));
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.theme = document.documentElement.dataset.theme || "dark";
this.stars = this.createStars(this.reducedMotion ? 48 : 150);
}
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.3 + random() * 1.3,
phase: random() * Math.PI * 2,
speed: 0.00015 + random() * 0.0004,
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
}));
}
start(scene, data = {}) {
this.theme = applyLoadingPalette();
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.canvas.dataset.looping = "true";
this.resizeObserver.observe(this.canvas);
this.resize();
if (this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, 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(), 2200);
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";
this.canvas.dataset.looping = "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" ? 10950 : 10600, performance.now());
}
}
frame(now) {
if (!this.running) return;
if (this.completingAt) {
const duration = this.scene === "fortune" ? 1800 : 1700;
const progress = clamp01((now - this.completingAt) / duration);
this.drawCompletion(progress, now);
if (progress >= 1) {
this.stop();
return;
}
} else {
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));
}
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 currentTheme = document.documentElement.dataset.theme || "dark";
if (currentTheme !== this.theme) this.theme = applyLoadingPalette();
const { context: ctx, width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
gradient.addColorStop(0, PAPER_CENTER);
gradient.addColorStop(0.52, PAPER_MIDDLE);
gradient.addColorStop(1, PAPER);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
for (const star of this.stars) {
const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
const alpha = twinkle * 0.5;
ctx.globalAlpha = alpha;
ctx.fillStyle = PARTICLE_COLORS[star.colorIndex];
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();
}
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
if (alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(mx, my, x2, y2);
ctx.stroke();
const angle = Math.atan2(y2 - my, x2 - mx);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
ctx.closePath();
ctx.fill();
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));
}
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();
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.006 * Math.sin(now / 620);
TRIGRAMS.forEach((trigram, index) => {
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 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 * 8);
const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
});
}
drawHexagram(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const scale = Math.min(width, Math.max(1, height - 150));
if (time < 1800) {
const alpha = this.stageAlpha(time, 0, 1800);
this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
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.22 * 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, 5);
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
}
if (time >= 3300 && time < 4700) {
const alpha = this.stageAlpha(time, 3300, 4700);
const distance = scale * 0.085;
const yaoWidth = Math.max(scale * 0.055, 28);
const yaoLine = Math.max(scale * 0.009, 3.5);
SIXIANG.forEach((symbol, index) => {
const progress = easeOut((time - 3330 - index * 160) / 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, 10);
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
});
}
const trigramRadius = scale * 0.215;
const trigramWidth = Math.max(scale * 0.052, 26);
const trigramLine = Math.max(scale * 0.0075, 3);
if (time >= 4700 && time < 6800) {
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
}
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);
const hexLine = Math.max(scale * 0.0042, 1.6);
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.4, GOLD, alpha * 0.14);
if (index < count) {
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
if (freshness > 0) {
const ctx = this.context;
const gradient = ctx.createLinearGradient(cx, cy, x, y);
gradient.addColorStop(0, "rgba(230,195,122,0)");
gradient.addColorStop(1, GOLD);
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
}
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
}
}
if (count > 0) {
const current = count - 1;
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
const pop = 1 + 0.22 * (1 - popTime);
this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
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) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const scale = Math.min(width, Math.max(1, height - 150));
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 && 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) {
const alpha = this.stageAlpha(time, 0, 2100);
const radius = scale * 0.17;
const nodeRadius = Math.max(scale * 0.018, 9);
const elements = [
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
];
const positions = {};
this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
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, 16);
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
const direction = element === "土" ? "中央土" : { : "东方木", : "南方火", : "西方金", : "北方水" }[element];
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
});
const order = ["木", "火", "土", "金", "水"];
order.forEach((element, index) => {
const from = positions[element];
const to = positions[order[(index + 1) % order.length]];
if (!from || !to) return;
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
});
}
drawStems(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 2100, 3900);
const stems = "甲乙丙丁戊己庚辛壬癸";
const movements = ["土", "金", "水", "木", "火"];
const radius = scale * 0.30;
for (let index = 0; index < 10; index += 1) {
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
if (progress <= 0) continue;
const [x, y] = point(cx, cy, radius, -90 + index * 36);
const element = movements[index % 5];
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
}
for (let index = 0; index < 5; index += 1) {
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
const angle = -90 + index * 36;
const [x1, y1] = point(cx, cy, radius, angle);
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
this.label(movements[index], labelX, labelY, 16, 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.31;
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
for (let index = 0; index < 12; index += 1) {
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
const [x, y] = point(cx, cy, radius, branchAngle(index));
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
}
qiNames.forEach((name, index) => {
const progress = smooth(4900 + index * 130, 5200 + index * 130, 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.4);
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
});
}
drawSixQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 5800, 7900);
const radius = scale * 0.27;
const drift = now * 0.004;
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 * 180) / 550);
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
});
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
}
drawAnnualQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 7900, 11000, 350);
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));
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
const radius = scale * 0.30;
QI6.forEach((qi, index) => {
const progress = smooth(9200 + index * 260, 9480 + index * 260, 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.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
if (current) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
ctx.strokeStyle = CINNABAR;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
}
const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
});
}
drawCompletion(progress, now) {
this.drawBackground(now);
if (this.scene === "fortune") {
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
} else {
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
}
}
}
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
})(window);
+672
View File
@@ -0,0 +1,672 @@
(function exposeHeavenLoading(global) {
"use strict";
const PAPER = "#fdfcf8";
const PAPER_CENTER = "#f1e8d9";
const NODE_TEXT = "#fffaf0";
const INK = "#68493d";
const INK_BRIGHT = "#963f37";
const GOLD = "#80533e";
const GOLD_BRIGHT = "#b64e43";
const CINNABAR = "#b94038";
const DIM = "rgba(68,57,49,0.62)";
const PARTICLE_COLORS = ["#a94b42", "#456b62", "#506b85"];
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
const ELEMENT_COLORS = {
: "#4f7a4a",
: "#b3483d",
: "#96702c",
: "#70685b",
: "#496d92",
};
const QI6 = [
{ name: "厥阴风木", element: "木" },
{ name: "少阴君火", element: "火" },
{ name: "少阳相火", element: "火" },
{ name: "太阴湿土", element: "土" },
{ name: "阳明燥金", element: "金" },
{ name: "太阳寒水", element: "水" },
];
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
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_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));
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 : 150);
}
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.3 + random() * 1.3,
phase: random() * Math.PI * 2,
speed: 0.00015 + random() * 0.0004,
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
}));
}
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.canvas.dataset.looping = "true";
this.resizeObserver.observe(this.canvas);
this.resize();
if (this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, 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(), 2200);
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";
this.canvas.dataset.looping = "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" ? 10950 : 10600, performance.now());
}
}
frame(now) {
if (!this.running) return;
if (this.completingAt) {
const duration = this.scene === "fortune" ? 1800 : 1700;
const progress = clamp01((now - this.completingAt) / duration);
this.drawCompletion(progress, now);
if (progress >= 1) {
this.stop();
return;
}
} else {
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));
}
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.44;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
gradient.addColorStop(0, PAPER_CENTER);
gradient.addColorStop(0.52, "#faf7ef");
gradient.addColorStop(1, PAPER);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
for (const star of this.stars) {
const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
const alpha = twinkle * 0.5;
ctx.globalAlpha = alpha;
ctx.fillStyle = PARTICLE_COLORS[star.colorIndex];
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();
}
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
if (alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(mx, my, x2, y2);
ctx.stroke();
const angle = Math.atan2(y2 - my, x2 - mx);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
ctx.closePath();
ctx.fill();
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));
}
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();
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.006 * Math.sin(now / 620);
TRIGRAMS.forEach((trigram, index) => {
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 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 * 8);
const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
});
}
drawHexagram(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.44;
const scale = Math.min(width, height);
if (time < 1800) {
const alpha = this.stageAlpha(time, 0, 1800);
this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
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.22 * 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, 5);
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
}
if (time >= 3300 && time < 4700) {
const alpha = this.stageAlpha(time, 3300, 4700);
const distance = scale * 0.085;
const yaoWidth = Math.max(scale * 0.055, 28);
const yaoLine = Math.max(scale * 0.009, 3.5);
SIXIANG.forEach((symbol, index) => {
const progress = easeOut((time - 3330 - index * 160) / 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, 10);
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
});
}
const trigramRadius = scale * 0.215;
const trigramWidth = Math.max(scale * 0.052, 26);
const trigramLine = Math.max(scale * 0.0075, 3);
if (time >= 4700 && time < 6800) {
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
}
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);
const hexLine = Math.max(scale * 0.0042, 1.6);
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.4, GOLD, alpha * 0.14);
if (index < count) {
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
if (freshness > 0) {
const ctx = this.context;
const gradient = ctx.createLinearGradient(cx, cy, x, y);
gradient.addColorStop(0, "rgba(128,83,62,0)");
gradient.addColorStop(1, GOLD);
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
}
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
}
}
if (count > 0) {
const current = count - 1;
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
const pop = 1 + 0.22 * (1 - popTime);
this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
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) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.44;
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 && 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) {
const alpha = this.stageAlpha(time, 0, 2100);
const radius = scale * 0.17;
const nodeRadius = Math.max(scale * 0.018, 9);
const elements = [
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
];
const positions = {};
this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
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, 16);
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
const direction = element === "土" ? "中央土" : { : "东方木", : "南方火", : "西方金", : "北方水" }[element];
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
});
const order = ["木", "火", "土", "金", "水"];
order.forEach((element, index) => {
const from = positions[element];
const to = positions[order[(index + 1) % order.length]];
if (!from || !to) return;
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
});
}
drawStems(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 2100, 3900);
const stems = "甲乙丙丁戊己庚辛壬癸";
const movements = ["土", "金", "水", "木", "火"];
const radius = scale * 0.30;
for (let index = 0; index < 10; index += 1) {
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
if (progress <= 0) continue;
const [x, y] = point(cx, cy, radius, -90 + index * 36);
const element = movements[index % 5];
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
}
for (let index = 0; index < 5; index += 1) {
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
const angle = -90 + index * 36;
const [x1, y1] = point(cx, cy, radius, angle);
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
this.label(movements[index], labelX, labelY, 16, 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.31;
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
for (let index = 0; index < 12; index += 1) {
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
const [x, y] = point(cx, cy, radius, branchAngle(index));
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
}
qiNames.forEach((name, index) => {
const progress = smooth(4900 + index * 130, 5200 + index * 130, 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.4);
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
});
}
drawSixQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 5800, 7900);
const radius = scale * 0.27;
const drift = now * 0.004;
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 * 180) / 550);
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
});
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
}
drawAnnualQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 7900, 11000, 350);
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));
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
const radius = scale * 0.30;
QI6.forEach((qi, index) => {
const progress = smooth(9200 + index * 260, 9480 + index * 260, 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.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
if (current) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
ctx.strokeStyle = CINNABAR;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
}
const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
});
}
drawCompletion(progress, now) {
this.drawBackground(now);
if (this.scene === "fortune") {
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
} else {
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
}
}
}
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
})(window);
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
(function exposePageRegistry(global) {
"use strict";
const pages = [
["sentimentCycleView", "情绪周期", "sentiment", "market", "authenticated", true],
["limitPool", "涨停池", "pools", "market", "authenticated", false],
["brokenView", "炸板池", "pools", "market", "authenticated", false],
["downView", "跌停板", "pools", "market", "authenticated", false],
["yesterdayView", "昨日涨停", "pools", "market", "authenticated", false],
["performanceView", "涨停表现", "pools", "market", "authenticated", false],
["ladderView", "市场天梯", "ladder", "market", "authenticated", false],
["rotationView", "板块轮动", "rotation", "market", "authenticated", false],
["auctionView", "集合竞价", "auction", "market", "authenticated", false],
["themeLibraryView", "题材库", "themes", "market", "authenticated", false],
["popularityView", "人气热榜", "popularity", "market", "authenticated", false],
["dragonView", "龙虎榜", "dragon_tiger", "market", "authenticated", false],
["screenerView", "智能选股", "screener", "intelligence", "member", false],
["mentorView", "问师", "mentor", "intelligence", "member", false],
["heavenView", "问天", "heaven", "intelligence", "member", false],
["reviewWorkspaceView", "我的复盘", "review", "personal", "authenticated", false],
].map(([id, title, feature, group, access, isDefault]) => Object.freeze({
id,
title,
feature,
group,
access,
default: isDefault,
desktop_scroll: "page",
mobile_layout: "dedicated",
}));
const internalPages = [
Object.freeze({
id: "screenerTrackingView",
title: "策略持续跟踪",
feature: "screener",
group: "intelligence",
access: "member",
internal: true,
navigation_alias: "screenerView",
}),
];
const all = [...pages, ...internalPages];
const byId = new Map(all.map((page) => [page.id, page]));
const defaultPage = pages.find((page) => page.default);
const aliases = Object.freeze({ sectorView: "rotationView", breadthView: "limitPool" });
global.XiaobaiPages = Object.freeze({
schemaVersion: 1,
pages: Object.freeze(pages),
internalPages: Object.freeze(internalPages),
all: Object.freeze(all),
defaultPage,
aliases,
resolve(id) {
return aliases[id] || id;
},
get(id) {
return byId.get(id) || null;
},
has(id) {
return byId.has(id);
},
inGroup(id, group) {
return byId.get(id)?.group === group;
},
});
})(window);
+4
View File
@@ -0,0 +1,4 @@
window.XiaobaiPageModules.register("auction", ["auctionView"], {
enter: ["loadAuction"],
leave: ["clearAuction"],
});
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
enter: ["loadDragonTiger"],
});
+4
View File
@@ -0,0 +1,4 @@
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
enter: ["loadHeaven"],
leave: ["stopHeaven"],
});
+1
View File
@@ -0,0 +1 @@
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
enter: ["loadMentor"],
});
+7
View File
@@ -0,0 +1,7 @@
window.XiaobaiPageModules.register("pools", [
"limitPool",
"brokenView",
"downView",
"yesterdayView",
"performanceView",
]);
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
enter: ["loadPopularity"],
});
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
enter: ["loadReview"],
});
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
enter: ["loadRotation"],
});
+60
View File
@@ -0,0 +1,60 @@
(function exposePageModuleRuntime(global) {
"use strict";
const definitions = new Map();
let sealed = false;
function register(feature, viewIds, lifecycle = {}) {
if (sealed) throw new Error("Page module registry is already sealed");
if (!feature || !Array.isArray(viewIds) || !viewIds.length) {
throw new Error("Page modules require a feature and at least one view ID");
}
viewIds.forEach((viewId) => {
if (definitions.has(viewId)) throw new Error(`Duplicate page module: ${viewId}`);
definitions.set(viewId, Object.freeze({
feature,
viewId,
enter: Object.freeze([...(lifecycle.enter || [])]),
leave: Object.freeze([...(lifecycle.leave || [])]),
}));
});
}
function create(options) {
sealed = true;
const pages = options.pages;
const actions = Object.freeze({ ...(options.actions || {}) });
const missing = pages.all.filter((page) => !definitions.has(page.id)).map((page) => page.id);
if (missing.length) throw new Error(`Missing page modules: ${missing.join(", ")}`);
function run(actionNames, context) {
actionNames.forEach((actionName) => {
const action = actions[actionName];
if (typeof action !== "function") throw new Error(`Unknown page action: ${actionName}`);
action(context);
});
}
function beforeMount(viewId, previousView) {
actions.closeTransientUi?.({ viewId, previousView });
if (previousView && previousView !== viewId) {
run(definitions.get(previousView)?.leave || [], { viewId, previousView });
}
}
function afterMount(viewId, previousView) {
const context = { viewId, previousView };
actions.applyAccess?.(context);
run(definitions.get(viewId)?.enter || [], context);
}
return Object.freeze({
afterMount,
beforeMount,
get: (viewId) => definitions.get(viewId) || null,
has: (viewId) => definitions.has(viewId),
});
}
global.XiaobaiPageModules = Object.freeze({ create, register });
})(window);
+5
View File
@@ -0,0 +1,5 @@
window.XiaobaiPageModules.register("screener", ["screenerView"], {
enter: ["loadScreener"],
});
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
enter: ["loadSentiment"],
});
+3
View File
@@ -0,0 +1,3 @@
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
enter: ["loadThemes"],
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
(function exposeApiClient(global) {
"use strict";
let csrfTokenSupplier = () => "";
let unauthorizedHandler = () => {};
class ApiError extends Error {
constructor(message, status = 0, payload = null) {
super(message);
this.name = "ApiError";
this.status = status;
this.payload = payload;
}
}
function configure(options = {}) {
if (typeof options.csrfToken === "function") csrfTokenSupplier = options.csrfToken;
if (typeof options.onUnauthorized === "function") unauthorizedHandler = options.onUnauthorized;
}
function requestOptions(method, body, signal) {
const normalizedMethod = String(method || "GET").toUpperCase();
const options = { method: normalizedMethod, headers: {}, signal };
const csrfToken = csrfTokenSupplier();
if (!["GET", "HEAD", "OPTIONS"].includes(normalizedMethod) && csrfToken) {
options.headers["X-CSRF-Token"] = csrfToken;
}
if (body !== null && body !== undefined) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
return options;
}
async function parseJson(response) {
try {
return await response.json();
} catch (_error) {
return {};
}
}
function handleUnauthorized(response, url) {
if (response.status === 401 && !String(url).startsWith("/api/auth/")) {
unauthorizedHandler({ response, url });
}
}
async function request(url, method = "GET", body = null, options = {}) {
const response = await fetch(url, requestOptions(method, body, options.signal));
const payload = await parseJson(response);
handleUnauthorized(response, url);
if (!response.ok || payload.error) {
throw new ApiError(payload.error || "请求失败", response.status, payload);
}
return payload;
}
async function streamNdjson(url, options = {}) {
const response = await fetch(
url,
requestOptions(options.method || "POST", options.body, options.signal),
);
if (!response.ok) {
const payload = await parseJson(response);
handleUnauthorized(response, url);
throw new ApiError(
payload.error || options.errorMessage || "流式请求暂不可用",
response.status,
payload,
);
}
if (!response.body) throw new ApiError("当前浏览器不支持流式回答", response.status);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const consume = (line) => {
if (!line.trim()) return;
let event;
try {
event = JSON.parse(line);
} catch (_error) {
throw new ApiError("流式响应格式错误", response.status);
}
if (event.type === "error") {
throw new ApiError(event.error || options.errorMessage || "流式请求失败", response.status, event);
}
options.onEvent?.(event);
};
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
lines.forEach(consume);
if (done) break;
}
if (buffer.trim()) consume(buffer);
}
global.XiaobaiAPI = Object.freeze({ ApiError, configure, request, streamNdjson });
})(window);
+54
View File
@@ -0,0 +1,54 @@
(function exposeSharedComponents(global) {
"use strict";
const ui = global.XiaobaiUI;
if (!ui) throw new Error("XiaobaiUI must load before shared components");
function resolveElement(target, root = document) {
if (target instanceof Element) return target;
if (typeof target !== "string" || !target) return null;
return target.startsWith("#") ? root.querySelector(target) : root.getElementById?.(target);
}
function classNames(...values) {
return values.flatMap((value) => String(value || "").split(/\s+/)).filter(Boolean).join(" ");
}
function emptyStateHtml(message, options = {}) {
const classes = classNames("empty-state", options.className);
const attributes = options.role ? ` role="${ui.escapeHtml(options.role)}"` : "";
return `<div class="${ui.escapeHtml(classes)}"${attributes}>${ui.escapeHtml(message)}</div>`;
}
function renderEmptyState(target, message, options = {}) {
const element = resolveElement(target, options.root);
if (!element) return false;
element.innerHTML = emptyStateHtml(message, options);
return true;
}
function setText(target, value, options = {}) {
const element = resolveElement(target, options.root);
if (!element) return false;
element.textContent = value == null ? "" : String(value);
return true;
}
function renderCollection(target, items, renderItem, options = {}) {
const element = resolveElement(target, options.root);
if (!element) return 0;
const rows = Array.isArray(items) ? items : [];
element.innerHTML = rows.length
? rows.map((item, index) => renderItem(item, index)).join("")
: emptyStateHtml(options.emptyMessage || "", options.emptyOptions);
return rows.length;
}
global.XiaobaiComponents = Object.freeze({
classNames,
emptyStateHtml,
renderCollection,
renderEmptyState,
setText,
});
})(window);
+194
View File
@@ -0,0 +1,194 @@
(function exposeApplicationShell(global) {
"use strict";
const SIDEBAR_STORAGE_KEY = "xiaobai-sidebar-collapsed";
function create(options) {
const state = options.state;
const registry = options.pages;
let initialized = false;
function toggleHeaderCommandMenu(force) {
const menu = document.querySelector("#headerCommandGroup");
const button = document.querySelector("#headerMenuButton");
if (!menu || !button) return;
const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open");
menu.classList.toggle("is-open", open);
button.setAttribute("aria-expanded", String(open));
}
function updateSidebarControl() {
const button = document.querySelector("#sidebarCollapseButton");
if (!button) return;
const automaticallyCollapsed = global.innerWidth <= 1023 && global.innerWidth > 720;
const collapsed = document.body.classList.contains("sidebar-collapsed") || automaticallyCollapsed;
button.setAttribute("aria-expanded", String(!collapsed));
button.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏");
button.title = collapsed ? "展开侧栏" : "收起侧栏";
const label = button.querySelector("span");
if (label) label.textContent = collapsed ? "展开侧栏" : "收起侧栏";
}
function toggleSidebar() {
const collapsed = document.body.classList.toggle("sidebar-collapsed");
try {
global.localStorage.setItem(SIDEBAR_STORAGE_KEY, collapsed ? "1" : "0");
} catch (_error) {
// The shell remains usable when storage is unavailable.
}
updateSidebarControl();
}
function syncNavigation(viewId) {
const page = registry.get(viewId);
const navigationId = page?.navigation_alias || viewId;
const marketView = page?.group === "market";
document.body.dataset.activeView = viewId;
document.querySelectorAll(".module-tab").forEach((button) => {
button.classList.toggle("active", button.dataset.view === navigationId);
button.classList.toggle(
"mobile-active",
global.innerWidth <= 720
&& marketView
&& button.dataset.view === "limitPool"
&& viewId !== "limitPool",
);
});
const selector = document.querySelector("#mobileMarketSelector");
const select = document.querySelector("#mobileMarketViewSelect");
if (selector) selector.hidden = !marketView;
if (select && marketView) select.value = viewId;
toggleHeaderCommandMenu(false);
options.onNavigationSync?.(viewId);
}
function setStatus(text) {
const status = document.querySelector("#statusText");
if (status) status.textContent = text;
}
function setPageStatus(viewId, tradeDate = "") {
const page = registry.get(viewId);
const label = page?.title || "小白复盘";
setStatus(tradeDate && tradeDate !== "--" ? `${label} · 数据日期 ${tradeDate}` : `${label} · 等待数据`);
}
function openModalDialog(dialog) {
if (!(dialog instanceof HTMLDialogElement)) return;
document.querySelectorAll("dialog[open]").forEach((openDialog) => {
if (openDialog !== dialog) openDialog.close();
});
if (!dialog.open) dialog.showModal();
}
function mount(viewId, mountOptions = {}) {
const page = registry.get(viewId);
const view = document.getElementById(viewId);
if (!page || !view?.classList.contains("workspace-view")) return false;
const previousView = state.activeView;
options.onBeforeMount?.(viewId, previousView);
state.activeView = viewId;
document.querySelectorAll(".workspace-view").forEach((candidate) => {
const active = candidate.id === viewId;
candidate.classList.toggle("active-view", active);
candidate.classList.remove("view-entering");
if (active && options.motionEnabled?.()) {
void candidate.offsetWidth;
candidate.classList.add("view-entering");
candidate.addEventListener(
"animationend",
() => candidate.classList.remove("view-entering"),
{ once: true },
);
const body = candidate.querySelector("tbody");
if (body) options.animateRows?.(body);
}
});
syncNavigation(viewId);
setPageStatus(viewId, options.tradeDate?.() || "");
if (mountOptions.updateUrl !== false) {
const url = new URL(global.location.href);
url.searchParams.set("view", viewId);
url.hash = "";
global.history.replaceState(null, "", url);
}
global.scrollTo({ top: 0, behavior: "auto" });
options.onAfterMount?.(viewId, previousView);
return true;
}
function initialize() {
if (initialized) return;
initialized = true;
let collapsed = false;
try {
collapsed = global.localStorage.getItem(SIDEBAR_STORAGE_KEY) === "1";
} catch (_error) {
collapsed = false;
}
document.body.classList.toggle("sidebar-collapsed", collapsed);
updateSidebarControl();
syncNavigation(state.activeView);
document.querySelectorAll(".module-tab").forEach((button) => {
button.addEventListener("click", () => options.onNavigate?.(button.dataset.view));
});
document.querySelectorAll("[data-open-view]").forEach((button) => {
button.addEventListener("click", () => options.onNavigate?.(button.dataset.openView));
});
document.querySelector("#mobileMarketViewSelect")?.addEventListener("change", (event) => {
options.onNavigate?.(event.target.value);
});
document.querySelector("#sidebarCollapseButton")?.addEventListener("click", toggleSidebar);
document.querySelector("#headerMenuButton")?.addEventListener("click", (event) => {
event.stopPropagation();
toggleHeaderCommandMenu();
});
document.querySelector("#headerCommandGroup")?.addEventListener("click", (event) => {
if (event.target.closest("button") && !event.target.closest(".account-menu-shell")) {
toggleHeaderCommandMenu(false);
}
});
document.querySelector("#overviewToggle")?.addEventListener("click", (event) => {
const overview = document.querySelector(".overview-strip");
if (!overview) return;
const expanded = overview.dataset.overviewExpanded !== "true";
overview.dataset.overviewExpanded = String(expanded);
event.currentTarget.setAttribute("aria-expanded", String(expanded));
event.currentTarget.title = expanded ? "收起市场详情" : "展开市场详情";
const label = event.currentTarget.querySelector("span");
if (label) label.textContent = expanded ? "收起详情" : "展开详情";
event.currentTarget.querySelector("i")?.setAttribute(
"data-lucide",
expanded ? "chevron-up" : "chevron-down",
);
options.refreshIcons?.();
});
document.addEventListener("click", (event) => {
if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") toggleHeaderCommandMenu(false);
});
global.addEventListener("resize", () => {
if (global.innerWidth > 720) toggleHeaderCommandMenu(false);
updateSidebarControl();
syncNavigation(state.activeView);
});
}
return Object.freeze({
initialize,
mount,
openModalDialog,
page: (viewId) => registry.get(viewId),
setPageStatus,
setStatus,
syncNavigation,
toggleHeaderCommandMenu,
toggleSidebar,
updateSidebarControl,
});
}
global.XiaobaiShell = Object.freeze({ create });
})(window);
+44
View File
@@ -0,0 +1,44 @@
(function exposeStateStore(global) {
"use strict";
function create(domains) {
const owners = new Map();
const stores = {};
Object.entries(domains).forEach(([domain, values]) => {
stores[domain] = { ...values };
Object.keys(values).forEach((key) => {
if (owners.has(key)) throw new Error(`Duplicate state field: ${key}`);
owners.set(key, domain);
});
});
const proxy = new Proxy({}, {
get(_target, key) {
if (key === "domain") return (name) => stores[name];
if (key === "domains") return Object.freeze({ ...stores });
if (typeof key !== "string" || !owners.has(key)) return undefined;
return stores[owners.get(key)][key];
},
set(_target, key, value) {
if (typeof key !== "string" || !owners.has(key)) {
throw new Error(`Unregistered application state field: ${String(key)}`);
}
stores[owners.get(key)][key] = value;
return true;
},
has(_target, key) {
return key === "domain" || key === "domains" || owners.has(key);
},
ownKeys() {
return [...owners.keys()];
},
getOwnPropertyDescriptor(_target, key) {
if (!owners.has(key)) return undefined;
return { enumerable: true, configurable: true };
},
});
return proxy;
}
global.XiaobaiState = Object.freeze({ create });
})(window);
+402
View File
@@ -0,0 +1,402 @@
/*
* Canonical frontend tokens.
*
* Ownership flows in one direction:
* primitive values -> semantic meaning -> component contracts.
* Historical variable names remain aliases so page CSS can migrate without
* changing the rendered interface.
*/
:root {
color-scheme: light;
/* Primitive tokens */
--color-white: #ffffff;
--color-gray-25: #fcfcfd;
--color-gray-50: #f8f9fb;
--color-gray-100: #f2f4f7;
--color-gray-200: #e5e8ee;
--color-gray-300: #d4d9e2;
--color-gray-500: #697386;
--color-gray-700: #344054;
--color-gray-900: #172033;
--color-shell-canvas: #f1f4f6;
--color-page-canvas: #f4f5f7;
--color-surface-muted: #f6f8fa;
--color-surface-subtle: #f8fafc;
--color-border: #e5e7eb;
--color-border-strong: #d1d5db;
--color-text-primary: #1f2937;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-action-base: #1769c2;
--color-action-base-hover: #10569f;
--color-action-base-soft: #eaf2fb;
--color-action: #2563eb;
--color-action-hover: #1d4ed8;
--color-action-soft: #eff4ff;
--color-action-line: #c7d8fb;
--color-market-up-base: #d33f49;
--color-market-up-base-soft: #fff0f1;
--color-market-up: #e04536;
--color-market-up-soft: #fdecea;
--color-market-down-base: #07805b;
--color-market-down-base-soft: #eaf7f2;
--color-market-down: #16a34a;
--color-market-down-soft: #e9f7ee;
--color-warning-base: #aa6800;
--color-warning-base-soft: #fff6e5;
--color-warning: #b45309;
--color-warning-soft: #fdf3e3;
--size-radius-sm: 5px;
--size-radius-md: 7px;
--size-radius-lg: 10px;
--size-control: 32px;
--size-sidebar: 200px;
--size-topbar: 46px;
--size-summary: 34px;
--size-statusbar: 30px;
--size-page-pad-y: 14px;
--size-page-pad-x: 16px;
--size-card-gap: 12px;
--elevation-card: 0 1px 2px rgba(16, 24, 40, .05);
--elevation-soft: 0 1px 2px rgba(22, 34, 46, .04), 0 5px 18px rgba(22, 34, 46, .035);
--elevation-raised: 0 4px 14px rgba(16, 24, 40, .06);
--elevation-float: 0 14px 38px rgba(16, 24, 40, .14);
--motion-instant: 100ms;
--motion-fast: 140ms;
--motion-medium: 200ms;
--motion-deliberate: 260ms;
--motion-slow: 560ms;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
/* Semantic tokens */
--canvas: var(--color-shell-canvas);
--surface: var(--color-white);
--surface-muted: var(--color-surface-muted);
--surface-subtle: var(--color-surface-subtle);
--surface-canvas: var(--color-page-canvas);
--surface-raised: var(--color-white);
--surface-selected: #eef4ff;
--border: var(--color-border);
--border-strong: var(--color-border-strong);
--text-primary: var(--color-text-primary);
--text-secondary: var(--color-text-secondary);
--text-tertiary: var(--color-text-tertiary);
--action: var(--color-action-base);
--action-hover: var(--color-action-base-hover);
--action-soft: var(--color-action-base-soft);
--market-up: var(--color-market-up-base);
--market-up-soft: var(--color-market-up-base-soft);
--market-down: var(--color-market-down-base);
--market-down-soft: var(--color-market-down-base-soft);
--warning-color: var(--color-warning-base);
--warning-soft: var(--color-warning-base-soft);
--primary: var(--color-action);
--primary-hover: var(--color-action-hover);
--danger: var(--color-market-up);
--success: var(--color-market-down);
--warning: var(--color-warning);
/* Component tokens */
--card-bg: var(--surface-raised);
--card-border: var(--border);
--card-radius: var(--size-radius-lg);
--card-shadow: var(--elevation-card);
--control-height: var(--size-control);
--page-gap: var(--size-card-gap);
--radius-sm: var(--size-radius-sm);
--radius-md: var(--size-radius-md);
--radius-lg: var(--size-radius-lg);
--shadow-xs: var(--elevation-card);
--shadow-sm: var(--elevation-raised);
--shadow-float: var(--elevation-float);
--duration-fast: 150ms;
--duration-normal: 220ms;
--sidebar-width: var(--size-sidebar);
--topbar-height: var(--size-topbar);
--summary-height: var(--size-summary);
--statusbar-height: var(--size-statusbar);
--page-pad-y: var(--size-page-pad-y);
--page-pad-x: var(--size-page-pad-x);
--card-gap: var(--size-card-gap);
--workspace-height: calc(100vh - var(--topbar-height) - var(--statusbar-height));
--content-height: calc(var(--workspace-height) - var(--summary-height));
--table-wide: 1180px;
--table-medium: 930px;
--table-compact: 720px;
--col-rank: 44px;
--col-date: 94px;
--col-stock: 160px;
--col-number: 96px;
--col-action: 96px;
--col-text: 220px;
--right-rail-wide: 372px;
--pool-table-max-height: calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap));
--sentiment-history-max-height: 510px;
--sentiment-history-min-height: 220px;
--primary-share: 1.45fr;
--secondary-share: .75fr;
--mobile-nav-height: 58px;
--mobile-header-height: 50px;
--mobile-tab-height: 54px;
--mobile-shell-pad: 8px;
--mobile-page-pad: 10px;
--mobile-min-width: 320px;
--space-4: 4px;
--font-aux: 10.5px;
--dragon-profile-list-width: 340px;
--dragon-profile-detail-min-height: 460px;
--dragon-profile-list-max-height: 320px;
--dragon-profile-row-min-height: 64px;
--dragon-profile-row-avatar-size: 36px;
--dragon-profile-avatar-size: 72px;
--dragon-profile-control-height: 33px;
--dragon-profile-gap: 12px;
--dragon-profile-panel-padding: 16px;
--dragon-profile-row-padding: 10px 12px;
--dragon-profile-title-font: 18px;
--dragon-profile-name-font: 13px;
--dragon-profile-body-font: 12px;
--dragon-profile-meta-font: 11px;
--dragon-profile-transition: 160ms ease;
--dragon-profile-border-width: 1px;
--dragon-profile-focus-width: 2px;
--dragon-profile-focus-offset: -2px;
--dragon-profile-radius-inset: 2px;
--dragon-profile-body-line-height: 1.75;
--dragon-profile-icon-stroke: 1;
--dragon-profile-weight-strong: 750;
--dragon-profile-weight-semibold: 600;
--chart-background: #fbfcfd;
--chart-grid: #e2e8ec;
--chart-axis: #6c7983;
--chart-zero: #aeb7c1;
--chart-line: #1d65c1;
--chart-average: #b7791f;
--chart-up: #c93f45;
--chart-down: #087a55;
--chart-up-volume: rgba(201, 63, 69, .58);
--chart-down-volume: rgba(8, 122, 85, .58);
--chart-area: rgba(37, 99, 235, .07);
--chart-alert-area: rgba(224, 69, 54, .05);
--chart-moving-average: #d1d5db;
--chart-repair: #f59e0b;
--chart-ma-10: #a76500;
--chart-ma-20: #626c78;
/* Compatibility aliases. Do not add new usage of these names. */
--xb-gray-25: var(--color-gray-25);
--xb-gray-50: var(--color-gray-50);
--xb-gray-100: var(--color-gray-100);
--xb-gray-200: var(--color-gray-200);
--xb-gray-300: var(--color-gray-300);
--xb-gray-500: var(--color-gray-500);
--xb-gray-700: var(--color-gray-700);
--xb-gray-900: var(--color-gray-900);
--xb-blue-50: #eef4ff;
--xb-blue-100: #dce8ff;
--xb-blue-500: var(--color-action);
--xb-blue-600: var(--color-action-hover);
--xb-red-50: #fff1f0;
--xb-red-500: var(--color-market-up);
--xb-green-50: #ecf9f1;
--xb-green-500: var(--color-market-down);
--xb-amber-50: #fff7e8;
--xb-amber-500: var(--color-warning);
--bg: var(--surface-canvas);
--card: var(--surface-raised);
--ink: var(--text-primary);
--sub: var(--text-secondary);
--faint: var(--text-tertiary);
--line: var(--border);
--line-soft: #eef0f3;
--line-strong: var(--border-strong);
--text: var(--text-primary);
--text-muted: var(--text-secondary);
--blue: var(--primary);
--blue-d: var(--primary-hover);
--blue-dark: var(--action-hover);
--blue-soft: var(--color-action-soft);
--blue-line: var(--color-action-line);
--up: var(--danger);
--up-soft: var(--color-market-up-soft);
--down: var(--success);
--down-soft: var(--color-market-down-soft);
--coral: var(--market-up);
--coral-soft: var(--market-up-soft);
--green: var(--market-down);
--green-soft: var(--market-down-soft);
--amber: var(--color-warning);
--amber-soft: var(--color-warning-soft);
--radius: var(--size-radius-lg);
--shadow: var(--elevation-card);
--shadow-soft: var(--elevation-soft);
--r2-blue: var(--primary);
--r2-blue-dark: var(--primary-hover);
--r2-blue-soft: var(--color-action-soft);
--r2-blue-line: var(--color-action-line);
--r2-up: var(--danger);
--r2-up-soft: var(--color-market-up-soft);
--r2-down: var(--success);
--r2-down-soft: var(--color-market-down-soft);
--r2-amber: var(--color-warning);
--r2-amber-soft: var(--color-warning-soft);
--r2-ink: var(--text-primary);
--r2-sub: var(--text-secondary);
--r2-faint: var(--text-tertiary);
--r2-line: var(--border);
--r2-line-soft: #eef0f3;
--r2-bg: var(--surface-canvas);
--r2-card: var(--surface-raised);
--r2-radius: var(--size-radius-lg);
--r2-shadow: var(--elevation-card);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", "PingFang SC", "Microsoft YaHei UI", sans-serif;
font-size: 14px;
}
:root[data-theme="dark"] {
color-scheme: dark;
--canvas: #121416;
--surface: #1b1e21;
--surface-muted: #202428;
--surface-subtle: #24282d;
--surface-canvas: var(--canvas);
--surface-raised: var(--surface);
--surface-selected: #23364a;
--border: #343a40;
--border-strong: #474f57;
--text-primary: #e8eaed;
--text-secondary: #adb5bd;
--text-tertiary: #7f8993;
--action: #6ca8e8;
--action-hover: #8bbcf0;
--action-soft: #23364a;
--market-up: #f06d73;
--market-up-soft: #40262a;
--market-down: #43bc8a;
--market-down-soft: #1d382f;
--warning-color: #e2ad58;
--warning-soft: #3d3220;
--primary: var(--action);
--primary-hover: var(--action-hover);
--danger: var(--market-up);
--success: var(--market-down);
--warning: var(--warning-color);
--card-bg: var(--surface);
--card-border: var(--border);
--xb-gray-25: var(--surface);
--xb-gray-50: var(--surface-muted);
--xb-gray-100: var(--canvas);
--xb-gray-200: var(--border);
--xb-gray-300: var(--border-strong);
--xb-gray-500: var(--text-secondary);
--xb-gray-700: #cbd1d7;
--xb-gray-900: var(--text-primary);
--xb-blue-50: var(--action-soft);
--xb-blue-100: #294866;
--xb-blue-500: var(--action);
--xb-blue-600: var(--action-hover);
--xb-red-50: var(--market-up-soft);
--xb-red-500: var(--market-up);
--xb-green-50: var(--market-down-soft);
--xb-green-500: var(--market-down);
--xb-amber-50: var(--warning-soft);
--xb-amber-500: var(--warning-color);
--bg: var(--canvas);
--card: var(--surface);
--ink: var(--text-primary);
--sub: var(--text-secondary);
--faint: var(--text-tertiary);
--line: var(--border);
--line-soft: #2a2f34;
--line-strong: var(--border-strong);
--text: var(--text-primary);
--text-muted: var(--text-secondary);
--blue: var(--action);
--blue-d: var(--action-hover);
--blue-dark: var(--action-hover);
--blue-soft: var(--action-soft);
--blue-line: #42698e;
--up: var(--market-up);
--up-soft: var(--market-up-soft);
--down: var(--market-down);
--down-soft: var(--market-down-soft);
--coral: var(--market-up);
--coral-soft: var(--market-up-soft);
--green: var(--market-down);
--green-soft: var(--market-down-soft);
--amber: var(--warning-color);
--amber-soft: var(--warning-soft);
--r2-blue: var(--action);
--r2-blue-dark: var(--action-hover);
--r2-blue-soft: var(--action-soft);
--r2-blue-line: #42698e;
--r2-up: var(--market-up);
--r2-up-soft: var(--market-up-soft);
--r2-down: var(--market-down);
--r2-down-soft: var(--market-down-soft);
--r2-amber: var(--warning-color);
--r2-amber-soft: var(--warning-soft);
--r2-ink: var(--text-primary);
--r2-sub: var(--text-secondary);
--r2-faint: var(--text-tertiary);
--r2-line: var(--border);
--r2-line-soft: #2a2f34;
--r2-bg: var(--canvas);
--r2-card: var(--surface);
--r2-shadow: var(--shadow-soft);
--dialog-ink: var(--text-primary);
--dialog-line: var(--border);
--heaven-paper: #191a18;
--heaven-paper-soft: #20211e;
--heaven-ink: #e5e0d4;
--heaven-muted: #aaa497;
--heaven-rule: #3d3a34;
--chart-background: #181b1e;
--chart-grid: #30363c;
--chart-axis: #a3adb6;
--chart-zero: #68737d;
--chart-line: #6ca8e8;
--chart-average: #e2ad58;
--chart-up: #f06d73;
--chart-down: #43bc8a;
--chart-up-volume: rgba(240, 109, 115, .52);
--chart-down-volume: rgba(67, 188, 138, .52);
--chart-area: rgba(108, 168, 232, .12);
--chart-alert-area: rgba(240, 109, 115, .09);
--chart-moving-average: #69737d;
--chart-repair: #e2ad58;
--chart-ma-10: #d39a45;
--chart-ma-20: #9aa5af;
--on-action: #101418;
--warning-line: #6d5a38;
--warning-line-strong: #66502d;
--control-shadow: 0 1px 3px rgba(0, 0, 0, .3);
--dialog-backdrop: rgba(0, 0, 0, .62);
--ladder-level-1: #2d2426;
--ladder-level-2: #2b2822;
--ladder-level-3: #252a2d;
--ladder-level-4: #20282b;
--ladder-level-5: #202428;
--heat-strong-bg: #304f7a;
--heat-strong-ink: #f2f6fb;
--heat-warm-bg: #2b405f;
--heat-warm-ink: #dfeaf7;
--heat-mild-bg: #293440;
--heat-mild-ink: #c7d2dc;
--heaven-field-bg: #23241f;
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16);
--shadow: 0 18px 50px rgba(0, 0, 0, .46);
}
+15465
View File
File diff suppressed because it is too large Load Diff
+1253
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
(function exposeUiCore(global) {
"use strict";
function number(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, number(value)));
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;",
})[character]);
}
function formatNumber(value, digits = 0) {
return new Intl.NumberFormat("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(number(value));
}
function formatTimestamp(value) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "--";
return parsed.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function displayCompactDate(value) {
const text = String(value || "").replaceAll("-", "");
if (text.length !== 8) return value || "--";
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
function localDateString(value) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function todayString() {
return localDateString(new Date());
}
function parseLocalDate(value) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
global.XiaobaiUI = Object.freeze({
clamp,
displayCompactDate,
escapeHtml,
formatNumber,
formatTimestamp,
localDateString,
number,
parseLocalDate,
todayString,
});
})(window);
+12
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff