- 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>
1472 lines
71 KiB
JavaScript
1472 lines
71 KiB
JavaScript
"use strict";
|
||
/* ==========================================================================
|
||
xiaobai-datahub 管理后台 · 第八版「星港中枢」
|
||
一台持续运转的星系:整页星空舞台,中央自转星体 + 4 颗来源卫星沿 2 层完整椭圆
|
||
轨道环绕,滚动只是在同一座星系里移动镜头强调;A2 路由器式 LINK/ACT 信号灯——
|
||
全部由真实接口数据驱动,没有任何模拟流量。
|
||
只有 tushare 的逐次调用(recent_calls)和 eastmoney/tencent/ifind 的健康探测
|
||
(每次拉取 /admin/api/sources 都是一次真实网络探测)能证明"发生过一次事件",
|
||
因此只有这四路会闪 ACT;ths/xgb/akshare 是预留源,永远不闪。
|
||
========================================================================== */
|
||
|
||
/* ---------------------------------------------------------------- 基础工具 */
|
||
function $(id) { return document.getElementById(id); }
|
||
function esc(value) {
|
||
return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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();
|