wechat_ai/demos/node-sphere-lab.html

605 lines
33 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Node Sphere Lab · 节点球实验室</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
:root {
color-scheme: dark;
--bg: #070b14;
--panel: rgba(13, 20, 35, .82);
--line: rgba(148, 163, 184, .16);
--muted: #8190a8;
--text: #edf4ff;
--idle: #738197;
--running: #39e6a5;
--error: #ff5f74;
--accent: #7c6cff;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; overflow: hidden; background: #dce4ee; font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
button { font: inherit; }
.browser-shell { width: min(1320px, calc(100vw - 32px)); height: min(860px, calc(100vh - 32px)); }
.app-grid { display: grid; grid-template-columns: 272px minmax(0, 1fr); }
.glass { background: var(--panel); border: 1px solid var(--line); backdrop-filter: blur(20px); }
.canvas-wrap { min-height: 0; background: linear-gradient(135deg, rgba(14,22,40,.78), rgba(6,10,19,.94)); }
#scene { display: block; width: 100%; height: 100%; cursor: crosshair; }
.mode-btn, .action-btn, .status-btn { transition: .2s ease; }
.mode-btn:hover, .action-btn:hover, .status-btn:hover { transform: translateY(-1px); }
.mode-btn.active { color: #fff; border-color: rgba(124,108,255,.52); background: linear-gradient(120deg, rgba(124,108,255,.24), rgba(65,215,255,.08)); box-shadow: inset 3px 0 0 #8879ff; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 10px currentColor; }
.metric { border-top: 1px solid var(--line); }
.node-controls-disabled { opacity: .28; pointer-events: none; filter: saturate(.35); }
.grain { background-image: radial-gradient(rgba(255,255,255,.1) .55px, transparent .55px); background-size: 6px 6px; opacity: .09; }
.tooltip { pointer-events: none; transform: translate(-50%, calc(-100% - 14px)); }
@media (max-width: 860px) {
body { overflow: auto; }
.browser-shell { width: 100%; height: 100vh; border-radius: 0 !important; }
.app-grid { grid-template-columns: 1fr; }
aside { display: none !important; }
}
</style>
</head>
<body class="grid min-h-screen place-items-center p-4 text-slate-900">
<main class="browser-shell flex flex-col overflow-hidden rounded-[22px] border border-slate-300 bg-white shadow-2xl">
<div class="shrink-0 border-b border-slate-300 bg-slate-100">
<div class="flex h-9 items-end gap-1 px-3 pt-2">
<div class="flex h-7 w-56 items-center gap-2 rounded-t-xl bg-white px-3 text-xs font-medium text-slate-700 shadow-sm">
<span class="grid h-4 w-4 place-items-center rounded bg-violet-600 text-white"><i data-lucide="orbit" class="h-3 w-3"></i></span>
Node Sphere Lab
</div>
<button class="grid h-7 w-8 place-items-center rounded-t-lg text-slate-500 hover:bg-slate-200" aria-label="新建标签"><i data-lucide="plus" class="h-4 w-4"></i></button>
</div>
<div class="flex h-11 items-center gap-2 px-3">
<button class="text-slate-400" aria-label="后退"><i data-lucide="arrow-left" class="h-4 w-4"></i></button>
<button class="text-slate-400" aria-label="前进"><i data-lucide="arrow-right" class="h-4 w-4"></i></button>
<button class="text-slate-500" aria-label="刷新"><i data-lucide="rotate-cw" class="h-4 w-4"></i></button>
<div class="flex flex-1 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-1.5 text-xs text-slate-500 shadow-inner">
<i data-lucide="lock-keyhole" class="h-3.5 w-3.5"></i>
localhost/demos/node-sphere-lab.html
</div>
<i data-lucide="more-vertical" class="h-4 w-4 text-slate-500"></i>
</div>
</div>
<div class="app-grid min-h-0 flex-1 overflow-hidden bg-[var(--bg)] text-[var(--text)]">
<aside class="glass z-10 flex min-h-0 flex-col border-y-0 border-l-0 p-4">
<div class="mb-5 flex items-center gap-3 px-1">
<div class="grid h-10 w-10 place-items-center rounded-xl bg-violet-500/15 text-violet-300 ring-1 ring-violet-400/25">
<i data-lucide="atom" class="h-5 w-5"></i>
</div>
<div>
<h1 class="m-0 text-sm font-semibold tracking-wide">节点球实验室</h1>
<p class="m-0 mt-1 text-[11px] text-[var(--muted)]">NODE SPHERE LAB / 05</p>
</div>
</div>
<p class="mb-2 px-1 text-[10px] font-semibold tracking-[.2em] text-[var(--muted)]">视觉方案</p>
<nav class="grid gap-1.5" aria-label="节点球方案">
<button class="mode-btn active flex items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left text-sm text-slate-300" data-mode="orbit">
<i data-lucide="orbit" class="h-4 w-4 text-violet-300"></i><span><b class="block font-medium">轨道星环</b><small class="mt-0.5 block text-[10px] text-[var(--muted)]">多层椭圆轨道</small></span>
</button>
<button class="mode-btn flex items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left text-sm text-slate-300" data-mode="core">
<i data-lucide="radar" class="h-4 w-4 text-cyan-300"></i><span><b class="block font-medium">量子核心</b><small class="mt-0.5 block text-[10px] text-[var(--muted)]">脉冲能量核心</small></span>
</button>
<button class="mode-btn flex items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left text-sm text-slate-300" data-mode="mesh">
<i data-lucide="share-2" class="h-4 w-4 text-emerald-300"></i><span><b class="block font-medium">星图矩阵</b><small class="mt-0.5 block text-[10px] text-[var(--muted)]">动态拓扑连线</small></span>
</button>
<button class="mode-btn flex items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left text-sm text-slate-300" data-mode="helix">
<i data-lucide="dna" class="h-4 w-4 text-pink-300"></i><span><b class="block font-medium">双螺旋</b><small class="mt-0.5 block text-[10px] text-[var(--muted)]">纵深粒子流</small></span>
</button>
<button class="mode-btn flex items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left text-sm text-slate-300" data-mode="scatter">
<i data-lucide="circle-dashed" class="h-4 w-4 text-sky-300"></i><span><b class="block font-medium">粒子轨道球</b><small class="mt-0.5 block text-[10px] text-[var(--muted)]">无节点散射球体</small></span>
</button>
</nav>
<div class="mt-auto rounded-2xl border border-white/10 bg-white/[.035] p-3.5 transition" data-node-controls>
<div class="mb-3 flex items-center justify-between">
<span class="text-xs text-[var(--muted)]">节点数量</span>
<strong id="nodeCountSide" class="font-mono text-xl font-medium">12</strong>
</div>
<div class="grid grid-cols-2 gap-2">
<button id="removeNode" class="action-btn flex h-9 items-center justify-center gap-1.5 rounded-lg border border-white/10 bg-white/5 text-xs text-slate-300 hover:bg-white/10"><i data-lucide="minus" class="h-3.5 w-3.5"></i>减少</button>
<button id="addNode" class="action-btn flex h-9 items-center justify-center gap-1.5 rounded-lg bg-violet-500 text-xs font-medium text-white hover:bg-violet-400"><i data-lucide="plus" class="h-3.5 w-3.5"></i>增加</button>
</div>
</div>
</aside>
<section class="relative flex min-h-0 min-w-0 flex-col overflow-hidden">
<div class="grain pointer-events-none absolute inset-0 z-0"></div>
<header class="glass z-10 flex h-[74px] shrink-0 items-center justify-between border-x-0 border-t-0 px-5">
<div>
<div class="flex items-center gap-2">
<h2 id="modeTitle" class="m-0 text-lg font-semibold">轨道星环</h2>
<span class="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-medium text-emerald-300">LIVE</span>
</div>
<p id="modeDesc" class="m-0 mt-1 text-xs text-[var(--muted)]">节点围绕核心多轨道运行,粒子沿轨迹持续旋转</p>
</div>
<div class="flex items-center gap-2">
<button id="shuffleStatus" data-node-controls class="action-btn flex h-9 items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 text-xs text-slate-300 transition hover:bg-white/10"><i data-lucide="shuffle" class="h-3.5 w-3.5"></i>随机状态</button>
<button id="toggleMotion" class="action-btn grid h-9 w-9 place-items-center rounded-lg border border-white/10 bg-white/5 text-slate-300 hover:bg-white/10" aria-label="暂停动画"><i data-lucide="pause" class="h-4 w-4"></i></button>
</div>
</header>
<div class="canvas-wrap relative min-h-0 flex-1 overflow-hidden">
<canvas id="scene" aria-label="动态节点球画布"></canvas>
<div class="pointer-events-none absolute left-5 top-5 flex gap-2">
<span class="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 font-mono text-[10px] text-slate-400 backdrop-blur"><span class="mr-1.5 text-emerald-300"></span>FPS <b id="fps" class="text-slate-200">60</b></span>
<span class="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 font-mono text-[10px] text-slate-400 backdrop-blur">PARTICLES <b id="particleCount" class="text-slate-200">96</b></span>
</div>
<div id="tooltip" class="tooltip absolute z-20 hidden min-w-28 rounded-xl border border-white/10 bg-slate-950/90 px-3 py-2 text-center shadow-xl backdrop-blur">
<strong id="tooltipName" class="block text-xs font-medium">NODE-01</strong>
<span id="tooltipStatus" class="mt-1 block text-[10px] text-emerald-300">运行中</span>
</div>
<div data-node-controls class="absolute bottom-5 left-1/2 flex -translate-x-1/2 gap-2 rounded-2xl border border-white/10 bg-slate-950/70 p-1.5 shadow-xl backdrop-blur transition">
<button class="status-btn flex items-center gap-2 rounded-xl px-3 py-2 text-[11px] text-slate-300 hover:bg-white/5" data-set-status="idle"><span class="status-dot text-[var(--idle)]" style="background:currentColor"></span>全部未启动</button>
<button class="status-btn flex items-center gap-2 rounded-xl px-3 py-2 text-[11px] text-slate-300 hover:bg-white/5" data-set-status="running"><span class="status-dot text-[var(--running)]" style="background:currentColor"></span>全部运行</button>
<button class="status-btn flex items-center gap-2 rounded-xl px-3 py-2 text-[11px] text-slate-300 hover:bg-white/5" data-set-status="error"><span class="status-dot text-[var(--error)]" style="background:currentColor"></span>模拟异常</button>
</div>
</div>
<footer data-node-controls class="glass z-10 grid h-[76px] shrink-0 grid-cols-4 border-x-0 border-b-0 transition">
<div class="flex items-center gap-3 px-5">
<i data-lucide="boxes" class="h-4 w-4 text-violet-300"></i><div><small class="block text-[10px] text-[var(--muted)]">总节点</small><b id="totalMetric" class="font-mono text-sm">12</b></div>
</div>
<div class="metric flex items-center gap-3 border-l border-t-0 px-5">
<span class="status-dot text-[var(--running)]" style="background:currentColor"></span><div><small class="block text-[10px] text-[var(--muted)]">运行中</small><b id="runningMetric" class="font-mono text-sm">8</b></div>
</div>
<div class="metric flex items-center gap-3 border-l border-t-0 px-5">
<span class="status-dot text-[var(--idle)]" style="background:currentColor"></span><div><small class="block text-[10px] text-[var(--muted)]">未启动</small><b id="idleMetric" class="font-mono text-sm">2</b></div>
</div>
<div class="metric flex items-center gap-3 border-l border-t-0 px-5">
<span class="status-dot text-[var(--error)]" style="background:currentColor"></span><div><small class="block text-[10px] text-[var(--muted)]">异常错误</small><b id="errorMetric" class="font-mono text-sm">2</b></div>
</div>
</footer>
</section>
</div>
</main>
<script>
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const stateColors = { idle: '#738197', running: '#39e6a5', error: '#ff5f74' };
const stateLabels = { idle: '未启动', running: '运行中', error: '异常错误' };
const modes = {
orbit: { title: '轨道星环', desc: '节点围绕核心多轨道运行,粒子沿轨迹持续旋转' },
core: { title: '量子核心', desc: '节点形成能量外壳,核心脉冲驱动环形粒子浪涌' },
mesh: { title: '星图矩阵', desc: '节点构成动态拓扑,相邻服务之间建立实时连接' },
helix: { title: '双螺旋', desc: '节点沿双螺旋排列,粒子在纵深通道中往复流动' },
scatter: { title: '粒子轨道球', desc: '无节点球体由旋转粒子构成,并持续向外释放散射能量' }
};
let nodes = [];
let mode = 'orbit';
let running = true;
let time = 0;
let lastFrame = performance.now();
let frameSamples = [];
let hovered = -1;
let escapeParticles = [];
let sphereEscapes = [];
let dpr = Math.min(window.devicePixelRatio || 1, 2);
function seededStatus(i) {
return i % 7 === 5 ? 'error' : i % 5 === 3 ? 'idle' : 'running';
}
function addNode() {
if (nodes.length >= 24) return;
const i = nodes.length;
nodes.push({ id: i + 1, status: seededStatus(i), x: 0, y: 0, r: 7 });
updateMetrics();
}
function removeNode() {
if (nodes.length <= 3) return;
nodes.pop();
hovered = -1;
updateMetrics();
}
for (let i = 0; i < 12; i++) addNode();
function resize() {
const rect = canvas.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
new ResizeObserver(resize).observe(canvas);
function updateMetrics() {
const counts = { idle: 0, running: 0, error: 0 };
nodes.forEach(n => counts[n.status]++);
document.getElementById('nodeCountSide').textContent = nodes.length;
document.getElementById('totalMetric').textContent = nodes.length;
document.getElementById('runningMetric').textContent = counts.running;
document.getElementById('idleMetric').textContent = counts.idle;
document.getElementById('errorMetric').textContent = counts.error;
document.getElementById('particleCount').textContent = nodes.length * 8;
}
function glowCircle(x, y, radius, color, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
const g = ctx.createRadialGradient(x - radius * .28, y - radius * .32, 0, x, y, radius * 1.7);
g.addColorStop(0, '#ffffff');
g.addColorStop(.12, color);
g.addColorStop(.55, color + '88');
g.addColorStop(1, color + '00');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, radius * 1.7, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawNode(node, x, y, scale = 1, label = true) {
const color = stateColors[node.status];
const pulse = node.status === 'error' ? 1 + Math.sin(time * 5 + node.id) * .14 : 1;
const r = (node.status === 'idle' ? 5.5 : 7) * scale * pulse;
node.x = x; node.y = y; node.r = Math.max(9, r + 4);
if (node.status !== 'idle') glowCircle(x, y, r * 2.2, color, node.status === 'error' ? .72 : .52);
ctx.save();
ctx.strokeStyle = color + (node.status === 'idle' ? '66' : 'cc');
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(x, y, r + 4, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.78)';
ctx.beginPath(); ctx.arc(x - r * .25, y - r * .3, Math.max(1.2, r * .2), 0, Math.PI * 2); ctx.fill();
if (label && scale > .72) {
ctx.fillStyle = hovered === node.id - 1 ? '#ffffff' : '#7f8ca2';
ctx.font = '9px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(String(node.id).padStart(2, '0'), x, y + r + 15);
}
ctx.restore();
}
function drawParticle(x, y, color, size = 1.4, alpha = .7) {
ctx.save(); ctx.globalAlpha = alpha; ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(x, y, size, 0, Math.PI * 2); ctx.fill(); ctx.restore();
}
function drawOrbit(w, h) {
const cx = w / 2, cy = h / 2, base = Math.min(w, h) * .31;
ctx.save(); ctx.translate(cx, cy); ctx.rotate(-.16);
for (let ring = 0; ring < 3; ring++) {
const rx = base * (.72 + ring * .32), ry = rx * (.36 + ring * .055);
ctx.strokeStyle = ring === 1 ? 'rgba(124,108,255,.26)' : 'rgba(94,161,255,.14)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.ellipse(0, 0, rx, ry, ring * .56, 0, Math.PI * 2); ctx.stroke();
for (let p = 0; p < nodes.length * 3; p++) {
const a = time * (.24 + ring * .08) + p / (nodes.length * 3) * Math.PI * 2;
const cr = Math.cos(ring * .56), sr = Math.sin(ring * .56);
const ox = Math.cos(a) * rx, oy = Math.sin(a) * ry;
drawParticle(ox * cr - oy * sr, ox * sr + oy * cr, ring === 1 ? '#9d91ff' : '#55d5ff', 1.1, .18 + (p % 5) * .08);
}
}
ctx.restore();
glowCircle(cx, cy, 36 + Math.sin(time * 2) * 3, '#7c6cff', .55);
ctx.strokeStyle = 'rgba(151,136,255,.32)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cx, cy, 43 + Math.sin(time * 1.7) * 2, 0, Math.PI * 2); ctx.stroke();
nodes.forEach((node, i) => {
const ring = i % 3, rx = base * (.72 + ring * .32), ry = rx * (.36 + ring * .055);
const a = i / nodes.length * Math.PI * 2 + time * (.12 + ring * .025);
const rot = -.16 + ring * .56, ox = Math.cos(a) * rx, oy = Math.sin(a) * ry;
const x = cx + ox * Math.cos(rot) - oy * Math.sin(rot);
const y = cy + ox * Math.sin(rot) + oy * Math.cos(rot);
drawNode(node, x, y, .82 + (Math.sin(a) + 1) * .13);
});
}
function drawCore(w, h) {
const cx = w / 2, cy = h / 2, radius = Math.min(w, h) * .29;
for (let ring = 1; ring <= 5; ring++) {
const r = radius * (ring / 5) + Math.sin(time * 2 - ring) * 3;
ctx.strokeStyle = `rgba(72,198,255,${.16 - ring * .018})`;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
}
for (let p = 0; p < nodes.length * 8; p++) {
const layer = 1 + p % 4, a = p * 2.399 + time * (.32 + layer * .03);
const r = radius * (.24 + layer * .18) + Math.sin(time * 2 + p) * 8;
drawParticle(cx + Math.cos(a) * r, cy + Math.sin(a) * r * .82, p % 7 ? '#55d5ff' : '#a797ff', 1 + p % 3 * .35, .3 + p % 5 * .08);
}
glowCircle(cx, cy, 48 + Math.sin(time * 2.5) * 5, '#25c9ff', .42);
ctx.fillStyle = 'rgba(230,250,255,.9)'; ctx.beginPath(); ctx.arc(cx, cy, 8, 0, Math.PI * 2); ctx.fill();
nodes.forEach((node, i) => {
const a = i / nodes.length * Math.PI * 2 - time * .08;
const wobble = 1 + Math.sin(time * 1.4 + i * .8) * .07;
drawNode(node, cx + Math.cos(a) * radius * wobble, cy + Math.sin(a) * radius * .82 * wobble, .95);
});
}
function drawMesh(w, h) {
const cx = w / 2, cy = h / 2, radius = Math.min(w, h) * .35;
nodes.forEach((n, i) => {
const a = i * 2.39996 + time * .035;
const r = radius * (.28 + .72 * Math.sqrt((i + 1) / nodes.length));
n.x = cx + Math.cos(a) * r * 1.42;
n.y = cy + Math.sin(a) * r * .83;
});
// A soft additive bloom under every topology link, then a crisp core line.
ctx.save();
ctx.globalCompositeOperation = 'lighter';
nodes.forEach((a, i) => nodes.slice(i + 1).forEach(b => {
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d < radius * .66) {
const danger = a.status === 'error' || b.status === 'error';
const color = danger ? '#ff5f74' : '#56b1ff';
const strength = Math.max(0, 1 - d / (radius * .66));
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = 13;
ctx.strokeStyle = danger ? `rgba(255,95,116,${.08 + strength * .1})` : `rgba(86,177,255,${.07 + strength * .1})`;
ctx.lineWidth = 4.5;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.restore();
ctx.strokeStyle = danger ? `rgba(255,120,139,${.18 + strength * .22})` : `rgba(112,203,255,${.18 + strength * .26})`;
ctx.lineWidth = .75;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
// Two light packets travel in opposite directions across each link.
const flow = (time * .28 + i * .113 + b.id * .037) % 1;
const reverse = 1 - ((time * .18 + i * .071 + b.id * .043) % 1);
[flow, reverse].forEach((progress, packetIndex) => {
const px = a.x + (b.x - a.x) * progress;
const py = a.y + (b.y - a.y) * progress;
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = 10;
drawParticle(px, py, danger ? '#ff9cab' : '#b7e8ff', packetIndex ? 1 : 1.45, .55 + strength * .35);
ctx.restore();
});
}
}));
ctx.restore();
// Random particles escape from nodes and drift away from the graph center.
if (running && nodes.length && escapeParticles.length < 90 && Math.random() < .12) {
const source = nodes[Math.floor(Math.random() * nodes.length)];
const angle = Math.atan2(source.y - cy, source.x - cx) + (Math.random() - .5) * 1.2;
const speed = 18 + Math.random() * 42;
escapeParticles.push({
x: source.x, y: source.y,
px: source.x, py: source.y,
vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
life: .8 + Math.random() * 1.5,
maxLife: 0,
color: stateColors[source.status],
size: .7 + Math.random() * 1.6
});
escapeParticles[escapeParticles.length - 1].maxLife = escapeParticles[escapeParticles.length - 1].life;
}
ctx.save();
ctx.globalCompositeOperation = 'lighter';
escapeParticles.forEach(p => {
if (running) {
p.px = p.x; p.py = p.y;
p.x += p.vx / 60; p.y += p.vy / 60;
p.vx *= .996; p.vy = p.vy * .996 - .008;
p.life -= 1 / 60;
}
const alpha = Math.max(0, p.life / p.maxLife);
ctx.strokeStyle = p.color + Math.round(alpha * 130).toString(16).padStart(2, '0');
ctx.lineWidth = Math.max(.35, p.size * alpha);
ctx.shadowColor = p.color; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.moveTo(p.px, p.py); ctx.lineTo(p.x, p.y); ctx.stroke();
drawParticle(p.x, p.y, p.color, p.size * (.55 + alpha * .45), alpha);
});
ctx.restore();
escapeParticles = escapeParticles.filter(p => p.life > 0 && p.x > -30 && p.x < w + 30 && p.y > -30 && p.y < h + 30);
nodes.forEach((node, i) => drawNode(node, node.x + Math.sin(time + i) * 2, node.y + Math.cos(time * .8 + i) * 2, .88));
}
function drawHelix(w, h) {
const cx = w / 2, cy = h / 2, span = Math.min(w * .68, 620), amp = Math.min(h * .24, 130);
ctx.lineWidth = 1;
for (let side = 0; side < 2; side++) {
ctx.strokeStyle = side ? 'rgba(255,102,179,.22)' : 'rgba(86,205,255,.24)';
ctx.beginPath();
for (let s = 0; s <= 100; s++) {
const t = s / 100, x = cx - span / 2 + span * t;
const y = cy + Math.sin(t * Math.PI * 4 + time * .55 + side * Math.PI) * amp;
s ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.stroke();
}
for (let p = 0; p < nodes.length * 8; p++) {
const t = (p / (nodes.length * 8) + time * .035) % 1;
const side = p % 2, phase = t * Math.PI * 4 + time * .55 + side * Math.PI;
const depth = (Math.cos(phase) + 1) / 2;
drawParticle(cx - span / 2 + span * t, cy + Math.sin(phase) * amp, side ? '#ff7ebd' : '#65d9ff', .8 + depth * 1.5, .25 + depth * .6);
}
nodes.forEach((node, i) => {
const t = nodes.length === 1 ? .5 : i / (nodes.length - 1), side = i % 2;
const phase = t * Math.PI * 4 + time * .55 + side * Math.PI;
const depth = (Math.cos(phase) + 1) / 2;
drawNode(node, cx - span / 2 + span * t, cy + Math.sin(phase) * amp, .68 + depth * .48, depth > .28);
});
}
function drawScatterSphere(w, h) {
const cx = w / 2, cy = h / 2, radius = Math.min(w, h) * .285;
const tilt = -.24;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
// Six luminous great-circle tracks at different inclinations.
for (let ring = 0; ring < 6; ring++) {
const rotation = ring * Math.PI / 6 + time * (ring % 2 ? -.055 : .045);
const squash = .2 + (ring % 3) * .12;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(rotation + tilt);
ctx.shadowColor = ring % 2 ? '#7c6cff' : '#4edbff';
ctx.shadowBlur = 12;
ctx.strokeStyle = ring % 2 ? 'rgba(145,126,255,.19)' : 'rgba(78,219,255,.16)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.ellipse(0, 0, radius, radius * squash, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
for (let p = 0; p < 18; p++) {
const a = p / 18 * Math.PI * 2 + time * (.34 + ring * .025) * (ring % 2 ? -1 : 1);
const ox = Math.cos(a) * radius;
const oy = Math.sin(a) * radius * squash;
const cr = Math.cos(rotation + tilt), sr = Math.sin(rotation + tilt);
const depth = (Math.sin(a) + 1) / 2;
drawParticle(cx + ox * cr - oy * sr, cy + ox * sr + oy * cr,
ring % 2 ? '#a79aff' : '#79e7ff', .65 + depth * 1.2, .12 + depth * .52);
}
}
// Fibonacci sphere: evenly distributed particles rotate as one 3D shell.
const shellCount = 240;
for (let i = 0; i < shellCount; i++) {
const y0 = 1 - (i / (shellCount - 1)) * 2;
const ringRadius = Math.sqrt(Math.max(0, 1 - y0 * y0));
const theta = i * 2.399963 + time * .22;
let x0 = Math.cos(theta) * ringRadius;
let z0 = Math.sin(theta) * ringRadius;
const cosY = Math.cos(time * .12), sinY = Math.sin(time * .12);
const xr = x0 * cosY - z0 * sinY;
const zr = x0 * sinY + z0 * cosY;
const perspective = .74 + (zr + 1) * .17;
const px = cx + xr * radius * perspective;
const py = cy + y0 * radius * perspective;
const alpha = .12 + (zr + 1) * .28;
const color = i % 9 === 0 ? '#b49cff' : i % 5 === 0 ? '#ffffff' : '#55d8ff';
drawParticle(px, py, color, .55 + (zr + 1) * .48, alpha);
}
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius * .9);
core.addColorStop(0, 'rgba(93,124,255,.16)');
core.addColorStop(.52, 'rgba(52,174,255,.045)');
core.addColorStop(1, 'rgba(42,151,255,0)');
ctx.fillStyle = core;
ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fill();
// Random energy escapes from arbitrary points on the visible sphere edge.
if (running && sphereEscapes.length < 120 && Math.random() < .2) {
const angle = Math.random() * Math.PI * 2;
const startRadius = radius * (.7 + Math.random() * .28);
const speed = 28 + Math.random() * 70;
const tangent = (Math.random() - .5) * .45;
const direction = angle + tangent;
const particle = {
x: cx + Math.cos(angle) * startRadius,
y: cy + Math.sin(angle) * startRadius * .84,
vx: Math.cos(direction) * speed,
vy: Math.sin(direction) * speed,
life: .7 + Math.random() * 1.55,
maxLife: 0,
color: Math.random() > .35 ? '#5de0ff' : '#9c89ff',
size: .8 + Math.random() * 1.9
};
particle.px = particle.x; particle.py = particle.y; particle.maxLife = particle.life;
sphereEscapes.push(particle);
}
sphereEscapes.forEach(p => {
if (running) {
p.px = p.x; p.py = p.y;
p.x += p.vx / 60; p.y += p.vy / 60;
p.vx *= .998; p.vy *= .998; p.life -= 1 / 60;
}
const alpha = Math.max(0, p.life / p.maxLife);
ctx.shadowColor = p.color; ctx.shadowBlur = 10;
ctx.strokeStyle = p.color + Math.round(alpha * 155).toString(16).padStart(2, '0');
ctx.lineWidth = Math.max(.4, p.size * alpha);
ctx.beginPath(); ctx.moveTo(p.px, p.py); ctx.lineTo(p.x, p.y); ctx.stroke();
drawParticle(p.x, p.y, p.color, p.size * (.5 + alpha * .5), alpha);
});
sphereEscapes = sphereEscapes.filter(p => p.life > 0 && p.x > -40 && p.x < w + 40 && p.y > -40 && p.y < h + 40);
ctx.restore();
}
function drawGrid(w, h) {
ctx.save(); ctx.strokeStyle = 'rgba(105,133,178,.055)'; ctx.lineWidth = 1;
const gap = 44;
for (let x = (w / 2) % gap; x < w; x += gap) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
for (let y = (h / 2) % gap; y < h; y += gap) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
ctx.restore();
}
function frame(now) {
const rect = canvas.getBoundingClientRect();
const dt = Math.min((now - lastFrame) / 1000, .05); lastFrame = now;
if (running) time += dt;
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
({ orbit: drawOrbit, core: drawCore, mesh: drawMesh, helix: drawHelix, scatter: drawScatterSphere })[mode](rect.width, rect.height);
frameSamples.push(1 / Math.max(dt, .001));
if (frameSamples.length > 30) frameSamples.shift();
if (Math.floor(now / 500) !== Math.floor((now - dt * 1000) / 500)) {
document.getElementById('fps').textContent = Math.round(frameSamples.reduce((a,b) => a+b, 0) / frameSamples.length);
if (mode === 'scatter') document.getElementById('particleCount').textContent = 348 + sphereEscapes.length;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
document.querySelectorAll('.mode-btn').forEach(btn => btn.addEventListener('click', () => {
mode = btn.dataset.mode;
document.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b === btn));
document.getElementById('modeTitle').textContent = modes[mode].title;
document.getElementById('modeDesc').textContent = modes[mode].desc;
const nodeFree = mode === 'scatter';
document.querySelectorAll('[data-node-controls]').forEach(el => el.classList.toggle('node-controls-disabled', nodeFree));
document.getElementById('particleCount').textContent = nodeFree ? 348 + sphereEscapes.length : nodes.length * 8;
hovered = -1; hideTooltip();
}));
document.getElementById('addNode').addEventListener('click', addNode);
document.getElementById('removeNode').addEventListener('click', removeNode);
document.getElementById('shuffleStatus').addEventListener('click', () => {
const values = ['running', 'running', 'running', 'idle', 'error'];
nodes.forEach(n => n.status = values[Math.floor(Math.random() * values.length)]);
updateMetrics();
});
document.querySelectorAll('[data-set-status]').forEach(btn => btn.addEventListener('click', () => {
nodes.forEach(n => n.status = btn.dataset.setStatus); updateMetrics();
}));
document.getElementById('toggleMotion').addEventListener('click', event => {
running = !running;
const btn = event.currentTarget;
btn.innerHTML = `<i data-lucide="${running ? 'pause' : 'play'}" class="h-4 w-4"></i>`;
btn.setAttribute('aria-label', running ? '暂停动画' : '继续动画');
lucide.createIcons();
});
function nodeAt(clientX, clientY) {
if (mode === 'scatter') return -1;
const rect = canvas.getBoundingClientRect(), x = clientX - rect.left, y = clientY - rect.top;
for (let i = nodes.length - 1; i >= 0; i--) if (Math.hypot(x - nodes[i].x, y - nodes[i].y) <= nodes[i].r + 5) return i;
return -1;
}
function hideTooltip() { document.getElementById('tooltip').classList.add('hidden'); }
canvas.addEventListener('pointermove', event => {
hovered = nodeAt(event.clientX, event.clientY);
if (hovered < 0) { hideTooltip(); return; }
const node = nodes[hovered], tip = document.getElementById('tooltip');
document.getElementById('tooltipName').textContent = `NODE-${String(node.id).padStart(2, '0')}`;
const status = document.getElementById('tooltipStatus');
status.textContent = stateLabels[node.status]; status.style.color = stateColors[node.status];
tip.style.left = `${node.x}px`; tip.style.top = `${node.y}px`; tip.classList.remove('hidden');
});
canvas.addEventListener('pointerleave', () => { hovered = -1; hideTooltip(); });
canvas.addEventListener('click', event => {
const i = nodeAt(event.clientX, event.clientY); if (i < 0) return;
const cycle = ['idle', 'running', 'error'];
nodes[i].status = cycle[(cycle.indexOf(nodes[i].status) + 1) % cycle.length];
updateMetrics();
});
updateMetrics();
lucide.createIcons();
</script>
</body>
</html>