From 475ed4a8722a73b6990cf810600eff6fd14e63be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Sun, 13 Sep 2026 11:56:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(HEL-540):=20=E6=95=B0=E6=8D=AE=E4=B8=AD?= =?UTF-8?q?=E6=9E=A2=E5=90=8E=E5=8F=B0=E5=8D=87=E7=BA=A7=E4=B8=BA=E3=80=8C?= =?UTF-8?q?=E7=AC=AC=E5=85=AB=E7=89=88=C2=B7=E6=98=9F=E6=B8=AF=E4=B8=AD?= =?UTF-8?q?=E6=9E=A2=E3=80=8D=E5=85=A8=E5=8A=A8=E6=95=88=E5=8D=95=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用 Canvas 2D 自写透视投影重写 Stage 引擎:中央自转星体 + 4 颗来源卫星 (tushare/东方财富/腾讯/iFinD)沿 2 层完整椭圆轨道环绕,4 条入向线 + 2 条去向线(发布/审计),卫星名称与 LINK/ACT 双灯始终最上层、常显不隐 - 落地 HEL-538 安全盒硬公式:x0=cabinW+24,x1=W-24,y0=64,y1=H-118, 世界坐标统一钳制在 ±1.28(横)×±0.6656(纵),去向端半径 1.25 - 全屏无卡片舞台:#stageWrap 去边框/圆角/阴影,改为通栏 - LINK=稳态常亮,ACT=事件驱动短闪(tushare recent_calls / 健康探测 / 批次与发布状态变化),全局令牌桶 ≤3 簇/秒,四源独立队列 - 页面隐藏暂停 rAF + 清空未播闪簇、恢复不补播;prefers-reduced-motion 运行时 change 监听(非只读一次);静态版同步换成星体+双轨道示意图 - 识别色只复用既有设计 Token --chan1..4,未新增配色系统 - 修正审计尾迹角度/半径范围使其构造性落在安全盒硬包络内 - 修正 drawAuditTrail 误将完整 hex 色值当 rgba 前缀拼接导致 addColorStop 抛错的运行时 bug(day 主题下必现) 自测:node --check 通过;三档(1440x900/1280x800/1024x768)×日夜安全盒 数学自测脚本全通过(S 命中 427/380/291);Playwright 真浏览器起本地 测试服务端验证:六幕滚动、详情开合、隐藏/恢复暂停与续播、 prefers-reduced-motion 运行时切换、真实 probe 事件驱动 ACT、 60 秒连续运行无 DOM 增长/无 console 报错;xiaobai-datahub/tests/ 133 个后端测试全绿;git diff --check 无空白问题。 Co-authored-by: Cursor Co-authored-by: multica-agent --- xiaobai-datahub/admin/app.js | 906 +++++++++++++++++-------------- xiaobai-datahub/admin/styles.css | 17 +- 2 files changed, 522 insertions(+), 401 deletions(-) diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js index 0f567d4..00f8691 100644 --- a/xiaobai-datahub/admin/app.js +++ b/xiaobai-datahub/admin/app.js @@ -1,8 +1,9 @@ "use strict"; /* ========================================================================== - xiaobai-datahub 管理后台 · 第七版「轨道机芯」 - 一台持续运转的数据空间:滚动推镜、立体核心换面、四路真实来源持续流动、 - A2 路由器式 LINK/ACT 信号灯——全部由真实接口数据驱动,没有任何模拟流量。 + xiaobai-datahub 管理后台 · 第八版「星港中枢」 + 一台持续运转的星系:整页星空舞台,中央自转星体 + 4 颗来源卫星沿 2 层完整椭圆 + 轨道环绕,滚动只是在同一座星系里移动镜头强调;A2 路由器式 LINK/ACT 信号灯—— + 全部由真实接口数据驱动,没有任何模拟流量。 只有 tushare 的逐次调用(recent_calls)和 eastmoney/tencent/ifind 的健康探测 (每次拉取 /admin/api/sources 都是一次真实网络探测)能证明"发生过一次事件", 因此只有这四路会闪 ACT;ths/xgb/akshare 是预留源,永远不闪。 @@ -713,16 +714,50 @@ const Nav = { }; /* ========================================================================== - canvas 3D 引擎(动效版)——移植自经白栖知确认的第七版原稿, - 静态数据换成真实拉取结果,随机模拟事件全部替换为真实事件驱动。 + 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 CAM = { yaw: 0, pitch: .12, dist: 5.4, cx: 0, cy: 0, fov: 1.9 }; const T0 = performance.now(); - const now = () => 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 = { @@ -735,91 +770,96 @@ const Stage = (() => { }; 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 V = (x, y, z) => ({ x, y, z }); - function rotY(p, a) { const c = Math.cos(a), s = Math.sin(a); return V(p.x * c + p.z * s, p.y, -p.x * s + p.z * c); } - function rotX(p, a) { const c = Math.cos(a), s = Math.sin(a); return V(p.x, p.y * c - p.z * s, p.y * s + p.z * c); } - function bez(p0, p1, p2, p3, t) { - const u = 1 - t; - return V( - u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x, - u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y, - u * u * u * p0.z + 3 * u * u * t * p1.z + 3 * u * t * t * p2.z + t * t * t * p3.z, - ); - } - function project(p) { - let q = rotY(V(p.x - CAM.cx, p.y - CAM.cy, p.z), CAM.yaw); - q = rotX(q, -CAM.pitch); - const s = CAM.fov / Math.max(.3, CAM.dist - q.z); - return { x: W / 2 + q.x * s * W * .5, y: H / 2 - q.y * s * W * .5, z: q.z, s }; - } + 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(); } - const setAdd = () => { ctx.globalCompositeOperation = (C === PAL.night ? "lighter" : "source-over"); }; - function polyGlow(pts, color, alpha, width, dashPhase) { + + /* ---------- 响应式安全盒(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.5); + 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 = project(pts[i]), b = project(pts[i + 1]); - const depth = Math.max(.15, Math.min(1, 1.35 - (a.z + 3) / 6)); - let al = alpha * depth; - if (dashPhase !== null) al *= .35 + .65 * Math.max(0, Math.sin((i / pts.length) * 14 - dashPhase)); - ctx.strokeStyle = color + al.toFixed(3) + ")"; - ctx.lineWidth = width * (a.s * 2.2); ctx.lineCap = "round"; + 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 dot3(p, r, color, alpha, glow) { - const q = project(p); - const depth = Math.max(.2, Math.min(1, 1.35 - (q.z + 3) / 6)); - const rr = r * q.s * 2.4; - ctx.save(); setAdd(); - if (C !== PAL.night) alpha = Math.min(1, alpha * 1.35); - if (glow) { - const g = ctx.createRadialGradient(q.x, q.y, 0, q.x, q.y, rr * 3.2); - g.addColorStop(0, color + (.5 * alpha * depth) + ")"); g.addColorStop(1, color + "0)"); - ctx.fillStyle = g; ctx.beginPath(); ctx.arc(q.x, q.y, rr * 3.2, 0, 7); ctx.fill(); - } - ctx.fillStyle = color + (alpha * depth) + ")"; - ctx.beginPath(); ctx.arc(q.x, q.y, rr, 0, 7); ctx.fill(); - ctx.restore(); - return q; + 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; } - /* A2 双灯:LINK 反映真实连通状态(常亮)+ ACT 事件驱动短闪 */ - function lamp3(p, level, scale, kind, linkState) { - const q = project(p); - const u = Math.max(.7, q.s * 2.2) * (scale || 1); - const x = q.x, y = q.y, w = 13 * u, h = 5 * u, gap = 4 * u; + function lampAt(x, y, linkState, level, kind) { + const { w, h } = lampSize(); const gap = h * .8, pad = 3; ctx.save(); - ctx.globalAlpha = .9; - ctx.fillStyle = C === PAL.night ? "rgba(6,12,24,.78)" : "rgba(255,255,255,.92)"; + 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 - 4 * u, y - h - gap / 2 - 4 * u, w + 8 * u, h * 2 + gap + 8 * u, 5 * u); ctx.fill(); ctx.stroke(); + 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" ? .6 : linkState === "error" ? .55 : .25; - ctx.globalAlpha = linkAlpha; ctx.fillStyle = linkColor; ctx.shadowColor = linkColor; ctx.shadowBlur = linkState === "ok" || linkState === "error" ? 6 * u : 0; + 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 = 18 * u * level; - } else { ctx.globalAlpha = .18; ctx.fillStyle = C.amber; } + 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(); - return q; } - /* ---------- 六幕相机关键帧(纯几何/镜头语言,已由白栖知确认) ---------- */ + /* ---------- 六幕镜头强调参数(纯几何/结构语言,不引入相机旋转,安全盒钳制天然对全部取值有效) ---------- */ const CH = [ - { yaw: .00, pitch: .13, dist: 4.7, cx: 0, cy: 0, scale: 1.18, explode: .14, ring: 1.0, spread: 1.0, unfold: 0, trail: 0 }, - { yaw: .62, pitch: .17, dist: 3.7, cx: -.55, cy: .12, scale: .98, explode: .14, ring: .8, spread: 2.0, unfold: 0, trail: 0 }, - { yaw: 1.18, pitch: .30, dist: 4.2, cx: 0, cy: -.1, scale: .72, explode: .1, ring: .95, spread: .9, unfold: 0, trail: 0 }, - { yaw: 1.72, pitch: .06, dist: 4.0, cx: .1, cy: .24, scale: .9, explode: .7, ring: .6, spread: .75, unfold: 0, trail: 0 }, - { yaw: 2.38, pitch: .15, dist: 4.3, cx: 0, cy: 0, scale: 1.0, explode: .22, ring: 1.0, spread: .9, unfold: 1, trail: 0 }, - { yaw: 2.92, pitch: .10, dist: 4.6, cx: .4, cy: 0, scale: .95, explode: .16, ring: 1.0, spread: 1.0, unfold: 0, trail: 1 }, + { 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; } @@ -827,293 +867,340 @@ const Stage = (() => { 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); } - /* ---------- 几何:核心 / 端口 / 流道 / 环 ---------- */ - const PORTS = [V(-1.02, .52, .18), V(-1.05, -.42, .38), V(-.5, .98, -.4), V(-.48, -.92, -.42)]; - const SRC_FAR = [V(-3.4, 1.9, -1.6), V(-3.6, -1.7, -.8), V(-2.3, 2.6, -2.4), V(-2.2, -2.7, -2.0)]; - const TX_PORT = V(1.02, .18, .05), RX_PORT = V(.98, -.4, .1); - const TX_FAR = V(5.6, 1.4, -1.6), AUD_FAR = V(4.6, -2.4, .6); - function chanPts(from, to, spread, lift) { - const a = V(from.x * spread, from.y * spread, from.z); - const c1 = V(a.x * .55, a.y * .8 + lift, a.z * .7), c2 = V(to.x * 2.0, to.y * 1.5, to.z * 1.6); - const pts = []; for (let i = 0; i <= 42; i++) pts.push(bez(a, c1, c2, to, i / 42)); - return pts; - } - function getChannels(spread) { - const chans = FLOW_PROVIDERS.map((_, i) => chanPts(SRC_FAR[i], PORTS[i], spread, i % 2 ? -.5 : .5)); - chans.tx = chanPts(TX_FAR, TX_PORT, 1, .2).reverse(); - chans.aud = chanPts(AUD_FAR, RX_PORT, 1, -.2).reverse(); - return chans; - } - - const CUBE_F = [ - { n: "来源", key: "sources", idx: [0, 1, 3, 2], nor: V(-1, 0, 0) }, - { n: "发布", key: "release", idx: [4, 6, 7, 5], nor: V(1, 0, 0) }, - { n: "调度", key: "jobs", idx: [2, 3, 7, 6], nor: V(0, 1, 0) }, - { n: "暂存", key: "release2", idx: [0, 4, 5, 1], nor: V(0, -1, 0) }, - { n: "数据集", key: "datasets", idx: [1, 5, 7, 3], nor: V(0, 0, 1) }, - { n: "审计", key: "audit", idx: [0, 2, 6, 4], nor: V(0, 0, -1) }, - ]; - const CUBE_V = []; for (const x of [-1, 1]) for (const y of [-1, 1]) for (const z of [-1, 1]) CUBE_V.push(V(x * .5, y * .5, z * .5)); - function facePoint(f, u, v, sc, explode) { - const nor = f.nor, ex = explode * .5; - let t1 = Math.abs(nor.x) ? V(0, 0, 1) : V(1, 0, 0); - let t2 = V(nor.y * t1.z - nor.z * t1.y, nor.z * t1.x - nor.x * t1.z, nor.x * t1.y - nor.y * t1.x); - let base = V(nor.x * (.5 + ex), nor.y * (.5 + ex), nor.z * (.5 + ex)); - return V( - (base.x + t1.x * u * .5 + t2.x * v * .5) * sc, - (base.y + t1.y * u * .5 + t2.y * v * .5) * sc, - (base.z + t1.z * u * .5 + t2.z * v * .5) * sc, - ); - } - function faceSummary(f) { - const s = state.data; - if (f.key === "sources") { - const items = (s.sources && s.sources.items || []).filter((it) => FLOW_PROVIDERS.includes(it.provider)); - const ok = items.filter((it) => it.health && (it.health.state === "ok" || it.health.state === "empty")).length; - return `${ok}/${items.length || 4} 已连接`; - } - if (f.key === "release" || f.key === "release2") { - const pubs = (s.batches && s.batches.publications) || []; - if (f.key === "release2") { const staged = ((s.batches && s.batches.batches) || []).filter((b) => b.state === "staged").length; return `队列 ${staged}`; } - const latest = pubs.slice().sort((a, b) => String(a.published_at).localeCompare(String(b.published_at))).pop(); - return latest ? String(latest.active_batch) : "暂无发布"; - } - if (f.key === "jobs") { const runs = (s.jobs && s.jobs.runs) || []; const today = todayYmd(); const n = runs.filter((r) => String(r.started_at || "").replace(/-/g, "").startsWith(today.slice(0, 8))).length; return `今日运行 ${n}`; } - if (f.key === "datasets") { const pubs = (s.datasets && s.datasets.publications) || []; return `${pubs.length} 套`; } - if (f.key === "audit") { const items = (s.audit && s.audit.items) || []; return `留痕 ${items.length}`; } - return ""; - } - function drawCore(sc, explode, unfold, vt) { - const faces = CUBE_F.map((f, fi) => { - const corners = f.idx.map((i) => { - const vtx = CUBE_V[i]; const nor = f.nor; - let t1 = Math.abs(nor.x) ? V(0, 0, 1) : V(1, 0, 0); - let t2 = V(nor.y * t1.z - nor.z * t1.y, nor.z * t1.x - nor.x * t1.z, nor.x * t1.y - nor.y * t1.x); - const u = 2 * (vtx.x * t1.x + vtx.y * t1.y + vtx.z * t1.z), v = 2 * (vtx.x * t2.x + vtx.y * t2.y + vtx.z * t2.z); - return facePoint(f, u, v, sc, explode); + /* ---------- 星空背景(两层 ≤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(); + } }); - const pr = corners.map(project); - const zc = pr.reduce((a, p) => a + p.z, 0) / 4; - const c3 = corners.reduce((a, p) => V(a.x + p.x / 4, a.y + p.y / 4, a.z + p.z / 4), V(0, 0, 0)); - const facing = rotY(V(f.nor.x, f.nor.y, f.nor.z), CAM.yaw).z > .12; - return { f, fi, pr, zc, c3, facing }; - }).sort((a, b) => b.zc - a.zc); - // 内部层片:校验 / 暂存 / 发布 - const slabNames = ["校验", "暂存", "发布"]; - for (let s = 0; s < 3; s++) { - const y = (s - 1) * (.3 + explode * .42) * sc, hw = .36 * sc, hh = .07 * sc; - const sv = [V(-hw, y - hh, -hw), V(hw, y - hh, -hw), V(hw, y - hh, hw), V(-hw, y - hh, hw), - V(-hw, y + hh, -hw), V(hw, y + hh, -hw), V(hw, y + hh, hw), V(-hw, y + hh, hw)]; - const edges = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]]; - ctx.save(); - const fillA = .10 + explode * .10; - const top = [4, 5, 6, 7].map((i) => project(sv[i])); - ctx.fillStyle = C.slab + fillA + ")"; - ctx.beginPath(); top.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.closePath(); ctx.fill(); - ctx.strokeStyle = C.slab + ".5)"; ctx.lineWidth = 1; - edges.forEach((e) => { const a = project(sv[e[0]]), b = project(sv[e[1]]); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); }); - if (explode > .45) { - const c = project(V(hw * 1.15, y, 0)); - ctx.fillStyle = C.ink; ctx.globalAlpha = .9; ctx.font = `600 ${Math.max(10, 11 * c.s * 2)}px "Noto Sans SC"`; - ctx.fillText(slabNames[s], c.x + 8, c.y + 4); ctx.globalAlpha = 1; - } - ctx.restore(); + bctx.globalAlpha = 1; + bgKey = key; } - faces.forEach((fc) => { - const { pr, f, facing } = fc; - const depth = Math.max(.15, Math.min(1, 1.2 - (fc.zc + 2.5) / 5)); - ctx.save(); - const g = ctx.createLinearGradient(pr[0].x, pr[0].y, pr[2].x, pr[2].y); - g.addColorStop(0, C.face + ((facing ? .20 : .06) * depth + .03) + ")"); - g.addColorStop(.55, C.face + ((facing ? .07 : .02) * depth + .015) + ")"); - g.addColorStop(1, C.face + ((facing ? .14 : .04) * depth + .02) + ")"); - ctx.fillStyle = g; - ctx.beginPath(); pr.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.closePath(); ctx.fill(); - if (facing) { ctx.shadowColor = C.edge + ".9)"; ctx.shadowBlur = 9; } - ctx.strokeStyle = C.edge + ((facing ? .8 : .3) * depth + .12) + ")"; ctx.lineWidth = facing ? 1.4 : 1; ctx.stroke(); - ctx.shadowBlur = 0; - if (facing && unfold < .5) { - const c = project(fc.c3); const fs = Math.max(11, 15 * c.s * 2); - ctx.globalAlpha = .55 + .45 * depth; ctx.fillStyle = C.ink; ctx.font = `600 ${fs}px "Noto Sans SC"`; ctx.textAlign = "center"; - ctx.fillText(f.n, c.x, c.y - fs * .3); - ctx.globalAlpha *= .7; ctx.font = `${fs * .74}px "DejaVu Sans Mono"`; ctx.fillStyle = C.cyan; - ctx.fillText(faceSummary(f), c.x, c.y + fs * .75); - ctx.textAlign = "left"; ctx.globalAlpha = 1; - } - ctx.restore(); - }); - Stage.faceCenters = faces.filter((f) => f.facing).map((fc) => ({ q: project(fc.c3), fi: fc.fi })); + ctx.drawImage(bgCanvas, 0, 0, W, H); + } + + /* ---------- 轨道(2 层完整椭圆,L2) ---------- */ + function drawOrbitEllipse(a, b) { ctx.save(); setAdd(); - const b0 = project(V(0, -.8 * sc, 0)), b1 = project(V(0, .8 * sc, 0)); - const bg2 = ctx.createLinearGradient(b0.x, b0.y, b1.x, b1.y); - const beamA = C === PAL.night ? .5 : .3; - bg2.addColorStop(0, C.slab + "0)"); bg2.addColorStop(.5, C.slab + beamA + ")"); bg2.addColorStop(1, C.slab + "0)"); - ctx.strokeStyle = bg2; ctx.lineCap = "round"; ctx.lineWidth = Math.max(3, 9 * b0.s * 2); - ctx.beginPath(); ctx.moveTo(b0.x, b0.y); ctx.lineTo(b1.x, b1.y); ctx.stroke(); - dot3(V(0, 0, 0), 4, C === PAL.night ? "rgba(190,240,255," : C.slab, .8, true); + 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(); } - function drawRing(radius, tilt, vt) { - const pts = []; - for (let i = 0; i <= 72; i++) { const a = i / 72 * Math.PI * 2; pts.push(rotX(V(Math.cos(a) * radius, 0, Math.sin(a) * radius), tilt)); } - polyGlow(pts, C.ring, .5, 1.1, vt * 1.2); + + /* ---------- 星体(自转球体 + 数据大陆 + 处理环 + 校验/暂存/发布分层,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 drawTasksRing(radius, tilt) { - 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); - jobs.forEach((job, i) => { - const a0 = i / jobs.length * Math.PI * 2 + Math.PI * .1; - let p = V(Math.cos(a0) * radius, 0, Math.sin(a0) * radius); p = rotX(p, tilt); - const run = latestByJob.get(job.id); - const 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,"; - const q = dot3(p, st === "never" ? 3 : 4.6, color, st === "never" ? .4 : .9, st !== "never"); - const isFresh = (Stage.freshJobRuns || []).some((r) => r.job_id === job.id); - if (isFresh) lamp3(p, Lamps.junction.level(now()), .78, st === "failed" ? "error" : "ok"); - ctx.save(); ctx.globalAlpha = .55; ctx.fillStyle = st === "failed" ? C.red : C.dim; - ctx.font = `${Math.max(9, 10 * q.s * 2)}px "Noto Sans SC"`; ctx.textAlign = "center"; - ctx.fillText(job.title.slice(0, 8), q.x, q.y - 13 * q.s * 2); 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 }; }); } - const flowSeeds = FLOW_PROVIDERS.map(() => Array.from({ length: 24 }, (_, i) => ({ o: i / 24, w: .6 + (i % 5) * .12 }))); - function drawFlows(chans, vt, alpha) { - FLOW_PROVIDERS.forEach((provider, i) => { - const pts = chans[i]; - polyGlow(pts, C.chan[i], .5 * alpha, 2.2, vt * .0016 * (1.2 + i * .18)); - flowSeeds[i].forEach((p) => { - const u = (p.o + vt * .00006 * p.w) % 1; - const pt = pts[Math.floor(u * (pts.length - 1))]; - dot3(pt, 2.6 * p.w, C.chan[i], .95 * alpha, p.w > 1.05); - }); - }); - polyGlow(chans.tx, C.slab, .5 * alpha, 2.4, vt * .0022); - polyGlow(chans.aud, C.edge, .3 * alpha, 1.6, vt * .0016); + 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(chans) { + 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 = chans[pk.src]; - for (let j = 0; j < 6; j++) { const uu = Math.min(1, u - j * .02); if (uu < 0) break; const pt = pts[Math.floor(uu * (pts.length - 1))]; dot3(pt, 3.4 - j * .42, col, .9 - j * .13, j === 0); } + 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 ? chans.aud : chans.tx; - for (let j = 0; j < 6; j++) { const uu = Math.min(1, u - j * .02); if (uu < 0) break; const pt = pts[Math.floor(uu * (pts.length - 1))]; dot3(pt, 3.2 - j * .4, col, .85 - j * .12, j === 0); } + 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); } } } } - function drawAuditTrail(amp) { + + /* ---------- 幕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); - if (amp < .05 || !items.length) return; - ctx.save(); ctx.globalAlpha = amp; - items.forEach((a, i) => { - const t = i / Math.max(1, items.length - 1); - const ang = Math.PI * (.25 + t * .85), r = 2.6 + t * .9; - const p = V(Math.cos(ang) * r, .9 - t * 1.9, -1.6 - Math.sin(ang) * 1.2); - const q = project(p); - const col = String(a.action).includes("rollback") ? C.red : C.cyan; - ctx.globalAlpha = amp * (1 - t * .55); - ctx.fillStyle = col; ctx.beginPath(); ctx.arc(q.x, q.y, 4 * q.s * 2, 0, 7); ctx.fill(); - ctx.font = `${Math.max(9, 10 * q.s * 2)}px "Noto Sans SC"`; ctx.fillStyle = C.ink; - ctx.fillText(`${a.action} · ${a.target || ""}`.slice(0, 22), q.x + 12, q.y + 3); - ctx.font = `${Math.max(8, 9 * q.s * 2)}px "DejaVu Sans Mono"`; ctx.fillStyle = C.dim; - ctx.fillText(timeShort(a.created_at), q.x + 12, q.y + 15); - }); - ctx.restore(); - } - function drawSourceLabels(alpha) { - if (alpha < .05) return; - ctx.save(); ctx.globalAlpha = alpha; - Stage.portScreen.forEach((q, i) => { - if (!q || q.hide) return; - const provider = FLOW_PROVIDERS[i]; - const info = ((state.data.sources && state.data.sources.items) || []).find((it) => it.provider === provider) || {}; - const health = info.health || {}; - const OFF = [{ dx: 34, dy: -78 }, { dx: 34, dy: 34 }, { dx: -230, dy: -92 }, { dx: -230, dy: 48 }][i]; - let x = q.x + OFF.dx, y = q.y + OFF.dy; - x = clamp(x, 320, W - 216); y = clamp(y, 14, H - 96); - ctx.fillStyle = C.face + ".14)"; ctx.strokeStyle = C.edge + ".6)"; ctx.lineWidth = 1; - roundRect(x, y, 196, 62, 9); ctx.fill(); ctx.stroke(); - ctx.fillStyle = C.ink; ctx.font = "600 12.5px \"Noto Sans SC\""; ctx.fillText(FLOW_LABEL[provider] || provider, x + 11, y + 20); - ctx.fillStyle = C.dim; ctx.font = "10.5px \"DejaVu Sans Mono\""; - ctx.fillText(`延迟 ${health.latency_ms ?? "-"}ms · ${health.state || "-"}`, x + 11, y + 37); - ctx.fillStyle = C.cyan; ctx.fillText(info.role || "", x + 11, y + 53); - ctx.strokeStyle = C.edge + ".45)"; ctx.beginPath(); ctx.moveTo(q.x + (OFF.dx > 0 ? 10 : -10), q.y); ctx.lineTo(OFF.dx > 0 ? x : x + 196, y + 31); ctx.stroke(); - }); - ctx.restore(); - } - function drawPublishLabels(alpha) { - if (alpha < .05) return; - ctx.save(); ctx.globalAlpha = alpha; - const rx = project(RX_PORT), tx = project(TX_PORT); - ctx.font = "600 11px \"Noto Sans SC\""; - ctx.fillStyle = C.cyan; ctx.fillText("RX 批次入口", rx.x - 70, rx.y + 34); - ctx.fillStyle = C.ink; ctx.fillText("TX 发布回执", tx.x + 16, tx.y - 10); - ctx.fillStyle = C.red; ctx.fillText("回滚分叉", tx.x + 60, tx.y + 66); - ctx.restore(); - } - function drawDatasetCards(alpha, coreX, coreY) { - if (alpha < .05 || !Stage.faceCenters.length) return; - const datasets = (state.data.datasets && state.data.datasets.publications) || []; - ctx.save(); ctx.globalAlpha = alpha; - Stage.faceCenters.forEach((fc) => { - const ds = datasets[fc.fi]; if (!ds) return; - let dx = fc.q.x - coreX, dy = fc.q.y - coreY; const len = Math.hypot(dx, dy) || 1; dx /= len; dy /= len; - let x = fc.q.x + dx * 110 - 75, y = fc.q.y + dy * 80 - 30; - x = clamp(x, 316, W - 170); y = clamp(y, 14, H - 84); - ctx.strokeStyle = C.edge + ".45)"; ctx.beginPath(); ctx.moveTo(fc.q.x, fc.q.y); ctx.lineTo(x + 75, y + 30); ctx.stroke(); - ctx.fillStyle = C.face + ".16)"; ctx.strokeStyle = C.edge + ".6)"; ctx.lineWidth = 1; - roundRect(x, y, 150, 60, 9); ctx.fill(); ctx.stroke(); - ctx.fillStyle = C.ink; ctx.font = "600 12.5px \"Noto Sans SC\""; ctx.fillText(ds.dataset, x + 11, y + 19); - ctx.fillStyle = C.dim; ctx.font = "10px \"DejaVu Sans Mono\""; ctx.fillText(`${ds.active_batch} · ${ds.state}`, x + 11, y + 34); - }); + 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 = { - faceCenters: [], portScreen: [], freshJobRuns: [], - init() { - canvas = $("scene"); ctx = canvas.getContext("2d"); - initPalette(); - addEventListener("resize", resize); - resize(); - buildCabinShell(); - canvas.addEventListener("click", onCanvasClick); - $("detailClose").addEventListener("click", () => Detail.close()); - // 相机位置由 render() 每帧直接读取 scrollY 计算,天然免疫快速/反向滚动排队问题, - // 无需额外监听 scroll 事件。 - }, - onThemeChange(theme) { initPalette(); }, - 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"); }, - }; + /* ---------- 主循环 ---------- */ + const publicApi = { portScreen: [], freshJobRuns: [] }; - function scheduleNav() { - document.querySelectorAll("#rail .stop").forEach((el) => { - el.onclick = () => Nav.go(+el.dataset.i); - }); - } + function scheduleNav() { document.querySelectorAll("#rail .stop").forEach((el) => { el.onclick = () => Nav.go(+el.dataset.i); }); } function buildCabinShell() { $("rail").innerHTML = SCENES.map((s, i) => `
${esc(s.label)}
`).join('
'); scheduleNav(); @@ -1140,11 +1227,10 @@ const Stage = (() => { } function onCanvasClick(e) { const r = canvas.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top; - // 优先命中当前可见的来源端口(仅在数据源幕出现),否则命中画面中心的机芯本体—— - // 两种情况都应该打开“当前所在的那一幕”的详情,而不是硬编码成数据源。 - let best = -1, bd = 34 * 34; - Stage.portScreen.forEach((q, i) => { if (!q || q.hide) return; const d = (q.x - x) ** 2 + (q.y - y) ** 2; if (d < bd) { bd = d; best = i; } }); - const coreHit = Math.hypot(x - W / 2, y - H / 2) < Math.min(W, H) * 0.3; + 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(); } @@ -1170,7 +1256,7 @@ const Stage = (() => { $("phase").textContent = `${idx + 1} / ${SCENES.length}`; state.scene = idx; } else { - renderCabin(idx); // 刷新真实数据但不重触发进出动画 + renderCabin(idx); } if (Detail.openKey) Detail.reopen(); } @@ -1181,54 +1267,72 @@ const Stage = (() => { function render() { if (!canvas) return; - const vt = performance.now() - T0; - const max = Math.max(1, document.body.scrollHeight - innerHeight); - const p = clamp((scrollY / max) * 5, 0, 5); + computeSafeBox(); + drawBackdrop(); + const p = progress(); const K = chapterAt(p); - CAM.yaw = K.yaw + (vt / 1000) * .05; CAM.pitch = K.pitch; CAM.dist = K.dist; CAM.cx = K.cx; CAM.cy = K.cy; + const vtSec = (performance.now() - T0) / 1000; + const sats = computeSats(K, vtSec); - const bg = ctx.createRadialGradient(W * .5, H * .46, 60, W * .5, H * .5, Math.max(W, H) * .75); - bg.addColorStop(0, C.bgB); bg.addColorStop(1, C.bgA); - ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); - ctx.save(); setAdd(); - const fl = ctx.createRadialGradient(W * .5, H * .8, 10, W * .5, H * .8, W * .24); - fl.addColorStop(0, C.face + ".13)"); fl.addColorStop(1, C.face + "0)"); - ctx.fillStyle = fl; ctx.beginPath(); ctx.ellipse(W * .5, H * .8, W * .24, H * .09, 0, 0, 7); ctx.fill(); - ctx.restore(); + drawOrbitEllipse(K.outerA, K.outerA * SQUASH); + drawOrbitEllipse(ORBIT_INNER_A0, ORBIT_INNER_A0 * SQUASH); - const chans = getChannels(K.spread); - drawFlows(chans, vt, 1); - drawRing(1.9 * K.ring, .5, vt); - if (K.idx === 2) drawTasksRing(1.9 * K.ring, .5); - drawCore(K.scale, K.explode, K.unfold, vt); - drawRing(2.05 * K.ring, -.5, vt); - drawPackets(chans); + 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(); - Stage.portScreen = PORTS.map((pt, i) => { - const wp = V(pt.x * K.scale, pt.y * K.scale, pt.z * K.scale); - const behind = rotY(wp, CAM.yaw).z < -.25; - const provider = FLOW_PROVIDERS[i]; - const lv = Lamps[provider].level(nowMs) * (behind ? .25 : 1); - const q = lamp3(wp, lv, 1, Lamps[provider].activeKind(nowMs), Lamps[provider].link); - q.hide = behind; return q; - }); - lamp3(V(.35 * K.scale, -.15 * K.scale, .5 * K.scale), Lamps.junction.level(nowMs), .85, Lamps.junction.activeKind(nowMs), "ok"); - lamp3(V(TX_PORT.x * K.scale, TX_PORT.y * K.scale, TX_PORT.z * K.scale), Lamps.tx.level(nowMs), .9, Lamps.tx.activeKind(nowMs), "ok"); - - drawSourceLabels(K.idx === 1 ? Math.min(1, K.spread - 1) : 0); - drawPublishLabels(K.idx === 3 ? K.explode : 0); - drawDatasetCards(K.unfold > .4 ? Math.min(1, (K.unfold - .4) * 2.5) : 0, W / 2, H * .46); - drawAuditTrail(K.trail); - 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); - } + 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; })(); @@ -1239,30 +1343,46 @@ const Stage = (() => { 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); - ctx.save(); - ctx.translate(W / 2, H / 2); - ctx.rotate(seedAngle); - ctx.strokeStyle = night ? "rgba(140,190,255,.55)" : "rgba(47,99,214,.5)"; - ctx.lineWidth = 2; - const s = Math.min(W, H) * .16; - ctx.strokeRect(-s, -s, s * 2, s * 2); - ctx.beginPath(); ctx.moveTo(-s, -s); ctx.lineTo(s * .4, -s * .5); ctx.lineTo(s * .4, s * .5); ctx.lineTo(-s, s); ctx.closePath(); ctx.stroke(); - ctx.restore(); - // 四条固定流向的直线(静态,不闪烁不移动) + 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"]; - for (let i = 0; i < 4; i++) { - const y = H * (.2 + i * .18); - ctx.strokeStyle = colors[i]; ctx.globalAlpha = .55; ctx.lineWidth = 1.6; - ctx.beginPath(); ctx.moveTo(6, y); ctx.lineTo(W * .42, H / 2); ctx.stroke(); + 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.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]; diff --git a/xiaobai-datahub/admin/styles.css b/xiaobai-datahub/admin/styles.css index e62ead7..9408aad 100644 --- a/xiaobai-datahub/admin/styles.css +++ b/xiaobai-datahub/admin/styles.css @@ -1,4 +1,5 @@ -/* xiaobai-datahub 数据中枢后台 —— 第七版「轨道机芯」 +/* xiaobai-datahub 数据中枢后台 —— 第八版「星港中枢」 + 整页星空舞台 + 中央自转星体 + 4 颗来源卫星,六幕连续滚动换场(详见 app.js Stage 引擎)。 统一动效 Token:进入/退出用强 ease-out,屏内移动用强 ease-in-out,持续流动用 linear。 禁止 ease-in、禁止 transition:all —— 全文件遵守。 */ :root { @@ -137,14 +138,15 @@ h3 { font-size: 13px; margin: 14px 0 6px; color: var(--muted); } #topbar .spacer { flex: 1; } #topbar .who { font-size: 12px; } -/* ---------- 固定空间舞台(76vh,六幕连续场景) ---------- +/* ---------- 全幅星空舞台(第八版「星港中枢」,calc(100vh - 52px) 高,六幕连续场景) ---------- #track 的六段高幕(每段 130vh)在它身后正常参与文档流把页面撑高, #stageWrap 用 sticky 钉在视口里,滚动时只有 #track 的高度被“划过”, - 舞台本体在这段距离内始终可见、位置不变,直到 #track 撑出的空间耗尽才随之离场。 */ + 舞台本体在这段距离内始终可见、位置不变,直到 #track 撑出的空间耗尽才随之离场。 + 整个舞台是同构的日/夜全幅星空:无卡片边框、无圆角大框、无 overflow 裁切—— + 星系(轨道/卫星/线路/去向端)改由 app.js 的响应式安全盒公式钳制,构造性不出屏。 */ #stageWrap { - flex: none; height: 76vh; min-height: 480px; margin: 0 18px; border-radius: 18px; - overflow: hidden; border: 1px solid var(--stage-edge); - box-shadow: 0 30px 80px -30px rgba(0, 0, 0, .45), inset 0 0 120px rgba(0, 0, 0, .08); + flex: none; height: calc(100vh - var(--bar-h)); min-height: 480px; margin: 0; border-radius: 0; + border: 0; box-shadow: none; position: sticky; top: var(--bar-h); z-index: 2; } #scene { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; } @@ -192,7 +194,7 @@ h3 { font-size: 13px; margin: 14px 0 6px; color: var(--muted); } background: var(--glass); border: 1px solid var(--line); border-radius: 12px; padding: 10px 13px; backdrop-filter: blur(14px); line-height: 1.9; } -#legend .lampico { display: inline-block; width: 14px; height: 5px; border-radius: 99px; vertical-align: middle; margin-right: 6px; } +#legend .lampico { display: inline-block; width: 16px; height: 6px; border-radius: 99px; vertical-align: middle; margin-right: 6px; } #legend .li-link { background: rgba(47, 216, 206, .5); box-shadow: 0 0 6px rgba(47, 216, 206, .6); } #legend .li-act { background: var(--amber); box-shadow: 0 0 8px var(--amber); } @@ -273,7 +275,6 @@ main#page { padding: var(--pad); min-height: calc(100vh - 96px); background: var #detail { width: 320px; } } @media (max-width: 1100px) { - #stageWrap { margin: 0 10px; } #cabin { width: 230px; padding: 13px 14px; } #legend { display: none; } }