const metricAnimationFrames = new WeakMap(); let rowAnimationObserver = null; function changeClass(value) { return number(value) > 0 ? "up" : number(value) < 0 ? "down" : ""; } function streakLabel(streak) { const value = Math.max(1, number(streak)); return value === 1 ? "首板" : `${value}板`; } function signed(value) { const parsed = number(value); return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`; } function formatMoneyMillion(value) { const parsed = number(value); const sign = parsed > 0 ? "+" : ""; if (Math.abs(parsed) >= 100) return `${sign}${formatNumber(parsed / 100, 2)} 亿`; return `${sign}${formatNumber(parsed * 100, 0)} 万`; } async function apiRequest(url, method = "GET", body = null, requestOptions = {}) { return window.XiaobaiAPI.request(url, method, body, requestOptions); } function setLoading(loading, text = "正在加载复盘数据", context = "default") { elements.loading.hidden = !loading; elements.loading.dataset.context = loading ? context : "default"; setText("loadingTitle", text); setText( "loadingHint", context === "screener" ? "正在完成因子筛选、候选排序与历史样本回测,这通常需要一点时间" : "请稍候", ); } function setStatus(text) { applicationShell.setStatus(text); } let toastTimer; function showToast(message) { clearTimeout(toastTimer); elements.toast.textContent = message; elements.toast.hidden = false; toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 3600); } function setText(id, value) { const element = document.getElementById(id); if (element) element.textContent = value; } function motionEnabled() { return !window.matchMedia("(prefers-reduced-motion: reduce)").matches; } function refreshIcons() { if (!window.lucide?.createIcons) return; window.lucide.createIcons({ attrs: { "aria-hidden": "true" } }); } function toggleHeaderCommandMenu(force) { applicationShell.toggleHeaderCommandMenu(force); } function animateMetric(id, rawValue, formatter = (value) => value) { const element = document.getElementById(id); const target = Number(rawValue); if (!element || !Number.isFinite(target)) { setText(id, formatter(rawValue)); return; } const storedValue = Number(element.dataset.metricValue); const previous = Number.isFinite(storedValue) ? storedValue : 0; element.dataset.metricValue = String(target); const existingFrame = metricAnimationFrames.get(element); if (existingFrame) cancelAnimationFrame(existingFrame); if (!motionEnabled() || previous === target) { element.textContent = formatter(target); return; } element.classList.remove("metric-changed"); void element.offsetWidth; element.classList.add("metric-changed"); const startedAt = performance.now(); const duration = 560; const update = (now) => { const progress = Math.min(1, (now - startedAt) / duration); const eased = 1 - (1 - progress) ** 3; element.textContent = formatter(previous + (target - previous) * eased); if (progress < 1) { metricAnimationFrames.set(element, requestAnimationFrame(update)); } else { element.textContent = formatter(target); metricAnimationFrames.delete(element); setTimeout(() => element.classList.remove("metric-changed"), 80); } }; metricAnimationFrames.set(element, requestAnimationFrame(update)); } function animateRows(container) { if (!container) return; const rows = [...container.children].filter((item) => item.matches("tr, [data-code]")); if (!motionEnabled()) { rows.forEach((row) => row.classList.remove("row-pending", "row-enter")); return; } const unseenRows = rows.filter((row) => row.dataset.motionSeen !== "1"); unseenRows.slice(0, 12).forEach((row, index) => { row.dataset.motionSeen = "1"; row.classList.remove("row-pending", "row-enter"); row.style.setProperty("--row-delay", `${index * 24}ms`); requestAnimationFrame(() => row.classList.add("row-enter")); row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true }); }); if (!("IntersectionObserver" in window)) { unseenRows.slice(12).forEach((row) => { row.dataset.motionSeen = "1"; }); return; } if (!rowAnimationObserver) { rowAnimationObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const row = entry.target; rowAnimationObserver.unobserve(row); row.dataset.motionSeen = "1"; row.classList.remove("row-pending"); row.style.setProperty("--row-delay", "0ms"); requestAnimationFrame(() => row.classList.add("row-enter")); row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true }); }); }, { threshold: 0.08, rootMargin: "0px 0px 40px 0px" }); } unseenRows.slice(12).forEach((row) => { row.classList.add("row-pending"); rowAnimationObserver.observe(row); }); } function waitForMotion(duration) { return new Promise((resolve) => setTimeout(resolve, motionEnabled() ? duration : 0)); }