Files
xiaobai-review/xiaobai-datahub/admin/app.js
T
ab98f63692 fix(HEL-540): 星港中枢返工——去除每帧DOM重建、修复事件风暴节流
- syncUI 不再在幕次未变化时每帧调用 renderCabin,也不再每帧无条件 Detail.reopen;
  仅在幕次切换(数据驱动的 idx 变化)时重建 #cabin,同幕内容更新交给既有的
  Views.onChange(真实数据变化时才刷新),彻底避免正在点击/输入/聚焦的节点被
  60fps 循环整体替换。
- reserveGlobalSlot 排队延迟起点改为基于「倒数第 GLOBAL_CAP 个已排队起点」
  递增计算,而不是只锚定队首,修复事件风暴下多簇延迟起点挤在同一时间段、
  导致任意1秒窗口新起簇数远超3的问题。

自测(本地 127.0.0.1,真实 Hub 后端 + Playwright/Chromium 真实鼠标事件):
- python -m pytest xiaobai-datahub/tests:133/133 通过
- node --check xiaobai-datahub/admin/app.js:通过
- git diff --check:无尾随空白
- 控制舱按钮连续 8 次真实点击:0 次失败(此前 0 次命中)
- 数据源“探测一次”连续 6 次点击:产生 6 次真实 POST(此前 0 个)
- 盘后发布日期输入 20991231:保持 2 秒以上未被重置,值仍为 20991231
- 10 个并发 pollSources() 事件风暴复测:共 29 簇,任意滚动1秒窗口最大新起簇数 = 3(此前最多 20)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-13 12:50:11 +08:00

1472 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/* ==========================================================================
xiaobai-datahub 管理后台 · 第八版「星港中枢」
一台持续运转的星系:整页星空舞台,中央自转星体 + 4 颗来源卫星沿 2 层完整椭圆
轨道环绕,滚动只是在同一座星系里移动镜头强调;A2 路由器式 LINK/ACT 信号灯——
全部由真实接口数据驱动,没有任何模拟流量。
只有 tushare 的逐次调用(recent_calls)和 eastmoney/tencent/ifind 的健康探测
(每次拉取 /admin/api/sources 都是一次真实网络探测)能证明"发生过一次事件",
因此只有这四路会闪 ACTths/xgb/akshare 是预留源,永远不闪。
========================================================================== */
/* ---------------------------------------------------------------- 基础工具 */
function $(id) { return document.getElementById(id); }
function esc(value) {
return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[ch]));
}
function timeShort(value) {
const s = String(value ?? "");
const m = s.match(/(\d{2}:\d{2}:\d{2})/);
return m ? m[1] : s.replace("T", " ").slice(0, 16);
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
const state = {
csrf: "",
scene: 0,
releaseDate: "",
reduced: false,
visible: document.visibilityState === "visible",
online: navigator.onLine,
data: { overview: null, sources: null, jobs: null, batches: null, datasets: null, audit: null },
};
async function api(path, options = {}) {
const headers = Object.assign({ "Content-Type": "application/json" }, options.headers || {});
if (state.csrf && (options.method || "GET") !== "GET") headers["X-CSRF-Token"] = state.csrf;
const res = await fetch(path, Object.assign({}, options, { headers, credentials: "same-origin" }));
const body = await res.json();
if (!res.ok) {
const msg = (body.error && body.error.message) || body.error || res.statusText;
throw new Error(msg);
}
return body;
}
function show(id) {
["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; });
}
function table(headers, rows) {
const thead = headers.map((h) => `<th>${esc(h)}</th>`).join("");
const body = rows.length
? rows.map((cols) => `<tr>${cols.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")
: `<tr><td colspan="${headers.length}">暂无数据</td></tr>`;
return `<table><thead><tr>${thead}</tr></thead><tbody>${body}</tbody></table>`;
}
/* ---------------------------------------------------------------- 登录 / 会话 */
async function boot() {
try {
const session = await api("/admin/api/session");
state.csrf = session.csrf;
$("who").textContent = session.username;
if (session.must_change) { show("change-view"); return; }
show("shell");
enterShell();
} catch {
show("login-view");
}
}
$("login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
$("login-error").hidden = true;
try {
const result = await api("/admin/api/login", {
method: "POST",
body: JSON.stringify({ username: form.get("username"), password: form.get("password") }),
});
state.csrf = result.csrf;
if (result.must_change) show("change-view");
else { show("shell"); enterShell(); }
} catch (err) {
$("login-error").hidden = false;
$("login-error").textContent = err.message;
}
});
$("change-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
try {
await api("/admin/api/change-password", {
method: "POST",
body: JSON.stringify({ current: form.get("current"), new_password: form.get("new_password") }),
});
show("shell");
enterShell();
} catch (err) {
$("change-error").hidden = false;
$("change-error").textContent = err.message;
}
});
$("logout-btn").addEventListener("click", async () => {
await api("/admin/api/logout", { method: "POST", body: "{}" });
Poller.stopAll();
Stage.stop();
show("login-view");
});
/* ---------------------------------------------------------------- 主题(日/夜) */
function applyTheme(theme) {
const root = document.documentElement;
if (theme === "night") root.setAttribute("data-theme", "night");
else root.removeAttribute("data-theme");
$("theme-btn").textContent = theme === "night" ? "日间" : "夜间";
try { localStorage.setItem("hub_theme", theme); } catch { /* ignore storage errors */ }
Stage.onThemeChange(theme);
}
$("theme-btn").addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day";
applyTheme(current === "night" ? "day" : "night");
});
/* ---------------------------------------------------------------- 减少动态效果 & 可见性 & 网络 */
const REDUCE_MQ = matchMedia("(prefers-reduced-motion: reduce)");
function applyReduced(reduced) {
state.reduced = reduced;
document.documentElement.classList.toggle("reduced", reduced);
if (reduced) { Stage.stop(); StaticShell.mount(); }
else { StaticShell.unmount(); Stage.start(); }
}
REDUCE_MQ.addEventListener("change", (e) => applyReduced(e.matches));
document.addEventListener("visibilitychange", () => {
state.visible = document.visibilityState === "visible";
onRuntimeAvailabilityChange();
});
window.addEventListener("online", () => { state.online = true; onRuntimeAvailabilityChange(); });
window.addEventListener("offline", () => { state.online = false; onRuntimeAvailabilityChange(); });
function runtimeAvailable() { return state.visible && state.online; }
function onRuntimeAvailabilityChange() {
if (!$("shell") || $("shell").hidden) return;
if (runtimeAvailable()) {
// 恢复:不补播旧事件——所有通道先静默重建基线,再继续真实轮询
Poller.resume();
if (!state.reduced) Stage.resume();
} else {
// 隐藏/切页/断网:取消未播闪簇、清空队列、停止循环
Poller.pause();
Lamps.clearAll();
if (!state.reduced) Stage.pause();
}
updateCrumbStatus();
}
function updateCrumbStatus() {
const el = $("crumb");
if (!el) return;
if (!state.online) { el.textContent = "网络已断开 · 已暂停实时"; return; }
if (!state.visible) { el.textContent = "已切至后台 · 已暂停动效"; return; }
el.textContent = SCENES[state.scene] ? `8766 · ${SCENES[state.scene].label} · 持续运转` : "8766 · 四源汇流 · 持续运转";
}
/* ==========================================================================
A2 信号灯引擎:只有真实事件才能点亮 ACT。
- 单一来源的一簇事件最多折叠为 3 次短闪。
- 全站所有通道共享一个节流阀:任意 1 秒窗口内新起的闪烁簇不超过 3 个,
各来源互不同步(各自独立的小抖动),不做全局统一节拍。
========================================================================== */
const FLASH_ON = 90, FLASH_GAP = 160, MAX_CLUSTER = 3;
const GLOBAL_WINDOW = 1000, GLOBAL_CAP = 3;
const globalFlashStarts = [];
function reserveGlobalSlot(tNow) {
// 清掉确定已经过去(早于 tNow 一个窗口以上)的排队记录,
// 未来还会发生的排队起点必须保留,否则后续簇的计算会失去锚点。
while (globalFlashStarts.length && tNow - globalFlashStarts[0] > GLOBAL_WINDOW) globalFlashStarts.shift();
const n = globalFlashStarts.length;
let start;
if (n < GLOBAL_CAP) {
// 队列未满:本簇可以立即起闪。
start = tNow;
} else {
// 队列已排到 CAP:新起点必须晚于「倒数第 CAP 个已排队起点」一个窗口以上,
// 这样任何滚动 1 秒窗口内的新起簇数量都 ≤ CAP,而不是全都挤在同一段延迟里。
const anchor = globalFlashStarts[n - GLOBAL_CAP];
start = Math.max(tNow, anchor + GLOBAL_WINDOW + 40 + Math.random() * 120);
}
globalFlashStarts.push(start);
globalFlashStarts.sort((a, b) => a - b);
return start;
}
class LampChannel {
constructor(id) { this.id = id; this.bursts = []; this.link = "unknown"; this.lastAt = 0; }
push(n, kind) {
const count = clamp(Math.round(n) || 1, 1, MAX_CLUSTER);
const start = reserveGlobalSlot(performance.now());
this.bursts.push({ start, n: count, kind: kind || "ok" });
this.lastAt = Date.now();
}
clear() { this.bursts.length = 0; }
level(tMs) {
for (let i = this.bursts.length - 1; i >= 0; i--) {
const b = this.bursts[i];
const dur = (b.n - 1) * FLASH_GAP + FLASH_ON;
const dt = tMs - b.start;
if (dt < 0) continue;
if (dt <= dur) { const ph = dt % FLASH_GAP; return ph < FLASH_ON ? 1 : 0.1; }
if (dt <= dur + 260) return 0.22 * (1 - (dt - dur) / 260);
}
return 0;
}
activeKind(tMs) {
for (let i = this.bursts.length - 1; i >= 0; i--) {
const b = this.bursts[i];
const dur = (b.n - 1) * FLASH_GAP + FLASH_ON + 260;
if (tMs - b.start <= dur) return b.kind;
}
return null;
}
prune(tMs) { this.bursts = this.bursts.filter((b) => tMs - b.start < (b.n - 1) * FLASH_GAP + FLASH_ON + 400); }
}
const Lamps = {
tushare: new LampChannel("tushare"),
eastmoney: new LampChannel("eastmoney"),
tencent: new LampChannel("tencent"),
ifind: new LampChannel("ifind"),
junction: new LampChannel("junction"),
tx: new LampChannel("tx"),
audit: new LampChannel("audit"),
clearAll() { Object.values(this).forEach((c) => { if (c instanceof LampChannel) c.clear(); }); globalFlashStarts.length = 0; },
};
const FLOW_PROVIDERS = ["tushare", "eastmoney", "tencent", "ifind"];
const FLOW_LABEL = {
tushare: "tushare · 官方盘后", eastmoney: "eastmoney · 盘中观察",
tencent: "tencent · 盘中观察", ifind: "ifind · 授权实时",
};
/* 事件脉冲:来源 ACT 起闪 → 沿流道 → 核心接点 → TX;只在真实事件时入队 */
const PACKETS = [];
function emitPacket(providerIdx, kindRollback) {
PACKETS.push({ src: providerIdx, t0: performance.now(), rb: !!kindRollback });
if (PACKETS.length > 40) PACKETS.splice(0, PACKETS.length - 40); // 硬上限,防止无限增长
}
function fireSource(provider, n, kind) {
const idx = FLOW_PROVIDERS.indexOf(provider);
if (idx < 0) return;
Lamps[provider].push(n, kind);
emitPacket(idx, kind === "rollback");
setTimeout(() => Lamps.junction.push(1, kind), 900);
setTimeout(() => Lamps.tx.push(1, kind), 1650);
}
/* ==========================================================================
数据轮询 + 真实事件识别(只认已经发生的事实,不臆造)
========================================================================== */
const Seen = {
callsMax: -1, jobRunsMax: -1, auditMax: -1,
batchState: new Map(), pubPublishedAt: new Map(),
firstOverview: true, firstJobs: true, firstAudit: true, firstBatches: true,
};
async function pollOverview() {
const data = await api("/admin/api/overview");
const wasFirst = Seen.firstOverview;
const calls = data.recent_calls || [];
let maxId = Seen.callsMax;
const fresh = [];
for (const c of calls) { if (c.id > Seen.callsMax) fresh.push(c); if (c.id > maxId) maxId = c.id; }
Seen.callsMax = maxId;
Seen.firstOverview = false;
if (!wasFirst && fresh.length) fireSource("tushare", fresh.length, fresh.some((c) => !c.ok) ? "error" : "ok");
state.data.overview = data;
Views.refresh();
}
async function pollSources() {
const data = await api("/admin/api/sources");
for (const item of data.items) {
if (!FLOW_PROVIDERS.includes(item.provider)) continue;
const health = item.health || {};
const link = health.state === "ok" || health.state === "empty" ? "ok" : (health.state === "unconfigured" ? "unconfigured" : "error");
Lamps[item.provider].link = link;
// 每一次 /admin/api/sources 请求都会对该源做一次真实探测(现网行为),
// 探测本身完成即是一次真实事件;未配置的源不闪(没有发生过真实调用)。
if (item.provider !== "tushare" && link !== "unconfigured") {
Lamps[item.provider].push(1, link === "ok" ? "ok" : "error");
emitPacket(FLOW_PROVIDERS.indexOf(item.provider), false);
setTimeout(() => Lamps.junction.push(1, link), 700);
}
}
state.data.sources = data;
Views.refresh();
}
async function pollJobs() {
const data = await api("/admin/api/jobs");
const wasFirst = Seen.firstJobs;
let maxId = Seen.jobRunsMax;
const fresh = [];
for (const r of data.runs || []) { if (r.id > Seen.jobRunsMax) fresh.push(r); if (r.id > maxId) maxId = r.id; }
Seen.jobRunsMax = maxId;
Seen.firstJobs = false;
if (!wasFirst && fresh.length) {
const anyFail = fresh.some((r) => r.state === "failed");
Lamps.junction.push(fresh.length, anyFail ? "error" : "ok");
}
state.data.jobs = data;
Stage.freshJobRuns = fresh;
Views.refresh();
}
async function pollBatches() {
const date = state.releaseDate || "";
const data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`);
const wasFirst = Seen.firstBatches;
let changed = false;
for (const b of data.batches || []) {
const prev = Seen.batchState.get(b.batch_id);
if (prev !== b.state) { changed = true; Seen.batchState.set(b.batch_id, b.state); }
}
let published = false;
for (const p of data.publications || []) {
const key = `${p.dataset}:${p.trade_date}`;
const prev = Seen.pubPublishedAt.get(key);
if (prev !== p.published_at) { published = true; Seen.pubPublishedAt.set(key, p.published_at); }
}
Seen.firstBatches = false;
if (!wasFirst && published) Lamps.tx.push(1, "pub");
else if (!wasFirst && changed) Lamps.junction.push(1, "ok");
state.data.batches = data;
Views.refresh();
}
async function pollDatasets() {
const date = "";
const data = await api(`/admin/api/datasets?date=${encodeURIComponent(date)}`);
state.data.datasets = data;
Views.refresh();
}
async function pollAudit() {
const data = await api("/admin/api/audit");
const wasFirst = Seen.firstAudit;
let maxId = Seen.auditMax;
const fresh = [];
for (const a of data.items || []) { if (a.id > Seen.auditMax) fresh.push(a); if (a.id > maxId) maxId = a.id; }
Seen.auditMax = maxId;
Seen.firstAudit = false;
if (!wasFirst && fresh.length) {
const rollback = fresh.some((a) => String(a.action).includes("rollback"));
Lamps.audit.push(fresh.length, rollback ? "rollback" : "ok");
}
state.data.audit = data;
Views.refresh();
}
const Poller = (() => {
const specs = [
{ key: "overview", fn: pollOverview, every: 20000 },
{ key: "sources", fn: pollSources, every: 45000 },
{ key: "jobs", fn: pollJobs, every: 25000 },
{ key: "batches", fn: pollBatches, every: 25000 },
{ key: "datasets", fn: pollDatasets, every: 60000 },
{ key: "audit", fn: pollAudit, every: 20000 },
];
const timers = new Map();
function tickOne(spec) {
spec.fn().catch((err) => console.error(`[hub] poll ${spec.key} failed`, err));
}
function schedule(spec) {
clearTimer(spec.key);
const t = setInterval(() => { if (runtimeAvailable()) tickOne(spec); }, spec.every);
timers.set(spec.key, t);
}
function clearTimer(key) { if (timers.has(key)) { clearInterval(timers.get(key)); timers.delete(key); } }
function startAll() {
specs.forEach((spec, i) => { setTimeout(() => { tickOne(spec); schedule(spec); }, i * 160); });
}
function stopAll() { specs.forEach((s) => clearTimer(s.key)); }
function pause() { stopAll(); }
function resume() {
// 恢复时先静默重建基线(不补播旧事件),随后正常轮询
specs.forEach((spec) => { tickOne(spec); schedule(spec); });
}
return { startAll, stopAll, pause, resume };
})();
/* ==========================================================================
六幕场景内容:cabin(控制舱摘要)与 detail(功能抽屉)共用同一份真实数据渲染,
动效版与「减少动态效果」静态版都调用这里——保证信息与功能完全一致。
========================================================================== */
const SCENES = [
{ key: "overview", label: "总览" },
{ key: "sources", label: "数据源" },
{ key: "jobs", label: "调度任务" },
{ key: "release", label: "盘后发布" },
{ key: "datasets", label: "数据集" },
{ key: "audit", label: "审计" },
];
const EOD_LABELS = {
pending_first_attempt: "等待首次尝试", waiting_upstream: "等待上游", done: "已成功",
cutoff_failed: "已截止失败", closed_day: "休市",
};
const REV_LABELS = {
waiting_review: "等待复核", review_failed: "复核失败", aligned: "已追平",
cutoff: "已截止", pending_publish: "待发布", closed_day: "休市",
};
const PHASE_LABELS = { pre: "盘前", intraday: "盘中", lunch: "午间", eod: "盘后", closed: "休市" };
function todayYmd() {
const d = new Date(); const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
}
if (!state.releaseDate) state.releaseDate = todayYmd();
const Views = {
listeners: [],
onChange(fn) { this.listeners.push(fn); },
refresh() { this.listeners.forEach((fn) => { try { fn(); } catch (e) { console.error(e); } }); },
};
/* ---- 0 总览 ---- */
function cabinOverview() {
const d = state.data.overview;
if (!d) return { eyebrow: "SCENE 01 / 总览", title: "四源汇流 · 持续运转", sub: "正在加载真实状态…", rows: [], actions: [] };
const eod = d.eod_status || {}, rev = d.revision_status || {};
return {
eyebrow: "SCENE 01 / 总览",
title: "四源汇流 · 持续运转",
sub: `${d.trade_date} · ${PHASE_LABELS[d.session_phase] || d.session_phase}。灯闪一次,就是一次真实调用或探测。`,
rows: [
["今日发布", `${d.publications.length}`, "ok"],
["盘后补跑", EOD_LABELS[eod.state] || eod.state || "-", eod.state === "cutoff_failed" ? "fail" : "ok"],
["估值复核", REV_LABELS[rev.state] || rev.state || "-", rev.state === "review_failed" ? "fail" : "ok"],
["异常批次", `${d.anomalies.length}`, d.anomalies.length ? "fail" : "ok"],
],
actions: [{ label: "进入数据源 →", cls: "pri", action: () => Nav.go(1) }, { label: "查看最近调用", cls: "", action: () => Detail.open("overview") }],
};
}
function detailOverview() {
const d = state.data.overview;
if (!d) return `<h3>总览</h3><div class="dsub">正在加载…</div>`;
const eod = d.eod_status || {}, rev = d.revision_status || {};
const eodExtra = [];
if (eod.state === "waiting_upstream") {
eodExtra.push(`已试 ${eod.attempts} 次`);
if (eod.next_retry_at) eodExtra.push(`下次重试 ${esc(timeShort(eod.next_retry_at))}`);
if (eod.missing_datasets && eod.missing_datasets.length) eodExtra.push(`缺 ${esc(eod.missing_datasets.join(","))}`);
}
if (eod.state === "cutoff_failed" && eod.missing_datasets) eodExtra.push(`缺 ${esc(eod.missing_datasets.join(","))}`);
const revExtra = [];
if (rev.detail) revExtra.push(esc(String(rev.detail)));
if (rev.window) revExtra.push(esc(String(rev.window)));
return `
<h3>总览 · ${esc(d.trade_date)}</h3>
<div class="dsub">${esc(PHASE_LABELS[d.session_phase] || d.session_phase)} · 源共 ${d.source_count}</div>
<div class="cards">
<div class="card"><div class="muted">交易日</div><strong>${esc(d.trade_date)}</strong></div>
<div class="card"><div class="muted">阶段</div><strong>${esc(PHASE_LABELS[d.session_phase] || d.session_phase)}</strong></div>
<div class="card"><div class="muted">今日发布</div><strong>${d.publications.length}</strong></div>
<div class="card"><div class="muted">盘后补跑</div><strong>${esc(EOD_LABELS[eod.state] || eod.state || "-")}</strong><div class="muted">${eodExtra.join(" · ")}</div></div>
<div class="card"><div class="muted">估值复核</div><strong>${esc(REV_LABELS[rev.state] || rev.state || "-")}</strong><div class="muted">${revExtra.join(" · ")}</div></div>
<div class="card"><div class="muted">异常批次</div><strong class="${d.anomalies.length ? "fail" : "ok"}">${d.anomalies.length}</strong></div>
</div>
<h3>最近调用(tushare</h3>
${table(["时间", "源", "端点", "结果", "耗时"], d.recent_calls.map((row) => [
esc(timeShort(row.created_at)), esc(row.provider), esc(row.endpoint),
row.ok ? '<span class="ok">成功</span>' : `<span class="fail">${esc(row.error)}</span>`,
`${row.latency_ms ?? "-"} ms`,
]))}
`;
}
/* ---- 1 数据源 ---- */
function cabinSources() {
const d = state.data.sources;
if (!d) return { eyebrow: "SCENE 02 / 数据源", title: "四路来源 · 各闪各的", sub: "正在加载真实状态…", rows: [], actions: [] };
const live = d.items.filter((it) => FLOW_PROVIDERS.includes(it.provider));
const reserved = d.items.filter((it) => !FLOW_PROVIDERS.includes(it.provider));
const rows = live.map((it) => {
const h = it.health || {};
const okish = h.state === "ok" || h.state === "empty";
return [FLOW_LABEL[it.provider] || it.provider, `${h.latency_ms ?? "-"} ms · ${esc(h.state || "-")}`, okish ? "ok" : "warn"];
});
rows.push([`预留源 ${reserved.length} 个`, "未接入 · 无真实调用", "warn"]);
return {
eyebrow: "SCENE 02 / 数据源", title: "四路来源 · 各闪各的",
sub: "点击任一端口双灯,详情从它的空间位置展开。灯闪代表真实探测或调用刚发生。",
rows,
actions: [{ label: "查看全部来源", cls: "pri", action: () => Detail.open("sources") }],
};
}
function detailSources() {
const d = state.data.sources;
if (!d) return `<h3>数据源</h3><div class="dsub">正在加载…</div>`;
const html = `<h3>数据源</h3><div class="dsub">LINK 常亮表示健康;ACT 只在真实探测/调用发生时短闪</div>` +
table(["源", "角色", "状态", "凭据", "操作"], d.items.map((item) => {
const cred = item.credential || {};
const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置";
return [
esc(item.provider), esc(item.role),
esc((item.health && (item.health.state || item.health.status)) || "-"),
credText,
`<button class="btn ghost" data-probe="${esc(item.provider)}">探测一次</button>`,
];
}));
return html;
}
function bindSourcesDetail(root) {
root.querySelectorAll("[data-probe]").forEach((btn) => {
btn.addEventListener("click", async () => {
const provider = btn.dataset.probe;
btn.disabled = true;
try {
const result = await api(`/admin/api/sources/${provider}/probe`, { method: "POST", body: "{}" });
if (FLOW_PROVIDERS.includes(provider)) {
const ok = result.state === "ok" || result.state === "empty";
fireSource(provider, 1, ok ? "ok" : "error");
}
alert(JSON.stringify(result));
await pollSources();
Detail.reopen();
} finally { btn.disabled = false; }
});
});
}
/* ---- 2 调度任务 ---- */
function cabinJobs() {
const d = state.data.jobs;
if (!d) return { eyebrow: "SCENE 03 / 调度任务", title: "时间轮 · 任务接力", sub: "正在加载真实状态…", rows: [], actions: [] };
const runs = d.runs || [];
const latest = runs[0];
const failedRecent = runs.slice(0, 20).filter((r) => r.state === "failed").length;
return {
eyebrow: "SCENE 03 / 调度任务", title: "时间轮 · 任务接力",
sub: "环上的每个节点对应一个真实定时任务;节点接力亮起来自最近一次真实运行。",
rows: [
["最近运行", latest ? `${esc(latest.job_id)} · ${esc(latest.state)}` : "暂无", latest && latest.state === "failed" ? "fail" : "ok"],
["最近 20 次失败", `${failedRecent}`, failedRecent ? "fail" : "ok"],
["任务总数", `${d.jobs.length}`, "ok"],
],
actions: [{ label: "查看调度日志", cls: "pri", action: () => Detail.open("jobs") }],
};
}
function detailJobs() {
const d = state.data.jobs;
if (!d) return `<h3>调度任务</h3><div class="dsub">正在加载…</div>`;
return `
<h3>调度任务</h3>
${table(["任务", "时刻", "操作"], d.jobs.map((job) => [
`${esc(job.id)} · ${esc(job.title)}`, esc(job.at),
`<button class="btn ghost" data-run="${esc(job.id)}">手动触发</button>`,
]))}
<h3>最近运行</h3>
${table(["ID", "任务", "状态", "开始", "结束", "错误"], d.runs.map((row) => [
row.id, esc(row.job_id),
`<span class="${row.state === "failed" ? "fail" : row.state === "running" ? "warn" : "ok"}">${esc(row.state)}</span>`,
esc(timeShort(row.started_at)), esc(timeShort(row.finished_at)), esc(row.error || ""),
]))}
`;
}
function bindJobsDetail(root) {
root.querySelectorAll("[data-run]").forEach((btn) => {
btn.addEventListener("click", async () => {
const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || "";
btn.disabled = true;
try {
await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: "POST", body: JSON.stringify({ trade_date: date }) });
await pollJobs();
Detail.reopen();
} finally { btn.disabled = false; }
});
});
}
/* ---- 3 盘后发布 ---- */
function cabinRelease() {
const d = state.data.batches;
if (!d) return { eyebrow: "SCENE 04 / 盘后发布", title: "校验 → 暂存 → 发布", sub: "正在加载真实状态…", rows: [], actions: [] };
const pubs = d.publications || [];
const rollbacks = pubs.filter((p) => p.prev_batch).length;
const failedBatches = (d.batches || []).filter((b) => b.state === "failed").length;
return {
eyebrow: "SCENE 04 / 盘后发布", title: "校验 → 暂存 → 发布",
sub: `${d.trade_date} · RX 批次穿过三层,TX 给出回执;校验失败走红色回滚分叉。`,
rows: [
["活跃发布", `${pubs.length} 个数据集`, "ok"],
["可回滚", `${rollbacks}`, rollbacks ? "warn" : "ok"],
["批次异常", `${failedBatches}`, failedBatches ? "fail" : "ok"],
],
actions: [{ label: "查看批次与发布", cls: "pri", action: () => Detail.open("release") }],
};
}
function detailRelease() {
const d = state.data.batches;
if (!d) return `<h3>盘后发布</h3><div class="dsub">正在加载…</div>`;
return `
<h3>盘后发布 ${esc(d.trade_date)}</h3>
<div class="toolbar">
<label>日期 <input id="rel-date" value="${esc(d.trade_date)}" /></label>
<button type="button" class="btn ghost" id="rel-load">查看</button>
<button type="button" class="btn warn" id="rel-backfill">补数</button>
</div>
<h3>当前映射</h3>
${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], d.publications.map((row) => [
esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at),
row.prev_batch ? `<button class="btn danger" data-rollback="${esc(row.dataset)}">回滚</button>` : "-",
]))}
<h3>批次</h3>
${table(["batch_id", "数据集", "状态", "行数", "错误"], d.batches.map((row) => [
esc(row.batch_id), esc(row.dataset),
`<span class="${row.state === "failed" ? "fail" : "ok"}">${esc(row.state)}</span>`,
row.rows_out ?? "", esc(row.error || ""),
]))}
`;
}
function bindReleaseDetail(root) {
root.querySelector("#rel-load").addEventListener("click", async () => {
state.releaseDate = root.querySelector("#rel-date").value.trim();
await pollBatches();
Detail.reopen();
});
root.querySelector("#rel-backfill").addEventListener("click", () => dangerous("backfill"));
root.querySelectorAll("[data-rollback]").forEach((btn) => {
btn.addEventListener("click", () => dangerous("rollback", btn.dataset.rollback));
});
}
async function dangerous(kind, dataset) {
const date = state.releaseDate || "";
const ds = dataset || prompt("数据集(daily/valuation/moneyflow/auction/stocks→A组整批;index_daily→B组;或 reference", "daily");
if (!ds) return;
const password = prompt("二次确认:输入管理密码");
if (!password) return;
const confirmWord = `${ds}:${date}`;
const typed = prompt(`请输入确认词:${confirmWord}`);
if (!typed) return;
const path = kind === "rollback" ? "/admin/api/rollback" : "/admin/api/backfill";
try {
await api(path, { method: "POST", body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }) });
if (kind === "rollback") Lamps.tx.push(1, "rollback");
await pollBatches();
await pollAudit();
Detail.reopen();
} catch (err) {
alert(err.message);
}
}
/* ---- 4 数据集 ---- */
function cabinDatasets() {
const d = state.data.datasets;
if (!d) return { eyebrow: "SCENE 05 / 数据集", title: "六面展开 · 数据集空间", sub: "正在加载真实状态…", rows: [], actions: [] };
const pubs = d.publications || [];
return {
eyebrow: "SCENE 05 / 数据集", title: "六面展开 · 数据集空间",
sub: `${d.trade_date} · 核心面片翻开成数据集,稳定显示日期与状态。`,
rows: pubs.slice(0, 4).map((p) => [esc(p.dataset), `${esc(p.state)} · ${esc(timeShort(p.published_at))}`, p.state === "published" ? "ok" : "warn"]),
actions: [{ label: "查看差异报告", cls: "pri", action: () => Detail.open("datasets") }],
};
}
function detailDatasets() {
const d = state.data.datasets;
if (!d) return `<h3>数据集</h3><div class="dsub">正在加载…</div>`;
return `
<h3>数据集 / 质量 ${esc(d.trade_date)}</h3>
${table(["数据集", "批次", "状态", "发布时间"], d.publications.map((row) => [
esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at),
]))}
<h3>源间差异</h3>
${table(["指标", "左", "右", "偏差", "样本"], d.diff_reports.map((row) => [
esc(row.metric), esc(row.left_value), esc(row.right_value), esc(row.deviation), row.sample_count ?? "",
]))}
`;
}
/* ---- 5 审计 ---- */
function cabinAudit() {
const d = state.data.audit;
if (!d) return { eyebrow: "SCENE 06 / 审计", title: "时间尾迹 · 全程留痕", sub: "正在加载真实状态…", rows: [], actions: [] };
const items = d.items || [];
const latest = items[0];
return {
eyebrow: "SCENE 06 / 审计", title: "时间尾迹 · 全程留痕",
sub: "输出流在核心后方留下轨迹,发布、操作、回滚沿轨迹展开。",
rows: latest ? [[esc(latest.actor), `${esc(latest.action)} · ${esc(timeShort(latest.created_at))}`, String(latest.action).includes("rollback") ? "warn" : "ok"]] : [],
actions: [{ label: `查看审计(共 ${items.length} 条)`, cls: "pri", action: () => Detail.open("audit") }],
};
}
function detailAudit() {
const d = state.data.audit;
if (!d) return `<h3>审计</h3><div class="dsub">正在加载…</div>`;
return `<h3>审计</h3><div class="dsub">共 ${d.items.length} 条 · 最新在前</div>` +
table(["时间", "操作者", "动作", "对象", "详情"], d.items.map((row) => [
esc(timeShort(row.created_at)), esc(row.actor), esc(row.action), esc(row.target), esc(row.detail),
]));
}
const SCENE_CABIN = [cabinOverview, cabinSources, cabinJobs, cabinRelease, cabinDatasets, cabinAudit];
const SCENE_DETAIL = [detailOverview, detailSources, detailJobs, detailRelease, detailDatasets, detailAudit];
const SCENE_BIND = [null, bindSourcesDetail, bindJobsDetail, bindReleaseDetail, null, null];
const Detail = {
openKey: null,
open(key) {
const idx = SCENES.findIndex((s) => s.key === key);
if (idx < 0) return;
this.openKey = key;
if (state.reduced) return; // 静态版直接内嵌显示,无需抽屉
Stage.renderDetail(idx);
},
reopen() { if (this.openKey && !state.reduced) this.open(this.openKey); },
close() { this.openKey = null; if (!state.reduced) Stage.closeDetail(); },
};
const Nav = {
go(idx) {
if (state.reduced) { StaticShell.scrollTo(idx); return; }
Stage.scrollToScene(idx);
},
};
/* ==========================================================================
canvas 2D 自写透视投影引擎(第八版「星港中枢」)——HEL-536/537/538 唯一施工口径:
整页星空舞台,中央自转星体 + 4 颗来源卫星沿 2 层完整椭圆轨道环绕,
4 条入线 + 2 条去向线(发布/审计),A2 双灯永远最顶层、永不遮挡。
响应式安全盒钳制(HEL-538 最终公式):
x0=cabinW+24, x1=W-24, y0=64, y1=H-118, CX=(x0+x1)/2, CY=(y0+y1)/2
S = min((CX-x0)/1.28, (x1-CX)/1.28, (CY-y0)/(1.28*0.52), (y1-CY)/(1.28*0.52))
所有世界坐标元素(轨道/卫星/去向端/尾迹≤1.02/幕2外扩轨道≤1.175)落在
±1.28(横)×±0.6656(纵)包络内,构造性不出安全盒。
星体自转、卫星环绕、数据流为常态运行(真实时钟驱动,不随滚动),
六幕滚动只切换镜头强调(轨道展开/任务环/发布分层/数据集面片/审计尾迹),
LINK/ACT 双灯语义与 A2 事件引擎完全复用原有真实事件驱动逻辑,未做任何模拟。
========================================================================== */
const Stage = (() => {
let canvas, ctx, W = 0, H = 0, DPR = 1;
let running = false, rafId = null;
let PAL, C;
const T0 = performance.now();
const D2R = Math.PI / 180;
/* ---------- HEL-538 最终包络与几何常量 ---------- */
const FIT_X = 1.28, SQUASH = 0.52, FIT_Y = FIT_X * SQUASH; // 0.6656
const STAR_R = 0.28;
const ORBIT_OUTER_A0 = 0.94, ORBIT_INNER_A0 = 0.70, ORBIT_OUTER_A_SOURCES = 1.175;
const GOOUT_R = 1.25, TX_ANGLE = -20 * D2R, AUD_ANGLE = 25 * D2R;
const SAT_DEFS = [
{ provider: "tushare", sub: "官方盘后", orbit: "outer", phase: 200 * D2R, speed: .028, dir: -1 },
{ provider: "eastmoney", sub: "资讯 · 涨停", orbit: "outer", phase: 20 * D2R, speed: .022, dir: -1 },
{ provider: "tencent", sub: "行情快照", orbit: "inner", phase: 140 * D2R, speed: .034, dir: 1 },
{ provider: "ifind", sub: "授权实时", orbit: "inner", phase: 320 * D2R, speed: .026, dir: 1 },
];
const SAT_NAME = { tushare: "Tushare", eastmoney: "东方财富", tencent: "腾讯", ifind: "iFinD" };
const Geo = { CX: 0, CY: 0, S: 380, x0: 0, x1: 0, y0: 0, y1: 0, satLinePts: [], txPts: null, audPts: null };
function mulberry32(seed) {
let a = seed;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function initPalette() {
PAL = {
night: { bgA: "#05080F", bgB: "#0A1322", cyan: "#2FD8CE", blue: "#4A86FF", amber: "#FFB84D", red: "#FF6B6B",
ink: "#E8EEFC", dim: "#9AA6C4", face: "rgba(58,96,180,", edge: "rgba(140,190,255,", slab: "rgba(47,216,206,", ring: "rgba(120,170,255,",
chan: ["rgba(150,205,255,", "rgba(120,180,255,", "rgba(185,175,255,", "rgba(255,214,160,"] },
day: { bgA: "#E8ECF5", bgB: "#F7F9FF", cyan: "#0A9C93", blue: "#2F63D6", amber: "#B97A0E", red: "#D64F4F",
ink: "#1B2547", dim: "#5B6785", face: "rgba(120,160,235,", edge: "rgba(47,99,214,", slab: "rgba(10,156,147,", ring: "rgba(47,99,214,",
chan: ["rgba(47,99,214,", "rgba(10,124,146,", "rgba(109,93,214,", "rgba(190,120,10,"] },
};
C = PAL[document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day"];
}
const SPHERE_STOPS = {
night: [[0, "#F2FAFF"], [.12, "#C9E7FF"], [.4, "#7FB2EA"], [.7, "#3E6FAE"], [1, "#16305E"]],
day: [[0, "#FFFFFF"], [.12, "#EAF6FF"], [.4, "#A9CCF0"], [.7, "#5488C4"], [1, "#2E5A94"]],
};
const LAND_SPOTS = (() => {
const rnd = mulberry32(7712), arr = [];
for (let i = 0; i < 9; i++) arr.push({ a: rnd() * Math.PI * 2, r: .15 + rnd() * .55, s: .05 + rnd() * .06 });
return arr;
})();
const setAdd = () => { ctx.globalCompositeOperation = (C === PAL.night ? "lighter" : "source-over"); };
function roundRect(x, y, w, h, r) {
ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
}
/* ---------- 响应式安全盒(HEL-538 §一,唯一施工口径) ---------- */
function computeSafeBox() {
const cabinEl = $("cabin");
const cabinW = (cabinEl && cabinEl.getBoundingClientRect().width) || 300;
const x0 = cabinW + 24, x1 = W - 24, y0 = 64, y1 = H - 118;
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
const s = Math.max(40, Math.min((cx - x0) / FIT_X, (x1 - cx) / FIT_X, (cy - y0) / FIT_Y, (y1 - cy) / FIT_Y));
Geo.x0 = x0; Geo.x1 = x1; Geo.y0 = y0; Geo.y1 = y1; Geo.CX = cx; Geo.CY = cy; Geo.S = s;
}
function proj(wx, wy) { return { x: Geo.CX + wx * Geo.S, y: Geo.CY - wy * Geo.S }; }
function bpLerp(v1024, v1440) { const t = clamp((W - 1024) / (1440 - 1024), 0, 1); return v1024 + (v1440 - v1024) * t; }
function chipSize() { return { w: bpLerp(96, 120), h: bpLerp(38, 42) }; }
function lampSize() { return { w: bpLerp(14, 16), h: 6 }; }
/* ---------- 基础绘制原语(世界坐标 → 屏幕坐标) ---------- */
function _dotAt(x, y, rPx, colorPrefix, alpha, glow) {
ctx.save(); setAdd();
if (C !== PAL.night) alpha = Math.min(1, alpha * 1.4);
if (glow) {
const g = ctx.createRadialGradient(x, y, 0, x, y, rPx * 3);
g.addColorStop(0, colorPrefix + (.5 * alpha).toFixed(3) + ")"); g.addColorStop(1, colorPrefix + "0)");
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, rPx * 3, 0, 7); ctx.fill();
}
ctx.fillStyle = colorPrefix + alpha.toFixed(3) + ")";
ctx.beginPath(); ctx.arc(x, y, rPx, 0, 7); ctx.fill();
ctx.restore();
}
function dotWorld(pt, rPx, colorPrefix, alpha, glow) { const q = proj(pt.wx, pt.wy); _dotAt(q.x, q.y, rPx, colorPrefix, alpha, glow); return q; }
function polyGlowWorld(pts, colorPrefix, alpha, width, dashPhase) {
ctx.save(); setAdd();
const al0 = C !== PAL.night ? Math.min(1, alpha * 1.5) : alpha;
for (let i = 0; i < pts.length - 1; i++) {
const a = proj(pts[i].wx, pts[i].wy), b = proj(pts[i + 1].wx, pts[i + 1].wy);
let al = al0;
if (dashPhase != null) al *= .4 + .6 * Math.max(0, Math.sin((i / pts.length) * 14 - dashPhase));
ctx.strokeStyle = colorPrefix + al.toFixed(3) + ")";
ctx.lineWidth = width * clamp(Geo.S / 380, .7, 1.3); ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
ctx.restore();
}
function bezQuadWorld(p0, c, p1, n) {
const pts = [];
for (let i = 0; i <= n; i++) { const t = i / n, u = 1 - t; pts.push({ wx: u * u * p0.wx + 2 * u * t * c.wx + t * t * p1.wx, wy: u * u * p0.wy + 2 * u * t * c.wy + t * t * p1.wy }); }
return pts;
}
function lampAt(x, y, linkState, level, kind) {
const { w, h } = lampSize(); const gap = h * .8, pad = 3;
ctx.save();
ctx.globalAlpha = .95;
ctx.fillStyle = C === PAL.night ? "rgba(6,12,24,.82)" : "rgba(255,255,255,.94)";
ctx.strokeStyle = C.edge + ".5)"; ctx.lineWidth = 1;
roundRect(x - w / 2 - pad, y - h - gap / 2 - pad, w + pad * 2, h * 2 + gap + pad * 2, h * .7 + pad); ctx.fill(); ctx.stroke();
const linkColor = linkState === "ok" ? C.cyan : linkState === "error" ? C.red : C.dim;
const linkAlpha = linkState === "ok" ? .65 : linkState === "error" ? .6 : .28;
ctx.globalAlpha = linkAlpha; ctx.fillStyle = linkColor; ctx.shadowColor = linkColor;
ctx.shadowBlur = (linkState === "ok" || linkState === "error") ? 6 : 0;
roundRect(x - w / 2, y - h - gap / 2, w, h, h / 2); ctx.fill();
ctx.shadowBlur = 0;
const actColor = (kind === "error" || kind === "rollback") ? C.red : C.amber;
if (level > 0) { ctx.globalAlpha = .3 + .7 * level; ctx.fillStyle = actColor; ctx.shadowColor = actColor; ctx.shadowBlur = 14 * level; }
else { ctx.globalAlpha = .16; ctx.fillStyle = C.amber; }
roundRect(x - w / 2, y + gap / 2, w, h, h / 2); ctx.fill();
ctx.restore();
}
/* ---------- 六幕镜头强调参数(纯几何/结构语言,不引入相机旋转,安全盒钳制天然对全部取值有效) ---------- */
const CH = [
{ outerA: ORBIT_OUTER_A0, taskRing: 0, explode: .10, unfold: 0, trail: 0, srcExpand: 0 },
{ outerA: ORBIT_OUTER_A_SOURCES, taskRing: 0, explode: .10, unfold: 0, trail: 0, srcExpand: 1 },
{ outerA: ORBIT_OUTER_A0, taskRing: 1, explode: .08, unfold: 0, trail: 0, srcExpand: 0 },
{ outerA: ORBIT_OUTER_A0, taskRing: 0, explode: .90, unfold: 0, trail: 0, srcExpand: 0 },
{ outerA: ORBIT_OUTER_A0, taskRing: 0, explode: .12, unfold: 1, trail: 0, srcExpand: 0 },
{ outerA: ORBIT_OUTER_A0, taskRing: 0, explode: .10, unfold: 0, trail: 1, srcExpand: 0 },
];
function smooth(t) { return t * t * (3 - 2 * t); }
function lerpK(a, b, f) { const o = {}; for (const k in a) o[k] = a[k] + (b[k] - a[k]) * f; return o; }
function chapterAt(p) {
const i = clamp(Math.floor(p), 0, 4), f = smooth(clamp(p - i, 0, 1));
return { ...lerpK(CH[i], CH[i + 1], f), idx: clamp(Math.round(p), 0, 5) };
}
function progress() { const max = Math.max(1, document.body.scrollHeight - innerHeight); return clamp((scrollY / max) * 5, 0, 5); }
/* ---------- 星空背景(两层 ≤120 颗静态星 + 两团低透明星云,离屏缓存,仅 resize/换主题重绘) ---------- */
let bgCanvas = null, bgKey = "";
function drawBackdrop() {
const key = `${W}|${H}|${C === PAL.night ? "n" : "d"}`;
if (!bgCanvas || bgKey !== key) {
bgCanvas = document.createElement("canvas");
bgCanvas.width = Math.max(1, Math.round(W)); bgCanvas.height = Math.max(1, Math.round(H));
const bctx = bgCanvas.getContext("2d");
const bg = bctx.createRadialGradient(W * .5, H * .42, 40, W * .5, H * .5, Math.max(W, H) * .8);
bg.addColorStop(0, C.bgB); bg.addColorStop(1, C.bgA);
bctx.fillStyle = bg; bctx.fillRect(0, 0, W, H);
const neb1 = bctx.createRadialGradient(W * .16, H * .22, 10, W * .16, H * .22, W * .30);
neb1.addColorStop(0, C.face + ".10)"); neb1.addColorStop(1, C.face + "0)");
bctx.fillStyle = neb1; bctx.beginPath(); bctx.ellipse(W * .16, H * .22, W * .30, H * .20, 0, 0, 7); bctx.fill();
const neb2 = bctx.createRadialGradient(W * .86, H * .78, 10, W * .86, H * .78, W * .26);
neb2.addColorStop(0, C.slab + ".08)"); neb2.addColorStop(1, C.slab + "0)");
bctx.fillStyle = neb2; bctx.beginPath(); bctx.ellipse(W * .86, H * .78, W * .26, H * .18, 0, 0, 7); bctx.fill();
const rnd = mulberry32(20260913);
[[68, .85], [34, 1.3], [14, 1.9]].forEach(([n, rr]) => {
for (let i = 0; i < n; i++) {
const x = rnd() * W, y = rnd() * H;
bctx.globalAlpha = .16 + rnd() * .22;
bctx.fillStyle = C.dim; bctx.beginPath(); bctx.arc(x, y, rr, 0, 7); bctx.fill();
}
});
bctx.globalAlpha = 1;
bgKey = key;
}
ctx.drawImage(bgCanvas, 0, 0, W, H);
}
/* ---------- 轨道(2 层完整椭圆,L2 ---------- */
function drawOrbitEllipse(a, b) {
ctx.save(); setAdd();
ctx.strokeStyle = C.ring + ".4)"; ctx.lineWidth = 1.1;
ctx.beginPath();
for (let i = 0; i <= 96; i++) {
const ang = i / 96 * Math.PI * 2;
const q = proj(Math.cos(ang) * a, Math.sin(ang) * b);
if (i === 0) ctx.moveTo(q.x, q.y); else ctx.lineTo(q.x, q.y);
}
ctx.stroke();
ctx.restore();
}
/* ---------- 星体(自转球体 + 数据大陆 + 处理环 + 校验/暂存/发布分层,L4/L5 ---------- */
function drawSlabs(q, r, explode) {
if (explode < .25) return;
const names = ["校验", "暂存", "发布"];
for (let s = 0; s < 3; s++) {
const t = s - 1;
const yOff = t * (r * .85 + explode * r * .55);
const rw = r * 1.5, rh = Math.max(10, r * .22);
ctx.save();
ctx.globalAlpha = .55 + explode * .3;
ctx.fillStyle = C.slab + (.08 + explode * .08).toFixed(3) + ")";
ctx.strokeStyle = C.slab + ".55)"; ctx.lineWidth = 1;
roundRect(q.x - rw / 2, q.y + yOff - rh / 2, rw, rh, rh / 2); ctx.fill(); ctx.stroke();
if (explode > .5) {
ctx.fillStyle = C.ink; ctx.font = `600 ${Math.max(10, rh * .42)}px "Noto Sans SC"`;
ctx.textAlign = "center"; ctx.fillText(names[s], q.x, q.y + yOff + rh * .16); ctx.textAlign = "left";
}
ctx.restore();
}
}
function drawSphere(explode) {
const q = proj(0, 0), r = Geo.S * STAR_R;
if (r <= 0) return;
ctx.save(); setAdd();
const halo = ctx.createRadialGradient(q.x, q.y, r * .55, q.x, q.y, r * 2.0);
halo.addColorStop(0, C.slab + (C === PAL.night ? ".16)" : ".10)")); halo.addColorStop(1, C.slab + "0)");
ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(q.x, q.y, r * 2.0, 0, 7); ctx.fill();
ctx.restore();
ctx.save();
ctx.beginPath(); ctx.arc(q.x, q.y, r, 0, 7); ctx.clip();
const grad = ctx.createRadialGradient(q.x - r * .32, q.y - r * .34, r * .05, q.x, q.y, r * 1.05);
SPHERE_STOPS[C === PAL.night ? "night" : "day"].forEach(([o, col]) => grad.addColorStop(o, col));
ctx.fillStyle = grad; ctx.fillRect(q.x - r, q.y - r, r * 2, r * 2);
const rot = ((performance.now() - T0) / 1000) * .05;
ctx.globalCompositeOperation = C === PAL.night ? "lighter" : "multiply";
LAND_SPOTS.forEach((spot) => {
const ang = spot.a + rot;
const lx = q.x + Math.cos(ang) * r * spot.r * .9, ly = q.y + Math.sin(ang) * r * spot.r * .55;
const rr = r * spot.s;
const g2 = ctx.createRadialGradient(lx, ly, 0, lx, ly, rr);
g2.addColorStop(0, C.slab + (C === PAL.night ? ".5)" : ".22)")); g2.addColorStop(1, C.slab + "0)");
ctx.fillStyle = g2; ctx.beginPath(); ctx.arc(lx, ly, rr, 0, 7); ctx.fill();
});
ctx.globalCompositeOperation = "source-over";
const shade = ctx.createRadialGradient(q.x - r * .3, q.y - r * .32, r * .35, q.x, q.y, r * 1.02);
shade.addColorStop(0, "rgba(0,0,0,0)");
shade.addColorStop(1, C === PAL.night ? "rgba(2,6,18,.55)" : "rgba(25,55,105,.22)");
ctx.fillStyle = shade; ctx.fillRect(q.x - r, q.y - r, r * 2, r * 2);
ctx.restore();
ctx.save();
ctx.strokeStyle = C.edge + ".55)"; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.arc(q.x, q.y, r, 0, 7); ctx.stroke();
ctx.restore();
ctx.save(); setAdd();
ctx.strokeStyle = C.ring + ".5)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.ellipse(q.x, q.y, r * 1.25, r * .30, -.18, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
drawSlabs(q, r, explode);
}
/* ---------- 卫星:轨道位置 / 连线 / 本体 / 芯片标签 / 双灯 ---------- */
function computeSats(K, vtSec) {
const outerA = K.outerA, outerB = outerA * SQUASH, innerA = ORBIT_INNER_A0, innerB = innerA * SQUASH;
return SAT_DEFS.map((def, i) => {
const a = def.orbit === "outer" ? outerA : innerA, b = def.orbit === "outer" ? outerB : innerB;
const angle = def.phase + def.dir * def.speed * vtSec;
const wx = Math.cos(angle) * a, wy = Math.sin(angle) * b;
return { def, i, wx, wy, back: Math.sin(angle) > .02 };
});
}
function satAnchorAndSurf(s) {
const d = Math.hypot(s.wx, s.wy) || 1, ux = s.wx / d, uy = s.wy / d;
return { anchor: { wx: s.wx - ux * .09, wy: s.wy - uy * .09 }, surf: { wx: ux * STAR_R * 1.05, wy: uy * STAR_R * 1.05 }, ux, uy };
}
const flowSeeds = SAT_DEFS.map(() => Array.from({ length: 6 }, (_, i) => ({ o: i / 6, w: .6 + (i % 3) * .18 })));
function drawFlowDotsGeneric(seeds, pts, colorPrefix, alphaScale, speed) {
const vt = performance.now() - T0;
seeds.forEach((fs) => {
const u = (fs.o + vt * speed * fs.w) % 1;
const pt = pts[Math.min(pts.length - 1, Math.floor(u * (pts.length - 1)))];
dotWorld(pt, Math.max(1.6, Geo.S * .007) * fs.w, colorPrefix, .85 * alphaScale, fs.w > 1.0);
});
}
function drawSatBody(s, front) {
const q = proj(s.wx, s.wy);
_dotAt(q.x, q.y, Math.max(2.4, Geo.S * .012), C.chan[s.i], front ? .95 : .35, front);
}
function drawSatLink(s, front) {
const { anchor, surf, ux, uy } = satAnchorAndSurf(s);
const mid = { wx: (anchor.wx + surf.wx) / 2, wy: (anchor.wy + surf.wy) / 2 };
const px = -uy, py = ux, bow = (s.i % 2 ? -1 : 1) * .10;
const ctrl = { wx: mid.wx + px * bow, wy: mid.wy + py * bow };
const pts = bezQuadWorld(anchor, ctrl, surf, 22);
Geo.satLinePts[s.i] = pts;
const colorPrefix = C.chan[s.i], alpha = front ? .6 : .26;
polyGlowWorld(pts, colorPrefix, alpha, 2.0, null);
drawFlowDotsGeneric(flowSeeds[s.i], pts, colorPrefix, front ? 1 : .5, .00012);
drawSatBody(s, front);
}
function drawSatChip(s, K) {
const q = proj(s.wx, s.wy);
const dx = q.x - Geo.CX, dy = q.y - Geo.CY, d = Math.hypot(dx, dy) || 1, ux = dx / d, uy = dy / d;
const { w: cw, h: ch } = chipSize();
const ax = q.x + ux * 14, ay = q.y + uy * 14;
let bx = ax - (ux < 0 ? cw : 0), by = ay - ch / 2;
if (bx < Geo.x0) bx = ax; if (bx + cw > Geo.x1) bx = ax - cw;
bx = clamp(bx, Geo.x0, Geo.x1 - cw); by = clamp(by, Geo.y0, Geo.y1 - ch);
const colorSolid = C.chan[s.i] + ".9)";
ctx.save();
ctx.fillStyle = C === PAL.night ? "rgba(10,17,36,.82)" : "rgba(255,255,255,.88)";
ctx.strokeStyle = C.edge + ".35)"; ctx.lineWidth = 1;
roundRect(bx, by, cw, ch, 9); ctx.fill(); ctx.stroke();
ctx.save(); roundRect(bx, by, 4, ch, 2); ctx.fillStyle = colorSolid; ctx.fill(); ctx.restore();
ctx.fillStyle = C.ink; ctx.font = `600 ${Math.max(11, ch * .32)}px "Noto Sans SC"`;
ctx.fillText(SAT_NAME[s.def.provider] || s.def.provider, bx + 12, by + ch * .42);
ctx.fillStyle = C.dim; ctx.font = `${Math.max(9, ch * .21)}px "DejaVu Sans Mono"`;
ctx.fillText(s.def.sub, bx + 12, by + ch * .72);
ctx.strokeStyle = C.edge + ".4)";
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx + (ux < 0 ? cw : 0), by + ch / 2); ctx.stroke();
if (K.srcExpand > .3) {
const info = ((state.data.sources && state.data.sources.items) || []).find((it) => it.provider === s.def.provider) || {};
const health = info.health || {};
ctx.fillStyle = C.cyan; ctx.font = `${Math.max(9, ch * .19)}px "DejaVu Sans Mono"`;
ctx.fillText(`${health.latency_ms ?? "-"}ms · ${health.state || "-"}`, bx + 12, by + ch - 4);
}
ctx.restore();
}
function drawSatLamps(sats) {
const nowMs = performance.now();
Stage.portScreen = sats.map((s) => {
const q = proj(s.wx, s.wy), provider = s.def.provider;
lampAt(q.x, q.y + lampSize().h * 1.8, Lamps[provider].link, Lamps[provider].level(nowMs), Lamps[provider].activeKind(nowMs));
return { x: q.x, y: q.y, hide: false };
});
}
function drawSatChips(sats, K) { sats.forEach((s) => drawSatChip(s, K)); }
/* ---------- 去向端:发布(TX) / 审计(AUD),半径 1.25,保留 HEL-538 空间关系 ---------- */
function goOutFar(angle) { return { wx: Math.cos(angle) * GOOUT_R, wy: Math.sin(angle) * GOOUT_R }; }
function goOutSurf(angle) { return { wx: Math.cos(angle) * STAR_R * 1.05, wy: Math.sin(angle) * STAR_R * 1.05 }; }
const txFlowSeeds = Array.from({ length: 4 }, (_, i) => ({ o: i / 4, w: .7 + (i % 2) * .2 }));
const audFlowSeeds = Array.from({ length: 3 }, (_, i) => ({ o: i / 3, w: .7 + (i % 2) * .2 }));
function drawGoOutLines() {
const txSurf = goOutSurf(TX_ANGLE), txFar = goOutFar(TX_ANGLE);
const audSurf = goOutSurf(AUD_ANGLE), audFar = goOutFar(AUD_ANGLE);
const txCtrl = { wx: (txSurf.wx + txFar.wx) / 2, wy: (txSurf.wy + txFar.wy) / 2 + .08 };
const audCtrl = { wx: (audSurf.wx + audFar.wx) / 2, wy: (audSurf.wy + audFar.wy) / 2 - .06 };
Geo.txPts = bezQuadWorld(txSurf, txCtrl, txFar, 24);
Geo.audPts = bezQuadWorld(audSurf, audCtrl, audFar, 24);
polyGlowWorld(Geo.txPts, C.slab, .55, 2.3, null);
polyGlowWorld(Geo.audPts, C.edge, .38, 1.7, null);
drawFlowDotsGeneric(txFlowSeeds, Geo.txPts, C.slab, 1, .00016);
drawFlowDotsGeneric(audFlowSeeds, Geo.audPts, C.edge, 1, .00012);
}
function drawGoOutLabels() {
const txQ = proj(goOutFar(TX_ANGLE).wx, goOutFar(TX_ANGLE).wy);
const audQ = proj(goOutFar(AUD_ANGLE).wx, goOutFar(AUD_ANGLE).wy);
const lw = lampSize().w;
ctx.save(); ctx.textAlign = "right";
ctx.fillStyle = C.ink; ctx.font = `600 ${Math.max(11, Geo.S * .027)}px "Noto Sans SC"`;
ctx.fillText("盘后发布", txQ.x - lw - 6, txQ.y - 8);
ctx.fillStyle = C.dim; ctx.font = `${Math.max(9, Geo.S * .02)}px "DejaVu Sans Mono"`;
ctx.fillText("TX 回执", txQ.x - lw - 6, txQ.y + 9);
ctx.fillStyle = C.ink; ctx.font = `600 ${Math.max(11, Geo.S * .027)}px "Noto Sans SC"`;
ctx.fillText("审计留痕", audQ.x - lw - 6, audQ.y - 8);
ctx.textAlign = "left"; ctx.restore();
}
const JUNCTION_ANGLE = 165 * D2R;
function drawJunctionAndEndpointLamps() {
const nowMs = performance.now();
const jq = proj(Math.cos(JUNCTION_ANGLE) * STAR_R * .92, Math.sin(JUNCTION_ANGLE) * STAR_R * .92);
lampAt(jq.x, jq.y, "ok", Lamps.junction.level(nowMs), Lamps.junction.activeKind(nowMs));
const txQ = proj(goOutFar(TX_ANGLE).wx, goOutFar(TX_ANGLE).wy);
lampAt(txQ.x, txQ.y, "ok", Lamps.tx.level(nowMs), Lamps.tx.activeKind(nowMs));
const audQ = proj(goOutFar(AUD_ANGLE).wx, goOutFar(AUD_ANGLE).wy);
lampAt(audQ.x, audQ.y, "ok", Lamps.audit.level(nowMs), Lamps.audit.activeKind(nowMs));
}
function drawPublishLabels(alpha) {
if (alpha < .05) return;
const q = proj(0, 0), r = Geo.S * STAR_R;
ctx.save(); ctx.globalAlpha = alpha;
ctx.font = "600 11px \"Noto Sans SC\"";
ctx.fillStyle = C.cyan; ctx.fillText("RX 批次入口", q.x - r * 1.35, q.y + r * 1.1);
ctx.fillStyle = C.red; ctx.fillText("失败 → 回滚分叉", q.x + r * .15, q.y + r * 1.2);
ctx.restore();
}
/* ---------- 事件脉冲(沿卫星线路→星体接点→TX/审计),复用真实事件 PACKETS 队列 ---------- */
const TRAVEL_IN = 900, TRAVEL_OUT = 750;
function drawPackets() {
const nowMs = performance.now();
for (let k = PACKETS.length - 1; k >= 0; k--) {
const pk = PACKETS[k]; const dt = nowMs - pk.t0;
if (dt > TRAVEL_IN + TRAVEL_OUT + 400) { PACKETS.splice(k, 1); continue; }
const col = pk.rb ? "rgba(255,107,107," : "rgba(255,214,150,";
if (dt < TRAVEL_IN) {
const u = dt / TRAVEL_IN, pts = Geo.satLinePts[pk.src]; if (!pts) continue;
for (let j = 0; j < 5; j++) { const uu = Math.min(1, u - j * .03); if (uu < 0) break; const pt = pts[Math.floor(uu * (pts.length - 1))]; dotWorld(pt, Math.max(1.8, Geo.S * .009) * (1 - j * .15), col, .9 - j * .15, j === 0); }
} else {
const u = (dt - TRAVEL_IN) / TRAVEL_OUT, pts = pk.rb ? Geo.audPts : Geo.txPts; if (!pts) continue;
for (let j = 0; j < 5; j++) { const uu = Math.min(1, u - j * .03); if (uu < 0) break; const pt = pts[Math.floor(uu * (pts.length - 1))]; dotWorld(pt, Math.max(1.7, Geo.S * .008) * (1 - j * .15), col, .85 - j * .14, j === 0); }
}
}
}
/* ---------- 幕2:调度任务环(沿外轨分布真实任务节点) ---------- */
function drawTasksRing(a, b, amt) {
const jobsData = state.data.jobs; if (!jobsData) return;
const jobs = jobsData.jobs || [], runs = jobsData.runs || [];
const latestByJob = new Map();
for (const r of runs) if (!latestByJob.has(r.job_id)) latestByJob.set(r.job_id, r);
const n = Math.max(1, jobs.length);
const nowMs = performance.now();
jobs.forEach((job, i) => {
const ang = (i / n) * Math.PI * 2 + Math.PI * .08;
const q = proj(Math.cos(ang) * a, Math.sin(ang) * b);
const run = latestByJob.get(job.id), st = run ? run.state : "never";
const color = st === "failed" ? "rgba(255,107,107," : st === "ok" ? "rgba(47,216,206," : st === "running" ? "rgba(255,184,77," : "rgba(150,160,190,";
_dotAt(q.x, q.y, (st === "never" ? 2.6 : 4.2) * clamp(Geo.S / 380, .7, 1.3), color, (st === "never" ? .35 : .9) * amt, st !== "never");
const isFresh = (Stage.freshJobRuns || []).some((r) => r.job_id === job.id);
if (isFresh) lampAt(q.x, q.y - 16, "ok", Lamps.junction.level(nowMs), st === "failed" ? "error" : "ok");
ctx.save(); ctx.globalAlpha = .6 * amt; ctx.fillStyle = st === "failed" ? C.red : C.dim;
ctx.font = `${Math.max(9, Geo.S * .026)}px "Noto Sans SC"`; ctx.textAlign = "center";
ctx.fillText(String(job.title || job.id).slice(0, 8), q.x, q.y - 12); ctx.textAlign = "left"; ctx.restore();
});
}
/* ---------- 幕5:数据集大陆标记(星体表面 6 个锚点展开信息卡) ---------- */
const CONTINENT_ANGLES = [30, 90, 150, 210, 270, 330].map((d) => d * D2R);
function drawDatasetContinents(amt) {
const pubs = (state.data.datasets && state.data.datasets.publications) || [];
const r = STAR_R * 1.18;
ctx.save(); ctx.globalAlpha = clamp(amt, 0, 1);
CONTINENT_ANGLES.forEach((ang, i) => {
const ds = pubs[i]; if (!ds) return;
const q = proj(Math.cos(ang) * r, Math.sin(ang) * r);
_dotAt(q.x, q.y, Math.max(2.2, Geo.S * .01), C.slab, .9, true);
const dx = q.x - Geo.CX, dy = q.y - Geo.CY, d = Math.hypot(dx, dy) || 1, ux = dx / d, uy = dy / d;
let x = q.x + ux * 78 - 75, y = q.y + uy * 46 - 20;
x = clamp(x, Geo.x0, Geo.x1 - 150); y = clamp(y, Geo.y0, Geo.y1 - 40);
ctx.strokeStyle = C.edge + ".4)"; ctx.beginPath(); ctx.moveTo(q.x, q.y); ctx.lineTo(x + 75, y + 20); ctx.stroke();
ctx.fillStyle = C.face + ".16)"; ctx.strokeStyle = C.edge + ".55)"; ctx.lineWidth = 1;
roundRect(x, y, 150, 40, 8); ctx.fill(); ctx.stroke();
ctx.fillStyle = C.ink; ctx.font = "600 12px \"Noto Sans SC\""; ctx.fillText(String(ds.dataset || ""), x + 10, y + 17);
ctx.fillStyle = C.dim; ctx.font = "10px \"DejaVu Sans Mono\""; ctx.fillText(`${ds.active_batch || ""} · ${ds.state || ""}`, x + 10, y + 32);
});
ctx.restore();
}
/* ---------- 幕6:审计时间尾迹(星体后方,半径 ≤1.02) ---------- */
function drawAuditTrail(amt) {
if (amt < .05) return;
const items = ((state.data.audit && state.data.audit.items) || []).slice(0, 5);
const n = Math.max(1, items.length);
ctx.save();
for (let i = 0; i < n; i++) {
const t = i / Math.max(1, n - 1);
// 角度收窄在 150°~210°(星体左后方),半径 ≤1.02,确保 |wy|<=0.51、|wx|<=1.02
// 双双落在 ±1.28×±0.6656 硬包络内(不依赖某一档具体安全盒的富余量)
const ang = D2R * (150 + t * 60), rad = STAR_R * 1.43 + t * (1.02 - STAR_R * 1.43);
const q = proj(Math.cos(ang) * rad, Math.sin(ang) * rad);
const a = items[i];
ctx.globalAlpha = amt * (1 - t * .5);
// _dotAt 的 colorPrefix 需要 "rgba(r,g,b," 前缀形态(供其内部拼接 alpha/")"),
// 不能直接传 C.red/C.cyan 这类完整 hex;复用既有的失败态红与 C.slab 青色前缀,不新增色值
const col = a && String(a.action).includes("rollback") ? "rgba(255,107,107," : C.slab;
_dotAt(q.x, q.y, Math.max(2.2, Geo.S * .01), col, .8, true);
if (a) {
ctx.fillStyle = C.ink; ctx.font = `${Math.max(9, Geo.S * .022)}px "Noto Sans SC"`;
ctx.fillText(`${a.action || ""} · ${a.target || ""}`.slice(0, 20), q.x + 10, q.y + 3);
ctx.fillStyle = C.dim; ctx.font = `${Math.max(8, Geo.S * .018)}px "DejaVu Sans Mono"`;
ctx.fillText(timeShort(a.created_at), q.x + 10, q.y + 14);
}
}
ctx.restore();
}
/* ---------- 主循环 ---------- */
const publicApi = { portScreen: [], freshJobRuns: [] };
function scheduleNav() { document.querySelectorAll("#rail .stop").forEach((el) => { el.onclick = () => Nav.go(+el.dataset.i); }); }
function buildCabinShell() {
$("rail").innerHTML = SCENES.map((s, i) => `<div class="stop" data-i="${i}"><i></i>${esc(s.label)}</div>`).join('<div class="sep"></div>');
scheduleNav();
}
function resize() {
if (!canvas) return;
DPR = Math.min(devicePixelRatio || 1, 2);
const r = canvas.parentElement.getBoundingClientRect();
W = r.width; H = r.height;
canvas.width = W * DPR; canvas.height = H * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
function positionDetail(idx) {
const el = $("detail");
let anchor = null;
if (idx === 1 && Stage.portScreen.length) anchor = Stage.portScreen.find((q) => q && !q.hide);
if (anchor) {
el.style.left = clamp(anchor.x + 34, 20, W - 380) + "px";
el.style.top = clamp(anchor.y - 40, 16, H - 260) + "px";
} else {
el.style.left = Math.max(20, W / 2 - 180) + "px";
el.style.top = "20px";
}
}
function onCanvasClick(e) {
const r = canvas.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
let best = -1, bd = 30 * 30;
Stage.portScreen.forEach((q, i) => { if (!q) return; const d = (q.x - x) ** 2 + (q.y - y) ** 2; if (d < bd) { bd = d; best = i; } });
const sphereR = Geo.S * STAR_R;
const coreHit = Math.hypot(x - Geo.CX, y - Geo.CY) < sphereR * 1.3;
if (best >= 0 || coreHit) Detail.open(SCENES[state.scene].key);
else Detail.close();
}
let cabinIdx = -1;
function renderCabin(idx) {
const c = SCENE_CABIN[idx]();
const el = $("cabin");
el.innerHTML = `<div class="eyebrow">${esc(c.eyebrow)}</div><h2>${esc(c.title)}</h2><div class="sub">${esc(c.sub)}</div>
<div class="rows">${c.rows.map((r) => `<div class="row"><span class="dotlamp${r[2] === "fail" ? " fail" : r[2] === "warn" ? " warn" : ""}"></span><span>${esc(r[0])}</span><span class="fill"></span><b class="mono">${esc(r[1])}</b></div>`).join("")}</div>
<div class="actions">${c.actions.map((a, i) => `<button class="btn ${esc(a.cls)}" data-act="${i}">${esc(a.label)}</button>`).join("")}</div>`;
el.querySelectorAll("[data-act]").forEach((btn, i) => { btn.onclick = c.actions[i].action; });
}
function syncUI(idx, p) {
// 脏标记驱动:只在幕次真正切换时才重建 #cabin / 重新定位 rail
// 不再每帧(60x/秒)重建,避免真实鼠标点击/输入所在节点被整体替换;
// 同幕内的数据更新交给下方 Views.onChange(真实数据变化时才刷新)。
if (idx !== cabinIdx) {
const el = $("cabin");
el.classList.add("hide");
setTimeout(() => { renderCabin(idx); el.classList.remove("hide"); }, 180);
cabinIdx = idx;
document.querySelectorAll("#rail .stop").forEach((el2, i) => el2.classList.toggle("on", i === idx));
$("hint").classList.toggle("off", p > .15);
$("crumb").textContent = `8766 · ${SCENES[idx].label} · 持续运转`;
$("phase").textContent = `${idx + 1} / ${SCENES.length}`;
state.scene = idx;
}
}
Views.onChange(() => {
if (running && cabinIdx >= 0) renderCabin(cabinIdx);
if (Detail.openKey && !$("detail").classList.contains("closed")) Detail.reopen();
});
function render() {
if (!canvas) return;
computeSafeBox();
drawBackdrop();
const p = progress();
const K = chapterAt(p);
const vtSec = (performance.now() - T0) / 1000;
const sats = computeSats(K, vtSec);
drawOrbitEllipse(K.outerA, K.outerA * SQUASH);
drawOrbitEllipse(ORBIT_INNER_A0, ORBIT_INNER_A0 * SQUASH);
if (K.trail > .05) drawAuditTrail(K.trail);
sats.filter((s) => s.back).forEach((s) => drawSatLink(s, false));
drawSphere(K.explode);
if (K.taskRing > .05) drawTasksRing(K.outerA, K.outerA * SQUASH, K.taskRing);
sats.filter((s) => !s.back).forEach((s) => drawSatLink(s, true));
drawGoOutLines();
drawPackets();
if (K.unfold > .3) drawDatasetContinents(Math.min(1, (K.unfold - .3) * 1.6));
drawPublishLabels(Math.max(0, Math.min(1, (K.explode - .3) / .55)));
drawSatChips(sats, K);
drawGoOutLabels();
drawSatLamps(sats);
drawJunctionAndEndpointLamps();
const nowMs = performance.now();
Object.values(Lamps).forEach((ch) => { if (ch instanceof LampChannel) ch.prune(nowMs); });
syncUI(K.idx, p);
}
function loop() { if (!running) return; render(); rafId = requestAnimationFrame(loop); }
Object.assign(publicApi, {
init() {
canvas = $("scene"); ctx = canvas.getContext("2d");
initPalette();
addEventListener("resize", resize);
resize();
buildCabinShell();
canvas.addEventListener("click", onCanvasClick);
$("detailClose").addEventListener("click", () => Detail.close());
// 相机(安全盒缩放 S)由 render() 每帧直接读取 scrollY + 窗口尺寸计算,
// 天然免疫快速/反向滚动排队问题,无需额外监听 scroll 事件。
},
onThemeChange() { initPalette(); bgKey = ""; },
start() { if (!running) { running = true; scheduleNav(); loop(); } },
resume() { this.start(); },
pause() { running = false; if (rafId) cancelAnimationFrame(rafId); rafId = null; },
stop() { this.pause(); },
scrollToScene(idx) {
const track = $("track");
const secH = track.offsetHeight / 6;
scrollTo({ top: track.offsetTop + idx * secH, behavior: "smooth" });
},
renderDetail(idx) {
const body = SCENE_DETAIL[idx]();
$("detailBody").innerHTML = body;
if (SCENE_BIND[idx]) SCENE_BIND[idx]($("detailBody"));
positionDetail(idx);
$("detail").classList.remove("closed");
},
closeDetail() { $("detail").classList.add("closed"); },
});
return publicApi;
})();
/* ==========================================================================
减少动态效果版:六幕静态区块,正常滚动,无位移/旋转/闪烁,信息与功能等价。
========================================================================== */
const StaticShell = (() => {
let mounted = false;
function drawStaticDiagram(canvasEl, seedAngle) {
// 星体中枢(第八版)静态版:星体 + 2 层完整椭圆轨道 + 4 颗卫星 + 指向星体的静态流向箭头。
// 与动效版同一套几何语言,只是冻结自转/环绕/闪烁——信息与功能保持等价。
const ctx = canvasEl.getContext("2d");
const W = canvasEl.width = canvasEl.clientWidth * 2;
const H = canvasEl.height = canvasEl.clientHeight * 2;
const night = document.documentElement.getAttribute("data-theme") === "night";
ctx.fillStyle = night ? "#0A1020" : "#F1F4FC";
ctx.fillRect(0, 0, W, H);
const cx = W / 2, cy = H / 2;
const ringColor = night ? "rgba(140,190,255,.4)" : "rgba(47,99,214,.35)";
const rOuterX = Math.min(W, H) * .42, rOuterY = rOuterX * .52;
const rInnerX = rOuterX * .74, rInnerY = rInnerX * .52;
ctx.strokeStyle = ringColor; ctx.lineWidth = 1.4;
[[rOuterX, rOuterY], [rInnerX, rInnerY]].forEach(([rx, ry]) => {
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.stroke();
});
const starR = Math.min(W, H) * .13;
const grad = ctx.createRadialGradient(cx - starR * .3, cy - starR * .3, starR * .1, cx, cy, starR);
grad.addColorStop(0, night ? "#EAF6FF" : "#FFFFFF");
grad.addColorStop(1, night ? "#2E5A94" : "#5488C4");
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(cx, cy, starR, 0, Math.PI * 2); ctx.fill();
// 四颗卫星:固定角度(每个诊断图用 seedAngle 做轻微错位,避免六幕图完全重复)
// 识别色沿用统一设计 Token --chan1..4(与动效版 PAL.chan 同源,不叠加新配色)
const colors = night ? ["#96CDFF", "#78B4FF", "#B9AFFF", "#FFD6A0"] : ["#2F63D6", "#0A7C92", "#6D5DD6", "#BE780A"];
const angles = [200, 20, 140, 320].map((d) => (d + seedAngle * 12) * Math.PI / 180);
const radii = [[rOuterX, rOuterY], [rOuterX, rOuterY], [rInnerX, rInnerY], [rInnerX, rInnerY]];
angles.forEach((ang, i) => {
const [rx, ry] = radii[i];
const sx = cx + Math.cos(ang) * rx, sy = cy + Math.sin(ang) * ry;
ctx.strokeStyle = colors[i]; ctx.globalAlpha = .6; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(cx, cy); ctx.stroke();
const mx = sx + (cx - sx) * .35, my = sy + (cy - sy) * .35;
const ah = Math.atan2(cy - sy, cx - sx);
ctx.save(); ctx.translate(mx, my); ctx.rotate(ah);
ctx.beginPath(); ctx.moveTo(-6, -4); ctx.lineTo(6, 0); ctx.lineTo(-6, 4); ctx.closePath();
ctx.fillStyle = colors[i]; ctx.globalAlpha = .85; ctx.fill();
ctx.restore();
ctx.globalAlpha = 1;
ctx.fillStyle = colors[i]; ctx.beginPath(); ctx.arc(sx, sy, 5, 0, Math.PI * 2); ctx.fill();
});
}
function lampChipHTML(provider) {
const lamp = Lamps[provider];
const linkOn = lamp.link === "ok";
const recent = Date.now() - lamp.lastAt < 8000;
const label = FLOW_LABEL[provider] || provider;
return `<span class="lampchip"><span class="lk${linkOn ? " on" : ""}"></span><span class="ac${recent ? " on recent" : ""}"></span>${esc(label)}
${lamp.lastAt ? `<span class="muted">· ACT ${Math.max(0, Math.round((Date.now() - lamp.lastAt) / 1000))}s 前</span>` : '<span class="muted">· 无最近事件</span>'}</span>`;
}
function sceneBlockHTML(idx) {
const s = SCENES[idx];
const cab = SCENE_CABIN[idx]();
return `
<section class="scene-static" id="static-${s.key}" data-scene="${idx}">
<div class="scene-head"><span class="eyebrow">${esc(cab.eyebrow)}</span><h2>${esc(cab.title)}</h2></div>
<canvas class="diagram" id="diagram-${idx}"></canvas>
<div class="scene-static-sub muted">${esc(cab.sub)}</div>
${idx === 1 ? `<div class="lamprow">${FLOW_PROVIDERS.map(lampChipHTML).join("")}</div>` : ""}
<div class="cards">${cab.rows.map((r) => `<div class="card"><div class="muted">${esc(r[0])}</div><strong class="${r[2] === "fail" ? "fail" : r[2] === "warn" ? "warn" : ""}">${esc(r[1])}</strong></div>`).join("")}</div>
<div id="static-detail-${idx}">${SCENE_DETAIL[idx]()}</div>
</section>`;
}
function rebindScene(idx) {
const root = $(`static-detail-${idx}`);
if (SCENE_BIND[idx]) SCENE_BIND[idx](root);
}
function rerenderScene(idx) {
const cab = SCENE_CABIN[idx]();
const section = document.querySelector(`.scene-static[data-scene="${idx}"]`);
if (!section) return;
const cardsEl = section.querySelector(".cards");
if (cardsEl) cardsEl.innerHTML = cab.rows.map((r) => `<div class="card"><div class="muted">${esc(r[0])}</div><strong class="${r[2] === "fail" ? "fail" : r[2] === "warn" ? "warn" : ""}">${esc(r[1])}</strong></div>`).join("");
if (idx === 1) { const lr = section.querySelector(".lamprow"); if (lr) lr.innerHTML = FLOW_PROVIDERS.map(lampChipHTML).join(""); }
const detailEl = $(`static-detail-${idx}`);
if (detailEl) { detailEl.innerHTML = SCENE_DETAIL[idx](); rebindScene(idx); }
}
function mount() {
if (mounted) return;
mounted = true;
$("staticNav").innerHTML = SCENES.map((s, i) => `<button data-i="${i}">${esc(s.label)}</button>`).join("");
$("staticNav").querySelectorAll("button").forEach((btn, i) => {
btn.addEventListener("click", () => { setActive(i); scrollToInternal(i); });
});
$("staticScenes").innerHTML = SCENES.map((_, i) => sceneBlockHTML(i)).join("");
SCENES.forEach((_, i) => { drawStaticDiagram($(`diagram-${i}`), i * .5); rebindScene(i); });
setActive(0);
Views.onChange(refreshAll);
}
function refreshAll() { if (mounted) SCENES.forEach((_, i) => rerenderScene(i)); }
function setActive(i) {
$("staticNav").querySelectorAll("button").forEach((b, j) => b.classList.toggle("active", j === i));
state.scene = i;
$("crumb").textContent = `8766 · ${SCENES[i].label} · 持续运转`;
$("phase").textContent = `${i + 1} / ${SCENES.length}`;
}
function scrollToInternal(i) {
const el = document.querySelector(`.scene-static[data-scene="${i}"]`);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}
function unmount() { /* CSS 控制显隐,无需清空内容,避免频繁重建 DOM */ }
return { mount, unmount, scrollTo: scrollToInternal };
})();
/* ==========================================================================
启动
========================================================================== */
function enterShell() {
Stage.init();
applyReduced(REDUCE_MQ.matches);
const savedTheme = (() => { try { return localStorage.getItem("hub_theme"); } catch { return null; } })();
applyTheme(savedTheme === "night" ? "night" : "day");
Poller.startAll();
updateCrumbStatus();
}
boot();