diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js index 0f567d4..857a95e 100644 --- a/xiaobai-datahub/admin/app.js +++ b/xiaobai-datahub/admin/app.js @@ -712,412 +712,445 @@ const Nav = { }, }; + /* ========================================================================== - canvas 3D 引擎(动效版)——移植自经白栖知确认的第七版原稿, - 静态数据换成真实拉取结果,随机模拟事件全部替换为真实事件驱动。 + canvas 2D 引擎(动效版)—— 第八版「星港中枢」 + 整页星空,中央星体,四颗数据源卫星沿两层轨道环绕;六幕滚动只切换镜头焦点 + (轨道外扩/任务环/发布分层/数据集展开/审计尾迹的透明度与半径),星系本体 + (星体 + 两层轨道 + 四颗卫星 + 双灯)始终同屏、连续运转,不退回卡片长页。 + 坐标系:世界原点为星系中心;屏幕坐标 = 安全盒中心 CX,CY + 世界坐标 * S。 + S、CX、CY 按 HEL-538 复审公式在 resize() 中计算,保证 1440/1280/1024 三档 + 构造性不裁切。真实数据/事件驱动全部复用上方 A2 灯引擎与 Poller,不臆造流量。 ========================================================================== */ 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(); + + /* ---------------- 安全盒 / 缩放(HEL-538 唯一施工口径) ---------------- */ + let cabinW = 300, sizeTier = 0; + let x0 = 0, x1 = 0, y0 = 0, y1 = 0, CX = 0, CY = 0, S = 1; + const FIT_X = 1.28, FIT_Y = 1.28 * 0.52; + const CHIP_SIZES = [{ w: 120, h: 42 }, { w: 108, h: 40 }, { w: 96, h: 38 }]; + const LAMP_SIZES = [{ w: 16, h: 6 }, { w: 15, h: 6 }, { w: 14, h: 6 }]; + + /* ---------------- 星系几何常量(HEL-536 §3,轨道配对按 HEL-537 已确认样图) ---------------- */ + const STAR_R = 0.28; + const ORBIT_TILT = -12 * Math.PI / 180; + const ORBITS = { inner: { a: .70, b: .70 * .52 }, outer: { a: .94, b: .94 * .52 } }; + const RING_EXPAND_MAX = 0.10; // 幕2 外扩幅度:outer a 最大 .94*1.10=1.034,远低于 1.175 上限 + function polarPt(r, deg) { const a = deg * Math.PI / 180; return { x: r * Math.cos(a), y: r * Math.sin(a) }; } + const TX_WORLD = polarPt(1.25, -20); // 盘后发布去向端 + const AUD_WORLD = polarPt(1.22, 25); // 审计去向端 + + /* 四颗卫星:轨道配对与朝向沿用 HEL-537 已确认样图(tushare/eastmoney 内轨, + tencent/ifind 外轨);相位、角速度、方向沿用 HEL-536 §3 逐源数值。 */ + const SATS = [ + { id: "tushare", label: "Tushare", sub: "官方盘后", orbit: "inner", phase: 200 * Math.PI / 180, speed: .028, dir: -1 }, + { id: "eastmoney", label: "东方财富", sub: "资讯 · 涨停", orbit: "inner", phase: 20 * Math.PI / 180, speed: .022, dir: -1 }, + { id: "tencent", label: "腾讯", sub: "行情快照", orbit: "outer", phase: 140 * Math.PI / 180, speed: .034, dir: 1 }, + { id: "ifind", label: "iFinD", sub: "机构数据", orbit: "outer", phase: 320 * Math.PI / 180, speed: .026, dir: 1 }, + ]; // 顺序与 FLOW_PROVIDERS 对齐,供 A2 灯引擎按下标直接取用 + + const STAR_LANDS = [ // 星体表面数据大陆(装饰性,球心相对坐标,半径相对球半径) + { x: -.35, y: -.28, rad: .22 }, { x: .28, y: -.12, rad: .16 }, { x: -.08, y: .22, rad: .20 }, + { x: .34, y: .30, rad: .14 }, { x: -.44, y: .14, rad: .12 }, { x: .04, y: -.42, rad: .11 }, + ]; 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,"] }, + night: { + bgA: "#05070F", bgB: "#0B1026", starHi: "#F2FAFF", starA: "#C9E7FF", starB: "#7FB2EA", starC: "#3E6FAE", starD: "#16305E", + band: "rgba(160,220,255,.35)", landHi: "rgba(190,235,255,.55)", shell: "rgba(126,200,255,.22)", + halo: "rgba(110,170,255,.22)", edge: "#D8F0FF", orbitLine: "rgba(140,180,255,.22)", + link: "#35E0B2", beam: "#7EC8FF", ink: "#E8F1FF", dim: "#93A5C8", faint: "#5C6B8E", + dockBg: "rgba(10,17,36,.85)", dockLine: "rgba(140,180,255,.30)", amber: "#FFB454", red: "#FF6B6B", actOff: "#3A2C18", + sat: { tushare: "#4CC9F0", eastmoney: "#FFB454", tencent: "#6FA8FF", ifind: "#FF6E7F" }, + }, + day: { + bgA: "#D8E3F5", bgB: "#E9F0FA", starHi: "#FFFFFF", starA: "#EAF6FF", starB: "#A9CCF0", starC: "#5488C4", starD: "#2E5A94", + band: "rgba(70,120,200,.30)", landHi: "rgba(80,140,220,.42)", shell: "rgba(90,140,210,.24)", + halo: "rgba(120,160,220,.28)", edge: "#FFFFFF", orbitLine: "rgba(90,120,180,.40)", + link: "#0E9E7E", beam: "#3E6CA8", ink: "#22304E", dim: "#5B6B8C", faint: "#8B99B8", + dockBg: "rgba(255,255,255,.92)", dockLine: "rgba(34,48,78,.35)", amber: "#B97A0E", red: "#D64F4F", actOff: "#E7DCC6", + sat: { tushare: "#3997B4", eastmoney: "#BF873F", tencent: "#537EBF", ifind: "#BF525F" }, + }, }; C = PAL[document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day"]; } + const isNight = () => C === PAL.night; + const setAdd = () => { ctx.globalCompositeOperation = isNight() ? "lighter" : "source-over"; }; - 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) { + /* ---------------- 基础几何工具 ---------------- */ + function toScreen(wx, wy) { return { x: CX + wx * S, y: CY - wy * S }; } + function quadPt(p0, p1, p2, 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 }; + return { x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x, y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y }; } 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) { + function glowDot(x, y, r, color, alpha, glow) { ctx.save(); setAdd(); - if (C !== PAL.night) alpha = Math.min(1, alpha * 1.5); - 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"; - 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(); + const g = ctx.createRadialGradient(x, y, 0, x, y, r * 3.4); + g.addColorStop(0, color + "55"); g.addColorStop(1, color + "00"); + ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r * 3.4, 0, 7); ctx.fill(); } - ctx.fillStyle = color + (alpha * depth) + ")"; - ctx.beginPath(); ctx.arc(q.x, q.y, rr, 0, 7); ctx.fill(); + ctx.globalAlpha = alpha; ctx.fillStyle = color; + ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill(); ctx.restore(); - return q; } - /* 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; - ctx.save(); - ctx.globalAlpha = .9; - ctx.fillStyle = C === PAL.night ? "rgba(6,12,24,.78)" : "rgba(255,255,255,.92)"; - 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(); - 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; - 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; } - 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 }, - ]; - function smooth(t) { return t * t * (3 - 2 * t); } + const smooth = (t) => 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; } + + /* ---------------- 六幕内容参数(镜头不再自由环绕,只切换焦点透明度/半径, + 星系本体——星体/双轨/四卫星/双灯——全程同屏,满足"连续换场不退回卡片长页") ---------------- */ + const CH = [ + { ring: 0, task: 0, explode: 0, unfold: 0, trail: 0 }, // 0 总览 + { ring: 1, task: 0, explode: 0, unfold: 0, trail: 0 }, // 1 数据源:轨道外扩,卫星芯片展开 + { ring: .3, task: 1, explode: 0, unfold: 0, trail: 0 }, // 2 调度任务:外轨兼作时间轮 + { ring: 0, task: 0, explode: 1, unfold: 0, trail: 0 }, // 3 盘后发布:星体分层 + { ring: 0, task: 0, explode: .15, unfold: 1, trail: 0 }, // 4 数据集:六张数据面片展开 + { ring: 0, task: 0, explode: 0, unfold: 0, trail: 1 }, // 5 审计:时间尾迹 + ]; 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) }; } - /* ---------- 几何:核心 / 端口 / 流道 / 环 ---------- */ - 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; + /* ---------------- 星空背景(两层预渲染星点 + 双团星云,克制层次) ---------------- */ + let starLayer = null, starLayerKey = ""; + function buildStarLayer() { + const key = `${Math.round(W)}x${Math.round(H)}`; + if (starLayerKey === key && starLayer) return; + starLayerKey = key; + const off = document.createElement("canvas"); + off.width = Math.max(1, Math.round(W)); off.height = Math.max(1, Math.round(H)); + const octx = off.getContext("2d"); + let seed = 20260913; + const rnd = () => { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; + const layers = [[70, .55, "rgba(200,215,255,"], [35, .8, "rgba(210,225,255,"], [15, 1.1, "rgba(225,238,255,"]]; + layers.forEach(([n, r, col]) => { + for (let i = 0; i < n; i++) { + const x = rnd() * off.width, y = rnd() * off.height, a = .25 + rnd() * .5; + octx.fillStyle = col + a.toFixed(2) + ")"; octx.beginPath(); octx.arc(x, y, r, 0, 7); octx.fill(); + } + }); + starLayer = off; } - 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; + function drawBackground(vt) { + const bg = ctx.createRadialGradient(W * .5, CY, 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 n1 = ctx.createRadialGradient(W * .18, H * .22, 10, W * .18, H * .22, Math.max(W, H) * .32); + n1.addColorStop(0, C === PAL.night ? "rgba(43,36,92,.16)" : "rgba(255,255,255,.4)"); n1.addColorStop(1, "rgba(0,0,0,0)"); + ctx.fillStyle = n1; ctx.fillRect(0, 0, W, H); + const n2 = ctx.createRadialGradient(W * .82, H * .78, 10, W * .82, H * .78, Math.max(W, H) * .30); + n2.addColorStop(0, C === PAL.night ? "rgba(27,36,80,.16)" : "rgba(196,186,240,.24)"); n2.addColorStop(1, "rgba(0,0,0,0)"); + ctx.fillStyle = n2; ctx.fillRect(0, 0, W, H); + ctx.restore(); + if (starLayer) { + ctx.save(); ctx.globalAlpha = .8 + Math.sin(vt * .06) * .06; ctx.drawImage(starLayer, 0, 0); ctx.restore(); + } } - 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} 已连接`; + /* ---------------- 轨道 ---------------- */ + function drawOrbit(a, b, alpha) { + ctx.save(); ctx.strokeStyle = C.orbitLine; ctx.globalAlpha = alpha; ctx.lineWidth = 1; + ctx.beginPath(); + const ct = Math.cos(ORBIT_TILT), st = Math.sin(ORBIT_TILT); + for (let i = 0; i <= 72; i++) { + const t = (i / 72) * Math.PI * 2; + const x0 = a * Math.cos(t), y0 = b * Math.sin(t); + const s = toScreen(x0 * ct - y0 * st, x0 * st + y0 * ct); + if (i === 0) ctx.moveTo(s.x, s.y); else ctx.lineTo(s.x, s.y); } - 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 ""; + ctx.stroke(); ctx.restore(); } - 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); - }); - 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(); - } - 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.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.restore(); + // 装饰性文字标签是否与某颗卫星芯片矩形重叠——重叠则让位(跳过该文字),保证主体(卫星)永不被次要信息遮挡 + function nearChip(x, y, chipRects) { + if (!chipRects) return false; + return chipRects.some((r) => Math.abs(x - r.x) < r.w / 2 && Math.abs(y - r.y) < r.h / 2); } - 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); - } - function drawTasksRing(radius, tilt) { + function drawTaskRing(alpha, expand, chipRects) { const jobsData = state.data.jobs; if (!jobsData) return; const jobs = jobsData.jobs || [], runs = jobsData.runs || []; + if (!jobs.length) return; const latestByJob = new Map(); for (const r of runs) if (!latestByJob.has(r.job_id)) latestByJob.set(r.job_id, r); + const a = ORBITS.outer.a * expand, b = ORBITS.outer.b * expand; + const ct = Math.cos(ORBIT_TILT), st = Math.sin(ORBIT_TILT); + ctx.save(); ctx.globalAlpha = alpha; 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 th = (i / jobs.length) * Math.PI * 2 + .3; + const x0 = a * Math.cos(th), y0 = b * Math.sin(th); + const q = toScreen(x0 * ct - y0 * st, x0 * st + y0 * ct); 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 st2 = run ? run.state : "never"; + const color = st2 === "failed" ? C.red : st2 === "ok" ? C.link : st2 === "running" ? C.amber : C.faint; + glowDot(q.x, q.y, st2 === "never" ? 3 : 4.4, color, st2 === "never" ? .4 : .9, st2 !== "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(); + if (isFresh) glowDot(q.x, q.y, 7, C.amber, .5 * Lamps.junction.level(performance.now()), true); + if (!nearChip(q.x, q.y - 12, chipRects)) { + ctx.fillStyle = st2 === "failed" ? C.red : C.dim; ctx.globalAlpha = alpha * .8; + ctx.font = "10px \"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.globalAlpha = alpha; + } + }); + ctx.restore(); + } + + /* ---------------- 星体(球体 + 自转经纬带/数据大陆 + 分层展开) ---------------- */ + function drawStar(vt, K) { + const c0 = toScreen(0, 0), r = STAR_R * S; + ctx.save(); setAdd(); + const halo = ctx.createRadialGradient(c0.x, c0.y, r * .5, c0.x, c0.y, r * 1.7); + halo.addColorStop(0, "rgba(0,0,0,0)"); halo.addColorStop(.55, C.halo); halo.addColorStop(1, "rgba(0,0,0,0)"); + ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(c0.x, c0.y, r * 1.7, 0, 7); ctx.fill(); + ctx.restore(); + + // 分层展开(幕4 盘后发布):校验 / 暂存 / 发布 三层扁环 + if (K.explode > .04) { + const names = ["校验", "暂存", "发布"]; + for (let i = 0; i < 3; i++) { + const oy = (i - 1) * (.42 + K.explode * .55) * STAR_R; + const s0 = toScreen(-.62 * STAR_R, oy), s1 = toScreen(.62 * STAR_R, oy); + ctx.save(); ctx.globalAlpha = .18 + K.explode * .22; ctx.strokeStyle = C.link; ctx.lineWidth = Math.max(1.4, r * .05); + ctx.beginPath(); ctx.ellipse((s0.x + s1.x) / 2, s0.y, Math.abs(s1.x - s0.x) / 2, r * .09, 0, 0, 7); ctx.stroke(); + if (K.explode > .5) { + ctx.globalAlpha = .85; ctx.fillStyle = C.ink; ctx.font = "600 11px \"Noto Sans SC\""; ctx.textAlign = "left"; + ctx.fillText(names[i], s1.x + 8, s0.y + 4); + } + ctx.restore(); + } + } + + // 球体本体(裁剪出圆形后画渐变 + 自转经纬带/数据大陆 + 暗面) + ctx.save(); ctx.beginPath(); ctx.arc(c0.x, c0.y, r, 0, 7); ctx.closePath(); ctx.clip(); + const grad = ctx.createRadialGradient(c0.x - r * .22, c0.y - r * .24, r * .05, c0.x, c0.y, r * 1.05); + grad.addColorStop(0, C.starHi); grad.addColorStop(.30, C.starA); grad.addColorStop(.60, C.starB); + grad.addColorStop(.85, C.starC); grad.addColorStop(1, C.starD); + ctx.fillStyle = grad; ctx.fillRect(c0.x - r, c0.y - r, r * 2, r * 2); + + ctx.save(); ctx.translate(c0.x, c0.y); ctx.rotate((vt * .05) % (Math.PI * 2)); + ctx.strokeStyle = C.band; ctx.lineWidth = Math.max(1, r * .018); + for (let i = 0; i < 9; i++) { + const bx = -r + (i + .5) * (2 * r / 9); + const edge = Math.abs(bx) / r; + ctx.globalAlpha = .5 - edge * .32; + ctx.beginPath(); ctx.moveTo(bx, -r * .95); ctx.lineTo(bx, r * .95); ctx.stroke(); + } + ctx.globalAlpha = 1; + STAR_LANDS.forEach((land) => { + const lx = land.x * r, ly = land.y * r, lr = land.rad * r; + const g2 = ctx.createRadialGradient(lx, ly, 0, lx, ly, lr); + g2.addColorStop(0, C.landHi); g2.addColorStop(1, "rgba(0,0,0,0)"); + ctx.globalAlpha = .55 + K.explode * .2 + K.unfold * .15; ctx.fillStyle = g2; + ctx.beginPath(); ctx.ellipse(lx, ly, lr, lr * .62, 0, 0, 7); ctx.fill(); + }); + ctx.globalAlpha = 1; ctx.restore(); + + const shade = ctx.createRadialGradient(c0.x - r * .22, c0.y - r * .24, r * .3, c0.x, c0.y, r * 1.05); + shade.addColorStop(0, "rgba(0,0,0,0)"); + shade.addColorStop(.6, isNight() ? "rgba(10,20,50,.18)" : "rgba(60,100,160,.10)"); + shade.addColorStop(1, isNight() ? "rgba(2,6,18,.55)" : "rgba(25,55,105,.35)"); + ctx.fillStyle = shade; ctx.fillRect(c0.x - r, c0.y - r, r * 2, r * 2); + ctx.restore(); + + // 外壳环(自转弧段,暗示体积) + ctx.save(); ctx.strokeStyle = C.shell; ctx.lineWidth = Math.max(1, r * .02); ctx.globalAlpha = .85; + const spin2 = (vt * -.014) % (Math.PI * 2); + for (let k = 0; k < 4; k++) { + const a0 = spin2 + k * Math.PI / 2, a1 = a0 + Math.PI * .32; + ctx.beginPath(); ctx.arc(c0.x, c0.y, r * 1.06, a0, a1); ctx.stroke(); + } + ctx.restore(); + ctx.save(); ctx.strokeStyle = C.edge; ctx.globalAlpha = .5; ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(c0.x, c0.y, r, 0, 7); ctx.stroke(); ctx.restore(); + return { x: c0.x, y: c0.y, r }; + } + + /* ---------------- 数据集面片(幕5 数据集:六张卡片沿固定角度展开,半径 ≤0.62) ---------------- */ + function drawDatasetCards(alpha, core, chipRects) { + if (alpha < .05) return; + const pubs = (state.data.datasets && state.data.datasets.publications) || []; + ctx.save(); ctx.globalAlpha = alpha; + for (let i = 0; i < 6; i++) { + const ang = (-90 + i * 60) * Math.PI / 180, cosA = Math.cos(ang), sinA = Math.sin(ang); + const rr = .40 + alpha * .22; // ≤0.62 世界半径,安全落在包络内;此处全程用屏幕坐标推导,避免坐标系混用 + const from = { x: core.x + cosA * core.r, y: core.y + sinA * core.r }; + const cardCenter = { x: core.x + cosA * rr * S, y: core.y + sinA * rr * S * .52 }; + const cw = 118, ch = 46; + const cx2 = clamp(cardCenter.x, x0 + cw / 2, x1 - cw / 2), cy2 = clamp(cardCenter.y, y0 + ch / 2, y1 - ch / 2); + if (nearChip(cx2, cy2, chipRects)) continue; // 与卫星芯片重叠——整张卡片让位,宁缺勿遮挡主体 + ctx.strokeStyle = C.orbitLine; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(cardCenter.x, cardCenter.y); ctx.stroke(); + const ds = pubs[i]; + ctx.fillStyle = isNight() ? "rgba(13,20,42,.72)" : "rgba(255,255,255,.82)"; + ctx.strokeStyle = C.orbitLine; roundRect(cx2 - cw / 2, cy2 - ch / 2, cw, ch, 9); ctx.fill(); ctx.stroke(); + ctx.fillStyle = C.ink; ctx.font = "600 12px \"Noto Sans SC\""; ctx.textAlign = "left"; + ctx.fillText(ds ? esc(ds.dataset) : "—", cx2 - cw / 2 + 10, cy2 - 6); + ctx.fillStyle = C.dim; ctx.font = "10px \"DejaVu Sans Mono\""; + ctx.fillText(ds ? `${ds.state} · ${ds.active_batch || ""}`.slice(0, 18) : "暂无数据", cx2 - cw / 2 + 10, cy2 + 12); + } + ctx.restore(); + } + + /* ---------------- 审计尾迹(幕6 审计:弧形时间轨,半径 ≤1.02) ---------------- */ + function drawAuditTrail(alpha, chipRects) { + const items = ((state.data.audit && state.data.audit.items) || []).slice(0, 5); + if (alpha < .05 || !items.length) return; + ctx.save(); ctx.globalAlpha = alpha; + items.forEach((a, i) => { + const t = i / Math.max(1, items.length - 1); + const ang = (250 + t * 70) * Math.PI / 180, rr = .78 + t * .22; // 底部弧段,远离四颗卫星常驻扇区;≤1.0 + const q = toScreen(Math.cos(ang) * rr, Math.sin(ang) * rr * .46); + const col = String(a.action).includes("rollback") ? C.red : C.link; + ctx.globalAlpha = alpha * (1 - t * .5); + glowDot(q.x, q.y, 3.6, col, .9, true); + if (!nearChip(q.x + 40, q.y, chipRects)) { + ctx.fillStyle = C.ink; ctx.font = "10.5px \"Noto Sans SC\""; ctx.textAlign = "left"; + ctx.fillText(`${a.action} · ${a.target || ""}`.slice(0, 20), q.x + 10, q.y + 3); + ctx.fillStyle = C.dim; ctx.font = "9px \"DejaVu Sans Mono\""; + ctx.fillText(timeShort(a.created_at), q.x + 10, q.y + 14); + } + }); + ctx.restore(); + } + + /* ---------------- 卫星几何:位置 / 径向锚定 14px / 安全盒硬钳制 ---------------- */ + function satWorldPos(sat, vt, expand) { + const th = sat.phase + sat.dir * sat.speed * vt; + const orb = ORBITS[sat.orbit]; + const a = orb.a * expand, b = orb.b * expand; + const x0 = a * Math.cos(th), y0 = -b * Math.sin(th); + const ct = Math.cos(ORBIT_TILT), st = Math.sin(ORBIT_TILT); + return { x: x0 * ct - y0 * st, y: x0 * st + y0 * ct, behind: Math.sin(th) > 0 }; + } + function computeSatGeo(vt, expand, core) { + return SATS.map((sat) => { + const pos = satWorldPos(sat, vt, expand); + const raw = toScreen(pos.x, pos.y); + let dx = raw.x - CX, dy = raw.y - CY; const d = Math.hypot(dx, dy) || 1; + const ux = dx / d, uy = dy / d; + const anchor = { x: raw.x + ux * 14, y: raw.y + uy * 14 }; // 径向外移 14px 锚定 + const chip = CHIP_SIZES[sizeTier]; + const chipPos = { // 安全盒硬钳制 + x: clamp(anchor.x, x0 + chip.w / 2, x1 - chip.w / 2), + y: clamp(anchor.y, y0 + chip.h / 2, y1 - chip.h / 2), + }; + const connFrom = { x: chipPos.x - ux * (chip.w / 2 + 6), y: chipPos.y - uy * (chip.w / 2 + 6) }; + const connTo = { x: core.x + ux * core.r, y: core.y + uy * core.r }; + const mid = { x: (connFrom.x + connTo.x) / 2, y: (connFrom.y + connTo.y) / 2 }; + const px = -uy, py = ux; + const side = (mid.x - CX) * px + (mid.y - CY) * py >= 0 ? 1 : -1; + const connCtrl = { x: mid.x + px * side * 22, y: mid.y + py * side * 22 }; + return { sat, pos, raw, ux, uy, chipPos, connFrom, connCtrl, connTo, behind: pos.behind }; }); } - 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 drawSatConnector(g, alphaMul) { + ctx.save(); + ctx.strokeStyle = C.sat[g.sat.id]; ctx.globalAlpha = .55 * alphaMul; ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(g.connFrom.x, g.connFrom.y); + ctx.quadraticCurveTo(g.connCtrl.x, g.connCtrl.y, g.connTo.x, g.connTo.y); ctx.stroke(); + ctx.restore(); + for (let k = 0; k < 3; k++) { + const u = ((performance.now() - T0) * .00012 + k / 3) % 1; + const pt = quadPt(g.connFrom, g.connCtrl, g.connTo, u); + glowDot(pt.x, pt.y, 2.4, C.sat[g.sat.id], .85 * alphaMul, false); + } } + function drawSatChip(g, alphaMul) { + const chip = CHIP_SIZES[sizeTier]; + const x = g.chipPos.x - chip.w / 2, y = g.chipPos.y - chip.h / 2; + ctx.save(); ctx.globalAlpha = alphaMul; + ctx.fillStyle = isNight() ? "rgba(10,17,36,.82)" : "rgba(255,255,255,.86)"; + ctx.strokeStyle = C.orbitLine; ctx.lineWidth = 1; + roundRect(x, y, chip.w, chip.h, 10); ctx.fill(); ctx.stroke(); + ctx.fillStyle = C.sat[g.sat.id]; roundRect(x, y + 5, 3, chip.h - 10, 2); ctx.fill(); + ctx.fillStyle = C.ink; ctx.font = "600 12px \"Noto Sans SC\""; ctx.textAlign = "left"; + ctx.fillText(g.sat.label, x + 12, y + chip.h * .42); + ctx.fillStyle = C.dim; ctx.font = "10px \"Noto Sans SC\""; + ctx.fillText(g.sat.sub, x + 12, y + chip.h * .74); + ctx.restore(); + } + + /* ---------------- A2 双灯(L8:永远最顶层绘制,永不压暗,尺寸下限按档位) ---------------- + 无论上游几何如何推导锚点,这里再做一次安全盒硬钳制,确保双灯永不出屏。 */ + function drawLampDock(rawX, rawY, linkState, actLevel, actKind) { + const lp = LAMP_SIZES[sizeTier]; + const padX = 4, padY = 3, gap = 3; + const boxW = lp.w + padX * 2, boxH = lp.h * 2 + gap + padY * 2; + const x = clamp(rawX, x0 + boxW / 2, x1 - boxW / 2); + const y = clamp(rawY, y0 + boxH / 2, y1 - boxH / 2); + ctx.save(); + ctx.fillStyle = C.dockBg; ctx.strokeStyle = C.dockLine; ctx.lineWidth = 1; + roundRect(x - boxW / 2, y - boxH / 2, boxW, boxH, Math.min(6, lp.h)); ctx.fill(); ctx.stroke(); + const linkColor = linkState === "ok" ? C.link : linkState === "error" ? C.red : C.faint; + const linkAlpha = linkState === "ok" ? .6 : linkState === "error" ? .6 : .3; + ctx.globalAlpha = linkAlpha; ctx.fillStyle = linkColor; ctx.shadowColor = linkColor; + ctx.shadowBlur = linkState === "unconfigured" ? 0 : 6; + roundRect(x - lp.w / 2, y - boxH / 2 + padY, lp.w, lp.h, lp.h / 2); ctx.fill(); + ctx.shadowBlur = 0; ctx.globalAlpha = 1; + const actColor = actKind === "error" || actKind === "rollback" ? C.red : C.amber; + if (actLevel > 0) { + ctx.globalAlpha = .3 + .7 * actLevel; ctx.fillStyle = actColor; ctx.shadowColor = actColor; ctx.shadowBlur = 10 * actLevel; + } else { ctx.globalAlpha = .5; ctx.fillStyle = C.actOff; } + roundRect(x - lp.w / 2, y + boxH / 2 - padY - lp.h, lp.w, lp.h, lp.h / 2); ctx.fill(); + ctx.restore(); + } + + /* ---------------- 去向端(发布 / 审计):固定连线 + 常流光点 + 右对齐内侧文字 ---------------- */ + function goConn(core, worldPt) { + const d = Math.hypot(worldPt.x, worldPt.y) || 1, ux = worldPt.x / d, uy = worldPt.y / d; + const from = { x: core.x + ux * core.r, y: core.y - uy * core.r }; + const to = toScreen(worldPt.x, worldPt.y); + const mid = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 - Math.abs(to.x - from.x) * .06 }; + return { from, ctrl: mid, to }; + } + function drawGoLine(conn, color, speed) { + ctx.save(); ctx.strokeStyle = color; ctx.globalAlpha = .6; ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(conn.from.x, conn.from.y); + ctx.quadraticCurveTo(conn.ctrl.x, conn.ctrl.y, conn.to.x, conn.to.y); ctx.stroke(); + ctx.restore(); + for (let k = 0; k < 2; k++) { + const u = ((performance.now() - T0) * speed + k / 2) % 1; + const pt = quadPt(conn.from, conn.ctrl, conn.to, u); + glowDot(pt.x, pt.y, 2.2, color, .85, false); + } + } + function drawGoLabel(pt, text) { + ctx.save(); ctx.fillStyle = C.ink; ctx.globalAlpha = .85; ctx.font = "600 11px \"Noto Sans SC\""; ctx.textAlign = "right"; + ctx.fillText(text, pt.x - 14, pt.y - 14); ctx.textAlign = "left"; ctx.restore(); + } + + /* ---------------- 事件脉冲(来源 ACT 起闪 → 星体接点 → TX/审计回应,因果接力) ---------------- */ const TRAVEL_IN = 900, TRAVEL_OUT = 750; - function drawPackets(chans) { + function drawPackets(satGeo, txConn, audConn) { 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,"; + const col = pk.rb ? C.red : C.amber; 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 g = satGeo[pk.src]; if (!g) continue; + const pt = quadPt(g.connFrom, g.connCtrl, g.connTo, clamp(dt / TRAVEL_IN, 0, 1)); + glowDot(pt.x, pt.y, 4, col, .9, true); } 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 conn = pk.rb ? audConn : txConn; + const pt = quadPt(conn.from, conn.ctrl, conn.to, clamp((dt - TRAVEL_IN) / TRAVEL_OUT, 0, 1)); + glowDot(pt.x, pt.y, 4, pk.rb ? C.red : C.beam, .85, true); } } } - function drawAuditTrail(amp) { - 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); - }); - 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"); }, - }; - - function scheduleNav() { - document.querySelectorAll("#rail .stop").forEach((el) => { - el.onclick = () => Nav.go(+el.dataset.i); - }); - } - function buildCabinShell() { - $("rail").innerHTML = SCENES.map((s, i) => `