605 lines
33 KiB
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>
|