Files
tianxuan/preview_cluster_overview.html
T

377 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>聚类概览 — 预览</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f7fa;color:#1a1a2e}
.header{background:#1a1a2e;color:#fff;padding:0.8rem 1.5rem;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:1.1rem}.header span{font-size:0.8rem;color:#aaa}
.main-layout{display:flex;position:relative;min-height:calc(100vh - 48px)}
.left{flex:1;min-width:0;padding:1rem;overflow-y:auto;transition:flex .3s}
.left.globe-open{flex:0 0 55%}
.right{flex:0 0 0;overflow:hidden;transition:flex .3s}
.right.globe-open{flex:0 0 45%}
.toggle-handle{
position:absolute;z-index:10;top:50%;width:20px;height:44px;background:#1a1a2e;color:#fff;
border:none;border-radius:4px 0 0 4px;cursor:pointer;display:flex;align-items:center;
justify-content:center;font-size:12px;transition:left .3s;left:calc(100% - 20px)
}
.toggle-handle:hover{background:#4361ee}
.card{background:#fff;border-radius:8px;padding:1rem;margin-bottom:1rem;box-shadow:0 1px 3px rgba(0,0,0,.1)}
.card h3{font-size:.95rem;margin-bottom:.5rem}
.scatter-wrap{padding:.75rem}
.scatter-wrap canvas{width:100%;height:380px;display:block;border-radius:4px;background:#fafbfc}
.pill-row{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:.75rem}
.pill{display:inline-flex;align-items:center;gap:3px;padding:2px 10px;border-radius:12px;font-size:.75rem;cursor:pointer;border:2px solid transparent;color:#fff;white-space:nowrap}
.pill.active{border-color:#333;font-weight:700}
.card-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
.cluster-card{background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:.8rem;cursor:pointer;transition:box-shadow .15s}
.cluster-card:hover{box-shadow:0 2px 8px rgba(0,0,0,.1)}
.cluster-card.selected{border-color:#4361ee;box-shadow:0 0 0 2px rgba(67,97,238,.2)}
.cluster-card .lbl{font-weight:700;font-size:1rem}
.cluster-card .meta{font-size:.78rem;color:#666;margin-top:.2rem}
.cluster-card .desc{font-size:.8rem;color:#333;margin-top:.4rem;padding:.4rem;background:#f8f9fa;border-left:3px solid #4361ee;border-radius:0 4px 4px 0;line-height:1.5}
.detail-panel{position:fixed;bottom:0;left:0;right:0;z-index:100;transform:translateY(100%);transition:transform .35s cubic-bezier(.4,0,.2,1)}
.detail-panel.open{transform:translateY(0)}
.detail-body{background:#fff;border-radius:16px 16px 0 0;box-shadow:0 -4px 24px rgba(0,0,0,.15);max-height:40vh;overflow-y:auto;padding:.5rem 1.5rem 1.5rem}
.detail-handle{width:36px;height:4px;background:#ccc;border-radius:2px;margin:8px auto;cursor:pointer}
.detail-close{float:right;cursor:pointer;font-size:1.3rem;color:#999;border:none;background:none}
.detail-close:hover{color:#333}
.detail-body h3{font-size:1rem;margin-bottom:.3rem}
.detail-body p{font-size:.85rem;color:#555;margin:.2rem 0}
.detail-body table{width:100%;border-collapse:collapse;font-size:.8rem;margin-top:.5rem}
.detail-body th{background:#f5f5f5;padding:.25rem .4rem;text-align:left;font-weight:600;border-bottom:2px solid #ddd;white-space:nowrap}
.detail-body td{padding:.2rem .4rem;border-bottom:1px solid #eee}
.feat-p{color:#2e7d32}.feat-n{color:#c62828}
#globeCont{width:100%;height:calc(100vh - 48px);background:#0a0a1a;position:relative}
.btn-sm{display:inline-block;padding:.25rem .6rem;border-radius:4px;font-size:.78rem;text-decoration:none;background:#4361ee;color:#fff}
</style>
</head>
<body>
<div class="header">
<h1>聚类概览 — 运行 #9</h1>
<span>1,284,592 条流 · 3 个簇 · 12,847 个噪声</span>
</div>
<div class="main-layout" id="main">
<div class="left" id="leftPanel">
<!-- UMAP 散点图 -->
<div class="card scatter-wrap">
<div style="display:flex;justify-content:space-between;align-items:center">
<h3>UMAP 嵌入 <span style="font-weight:400;font-size:.8rem;color:#888" id="ptCount">加载中...</span></h3>
<span id="viewToggle" style="display:none">
<button id="btn2D" class="btn-sm" style="font-weight:700">2D</button>
<button id="btn3D" class="btn-sm btn-outline">3D</button>
</span>
</div>
<canvas id="scatterChart"></canvas>
<div class="pill-row" id="legendPills"></div>
</div>
<!-- 簇标签 -->
<div class="pill-row" id="clusterPills"></div>
<!-- 簇卡片 -->
<div class="card-grid" id="cardGrid"></div>
</div>
<div class="right" id="rightPanel">
<div id="globeCont"></div>
</div>
<button class="toggle-handle" id="globeHandle" onclick="toggleGlobe()"></button>
</div>
<!-- 详情面板 -->
<div class="detail-panel" id="detailPanel">
<div class="detail-body">
<button class="detail-close" onclick="closeDetail()"></button>
<div class="detail-handle" onclick="closeDetail()"></div>
<div id="detailContent"><p style="color:#999;padding:.5rem 0">选择一个簇或数据点查看详情</p></div>
</div>
</div>
<script src="static/tianxuan/three.min.js"></script>
<script>
// ═══ 模拟数据 ═══
const COLORS = ['#4361ee','#f72585','#7209b7','#4cc9f0','#f8961e','#43aa8b','#f94144','#577590','#e9c46a','#9b5de5'];
const CLUSTERS = [
{label:0,size:847293,prop:0.66,silh:0.71,
desc:'低延迟 (23ms),比全局均值高3.1个标准差;大数据包 (1520 bytes),高2.3个标准差;系统端口 (443)',
detail:'簇 #0 由 847,293 条流组成(66%)。\n特征:延迟(4dur)均值23ms,区分度+3.2;数据包大小(8ppk)均值1520B,区分度+2.3;端口(:prd)高度集中于443(92%)TLS版本以1.3为主(78%)。'},
{label:1,size:311482,prop:0.24,silh:0.65,
desc:'高TLS1.3比例 (92%),比均值高1.8σ;短会话 (12s),低1.5σ;ECDHE曲线 X25519',
detail:'簇 #1 由 311,482 条流组成(24%)。\n特征:TLS1.3比率(0ver)92%,区分度+1.8;会话时长(8ses)均值12s,区分度-1.5;密钥交换曲线集中在X25519(88%);目标端口包括443和8443。'},
{label:2,size:112970,prop:0.09,silh:0.52,
desc:'高连接频率 (85次/min),比均值高4.2σ;非标准端口 (8080/8443);目标多域名',
detail:'簇 #2 由 112,970 条流组成(9%)。\n特征:连接频率(8seq/min)均值85,区分度+4.2;非标准端口比例高(76%),以8080/8443为主;SNI名称(snam)分散在8个不同域名;源IP来自不同/24网段。'},
{label:-1,size:12847,prop:0.01,silh:null,
desc:'噪声 — 异常TLS指纹、零散目标、单次连接',
detail:''},
];
const RNG = (s)=>{let seed=s%2147483647;return()=>{seed=seed*16807%2147483647;return(seed-1)/2147483646;}};
const r=RNG(42);
const centers={0:[2.2,1.8],1:[-2.0,0.3],2:[0.3,-2.2],'-1':[3.8,-1.2]};
const spreads={0:[0.7,0.6],1:[0.8,0.7],2:[0.6,0.8],'-1':[1.5,1.0]};
const SCATTER=[];
let totalPts=0;
for(const c of CLUSTERS){
const n=c.label===-1?200:Math.min(c.size/2000,200);
totalPts+=n;
for(let i=0;i<n;i++){
const cx=centers[c.label][0],cy=centers[c.label][1];
const sx=spreads[c.label][0],sy=spreads[c.label][1];
SCATTER.push({
x:cx+(r()-0.5)*2*sx, y:cy+(r()-0.5)*2*sy,
z:(r()-0.5)*2*0.8, label:c.label,
entity:c.label===-1?'噪声':`主机_${1000+i}`
});
}
}
let selCluster=null,globeOpen=false,globeInit=false;
// ═══ 2D Canvas 散点 ═══
function drawScatter(highlight){
const c=document.getElementById('scatterChart');
if(!c)return;
const W=c.parentElement.clientWidth||700,H=380;
c.width=W;c.height=H;
const ctx=c.getContext('2d');
const pad={top:25,bottom:30,left:50,right:20};
const pw=W-pad.left-pad.right,ph=H-pad.top-pad.bottom;
const xs=SCATTER.map(d=>d.x),ys=SCATTER.map(d=>d.y);
const xMin=Math.min(...xs),xMax=Math.max(...xs),yMin=Math.min(...ys),yMax=Math.max(...ys);
const xR=xMax-xMin||1,yR=yMax-yMin||1;
const toX=v=>pad.left+(v-xMin)/xR*pw,toY=v=>pad.top+ph-(v-yMin)/yR*ph;
ctx.clearRect(0,0,W,H);
ctx.strokeStyle='#eee';ctx.lineWidth=1;
for(let i=0;i<=5;i++){ctx.beginPath();ctx.moveTo(pad.left,pad.top+i*ph/5);ctx.lineTo(W-pad.right,pad.top+i*ph/5);ctx.stroke();}
SCATTER.forEach(d=>{
const cx=toX(d.x),cy=toY(d.y);
let color=d.label===-1?'rgba(150,150,150,0.4)':COLORS[Math.abs(d.label)%COLORS.length];
let r2=d.label===-1?2:4;
if(highlight!==null&&highlight!==undefined){
if(d.label===highlight){color=d.label===-1?'rgba(150,150,150,0.9)':COLORS[Math.abs(d.label)%COLORS.length];r2=6;}
else{color='rgba(200,200,200,0.15)';r2=2;}
}
ctx.beginPath();ctx.arc(cx,cy,r2,0,Math.PI*2);ctx.fillStyle=color;ctx.fill();
});
ctx.fillStyle='#999';ctx.font='11px sans-serif';ctx.textAlign='center';
ctx.fillText('UMAP-1',W/2,H-3);
ctx.save();ctx.translate(14,H/2);ctx.rotate(-Math.PI/2);ctx.fillText('UMAP-2',0,0);ctx.restore();
document.getElementById('ptCount').textContent=SCATTER.length+' 个点';
// legend pills (from scatter data)
const leg=document.getElementById('legendPills');leg.innerHTML='';
const seen={};
SCATTER.forEach(d=>{if(!seen[d.label]){seen[d.label]=true;
const p=document.createElement('span');p.className='pill';
p.style.background=d.label===-1?'#999':COLORS[Math.abs(d.label)%COLORS.length];
p.textContent=d.label===-1?'噪声':'#'+d.label;
p.onclick=()=>selectCluster(d.label);leg.appendChild(p);
}});
}
// ═══ 簇选择 ═══
function selectCluster(lbl){
selCluster=CLUSTERS.find(c=>c.label===lbl);
drawScatter(lbl);
document.querySelectorAll('#clusterPills .pill').forEach(p=>p.classList.toggle('active',parseInt(p.dataset.lbl)===lbl));
document.querySelectorAll('.cluster-card').forEach(p=>p.classList.toggle('selected',parseInt(p.dataset.label)===lbl));
if(globeOpen)globeFilter(lbl);
showDetail(selCluster);
}
function clearSel(){selCluster=null;drawScatter(null);closeDetail();
document.querySelectorAll('.pill,.cluster-card').forEach(p=>p.classList.remove('active','selected'));
if(globeOpen)globeFilter(null);
}
// ═══ 详情面板 ═══
function showDetail(c){
const dc=document.getElementById('detailContent');
if(!c||c.label===-1){dc.innerHTML='<p style="color:#999;">选择一个簇查看详情</p>';document.getElementById('detailPanel').classList.remove('open');return;}
dc.innerHTML='<h3>簇 #'+c.label+'</h3><p>大小: '+c.size.toLocaleString()+' | 占比: '+(c.prop*100).toFixed(1)+'%'+(c.silh!==null?' | 轮廓系数: '+c.silh.toFixed(4):'')+'</p>'+
'<p style="background:#f8f9fa;border-left:3px solid #4361ee;padding:.5rem;margin:.5rem 0;border-radius:0 4px 4px 0;font-size:.85rem;line-height:1.6;white-space:pre-wrap;">'+c.detail+'</p>'+
'<table><thead><tr><th>特征</th><th>区分度</th><th>均值</th><th>标准差</th></tr></thead><tbody>'+
(c.label===0?'<tr><td>延迟 (4dur)</td><td class="feat-p">+3.2</td><td>23.0ms</td><td>5.1</td></tr><tr><td>数据包 (8ppk)</td><td class="feat-p">+2.3</td><td>1520.0B</td><td>128</td></tr><tr><td>TLS版本 (0ver)</td><td class="feat-n">-1.8</td><td>1.2</td><td>0.3</td></tr>':
c.label===1?'<tr><td>TLS1.3比率</td><td class="feat-p">+1.8</td><td>92%</td><td>8%</td></tr><tr><td>会话时长</td><td class="feat-n">-1.5</td><td>12s</td><td>4s</td></tr><tr><td>端口</td><td class="feat-p">+1.2</td><td>443</td><td>12</td></tr>':
'<tr><td>连接频率</td><td class="feat-p">+4.2</td><td>85/min</td><td>12</td></tr><tr><td>非标准端口</td><td class="feat-p">+2.8</td><td>76%</td><td>9%</td></tr><tr><td>SNI域名数</td><td class="feat-p">+2.1</td><td>8</td><td>2</td></tr>')+
'</tbody></table>';
document.getElementById('detailPanel').classList.add('open');
}
function closeDetail(){document.getElementById('detailPanel').classList.remove('open');}
// ═══ 3D UMAP scatter ═══
let s3DRenderer,s3DScene,s3DCamera,s3DPts,s3DInit=false,is3DMode=false;
function hasWG2(){try{var c=document.createElement('canvas');return!!c.getContext('webgl2');}catch(e){return false;}}
(function(){
if(!hasWG2())return;
document.getElementById('viewToggle').style.display='inline';
const b2=document.getElementById('btn2D'),b3=document.getElementById('btn3D');
function show2D(){is3DMode=false;document.querySelector('#scatterChart').style.display='';
document.getElementById('viewToggle').style.display='none';drawScatter(selCluster?selCluster.label:null);
setTimeout(()=>document.getElementById('viewToggle').style.display='inline',50);
b2.style.fontWeight='700';b3.style.fontWeight='normal';}
function show3D(){is3DMode=true;document.querySelector('#scatterChart').style.display='none';
if(!s3DInit)init3D();b3.style.fontWeight='700';b2.style.fontWeight='normal';}
b2.onclick=show2D;b3.onclick=show3D;
})();
function init3D(){
if(s3DInit)return;
const cont=document.getElementById('scatterChart').parentElement;
const W=cont.clientWidth||700,H=450;
const container=document.createElement('div');container.id='scatter3D';container.style.cssText='width:100%;height:450px';
cont.appendChild(container);
const xs=SCATTER.map(d=>d.x),ys=SCATTER.map(d=>d.y),zs=SCATTER.map(d=>d.z||0);
const xM=Math.min(...xs),xX=Math.max(...xs),yM=Math.min(...ys),yX=Math.max(...ys),zM=Math.min(...zs),zX=Math.max(...zs);
const cx=(xM+xX)/2,cy=(yM+yX)/2,cz=(zM+zX)/2,rg=Math.max(xX-xM,yX-yM,zX-zM)||1;
const nrm=(v,c,r)=>((v-c)/r)*3;
const scene=new THREE.Scene();scene.background=new THREE.Color(0xf8f9fa);
const camera=new THREE.PerspectiveCamera(50,W/H,0.1,50);camera.position.set(4.5,3.5,6);camera.lookAt(0,0,0);
const renderer=new THREE.WebGLRenderer({antialias:true});renderer.setSize(W,H);renderer.setPixelRatio(Math.min(window.devicePixelRatio,2));
container.appendChild(renderer.domElement);
const pos=new Float32Array(SCATTER.length*3);
const cols=new Float32Array(SCATTER.length*3);
SCATTER.forEach((d,i)=>{
pos[i*3]=nrm(d.x,cx,rg);pos[i*3+1]=nrm(d.y,cy,rg);pos[i*3+2]=d.z!=null?nrm(d.z,cz,rg):0;
const c3=new THREE.Color(d.label===-1?'#999999':COLORS[Math.abs(d.label)%COLORS.length]);
cols[i*3]=c3.r;cols[i*3+1]=c3.g;cols[i*3+2]=c3.b;
});
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
geo.setAttribute('color',new THREE.BufferAttribute(cols,3));
const mat=new THREE.PointsMaterial({size:0.12,vertexColors:true,sizeAttenuation:true,transparent:true,opacity:0.85});
const pts=new THREE.Points(geo,mat);scene.add(pts);
let isD=false,pm={x:0,y:0};
renderer.domElement.onmousedown=e=>{isD=true;pm={x:e.clientX,y:e.clientY};};
window.onmouseup=()=>isD=false;
window.onmousemove=e=>{if(!isD||!is3DMode)return;const dx=e.clientX-pm.x,dy=e.clientY-pm.y;
pts.rotation.y+=dx*0.005;pts.rotation.x+=dy*0.005;pts.rotation.x=Math.max(-Math.PI/2,Math.min(Math.PI/2,pts.rotation.x));
pm={x:e.clientX,y:e.clientY};};
renderer.domElement.onwheel=e=>{if(!is3DMode)return;e.preventDefault();camera.position.z+=e.deltaY*0.008;camera.position.z=Math.max(1.5,Math.min(15,camera.position.z));};
!function anim(){requestAnimationFrame(anim);if(!is3DMode)return;renderer.render(scene,camera);}();
s3DRenderer=renderer;s3DScene=scene;s3DCamera=camera;s3DPts=pts;s3DInit=true;
}
// ═══ 地球侧栏 ═══
function toggleGlobe(){
globeOpen=!globeOpen;
document.getElementById('leftPanel').classList.toggle('globe-open',globeOpen);
document.getElementById('rightPanel').classList.toggle('globe-open',globeOpen);
const h=document.getElementById('globeHandle');
h.innerHTML=globeOpen?'▶':'◀';
h.style.left=globeOpen?'calc(55% - 20px)':'calc(100% - 20px)';
if(globeOpen&&!globeInit)initGlobe();
}
function globeFilter(label){
if(!window._arcs)return;
window._arcs.forEach(m=>{
if(label===null){m.material.opacity=0.5;m.material.color.setHex(parseInt(m.userData.c,16));}
else{m.material.opacity=0.04;m.material.color.setHex(0x444444);}
});
}
function initGlobe(){
if(globeInit)return;
const cont=document.getElementById('globeCont');
if(!cont)return;
const W=cont.clientWidth||500,H=cont.clientHeight||500;
const scene=new THREE.Scene();scene.background=new THREE.Color(0x0a0a1a);
const cam=new THREE.PerspectiveCamera(45,W/H,0.1,100);cam.position.set(0,0,6);
const ren=new THREE.WebGLRenderer({antialias:true});ren.setSize(W,H);ren.setPixelRatio(Math.min(window.devicePixelRatio,2));
cont.appendChild(ren.domElement);
// Earth with texture
const tex=new THREE.TextureLoader().load('static/tianxuan/earth_atmos_2048.jpg');
const earth=new THREE.Mesh(new THREE.SphereGeometry(2,64,64),new THREE.MeshPhongMaterial({map:tex,specular:new THREE.Color(0x333333),shininess:5}));
scene.add(earth);
// Glow
scene.add(new THREE.Mesh(new THREE.SphereGeometry(2.06,48,48),new THREE.MeshBasicMaterial({color:0x224488,transparent:true,opacity:0.12})));
// Grid
const gridMat=new THREE.LineBasicMaterial({color:0x6699cc,transparent:true,opacity:0.08});
for(let lat=-80;lat<=80;lat+=20){const pts=[];for(let lon=0;lon<=360;lon+=5){const p=(90-lat)*Math.PI/180,t=(lon+180)*Math.PI/180;pts.push(new THREE.Vector3(-2.02*Math.sin(p)*Math.cos(t),2.02*Math.cos(p),2.02*Math.sin(p)*Math.sin(t)));}scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts),gridMat));}
for(let lon=0;lon<360;lon+=20){const pts=[];for(let lat=-90;lat<=90;lat+=5){const p=(90-lat)*Math.PI/180,t=(lon+180)*Math.PI/180;pts.push(new THREE.Vector3(-2.02*Math.sin(p)*Math.cos(t),2.02*Math.cos(p),2.02*Math.sin(p)*Math.sin(t)));}scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts),gridMat));}
// Lights
scene.add(new THREE.AmbientLight(0x222244));
const dl=new THREE.DirectionalLight(0xffffff,1);dl.position.set(5,10,7);scene.add(dl);
scene.add(new THREE.DirectionalLight(0x4488ff,0.3));
// Stars
const sg=new THREE.BufferGeometry();const sp=new Float32Array(600*3);
for(let i=0;i<600*3;i++)sp[i]=(Math.random()-0.5)*100;
sg.setAttribute('position',new THREE.BufferAttribute(sp,3));
scene.add(new THREE.Points(sg,new THREE.PointsMaterial({color:0xffffff,size:0.15})));
// Flow arcs
const tC={'TLSv1.3':0x4cc9f0,'TLSv1.2':0x43aa8b,'TLSv1.1':0xf8961e,'TLSv1.0':0xf94144};
const ag=new THREE.Group();scene.add(ag);
const arcs=[];
const cities=[{lat:31.2,lon:121.5},{lat:39.9,lon:116.4},{lat:35.7,lon:139.7},{lat:37.6,lon:127.0},{lat:51.5,lon:-0.13},{lat:48.9,lon:2.35},{lat:40.7,lon:-74.0},{lat:37.8,lon:-122.4},{lat:1.35,lon:103.8},{lat:-33.9,lon:151.2}];
const tls=['TLSv1.3','TLSv1.2','TLSv1.3','TLSv1.3','TLSv1.2','TLSv1.1','TLSv1.2','TLSv1.3','TLSv1.2','TLSv1.3'];
function ll(lat,lon,r){const p=(90-lat)*Math.PI/180,t=(lon+180)*Math.PI/180;return new THREE.Vector3(-r*Math.sin(p)*Math.cos(t),r*Math.cos(p),r*Math.sin(p)*Math.sin(t));}
for(let i=0;i<cities.length;i++){
for(let j=0;j<cities.length;j++){
if(i===j)continue;
const src=cities[i],dst=cities[j];
const f=ll(src.lat,src.lon,2),t=ll(dst.lat,dst.lon,2);
const mid=new THREE.Vector3().addVectors(f,t).multiplyScalar(0.5).normalize().multiplyScalar(3.2);
const curve=new THREE.QuadraticBezierCurve3(f,mid,t);
const c=tls[(i+j)%tls.length];
const mat=new THREE.MeshBasicMaterial({color:tC[c],transparent:true,opacity:0.4});
const mesh=new THREE.Mesh(new THREE.TubeGeometry(curve,16,0.012,4,false),mat);
mesh.userData={c:tC[c].toString(16)};ag.add(mesh);arcs.push(mesh);
}
}
window._arcs=arcs;
let isD=false,pm={x:0,y:0};
ren.domElement.onmousedown=e=>{isD=true;pm={x:e.clientX,y:e.clientY};};
window.onmouseup=()=>isD=false;
window.onmousemove=e=>{if(!isD)return;const dx=e.clientX-pm.x,dy=e.clientY-pm.y;earth.rotation.y+=dx*0.005;earth.rotation.x+=dy*0.005;ag.rotation.y=earth.rotation.y;ag.rotation.x=earth.rotation.x;pm={x:e.clientX,y:e.clientY};};
!function anim(){requestAnimationFrame(anim);if(!isD){earth.rotation.y+=0.003;ag.rotation.y=earth.rotation.y;}ren.render(scene,cam);}();
globeInit=true;
}
// ═══ 渲染 ═══
(function init(){
const pr=document.getElementById('clusterPills');
const ap=document.createElement('span');ap.className='pill';ap.style.background='#e8e8e8';ap.style.color='#333';
ap.textContent='全部';ap.onclick=clearSel;pr.appendChild(ap);
CLUSTERS.forEach(c=>{
if(c.label===-1)return;
const p=document.createElement('span');p.className='pill';p.style.background=COLORS[c.label%COLORS.length];
p.dataset.lbl=c.label;p.textContent='#'+c.label+' ('+(c.size/1000).toFixed(0)+'K)';p.onclick=()=>selectCluster(c.label);
pr.appendChild(p);
});
const np=document.createElement('span');np.className='pill';np.style.background='#999';
np.dataset.lbl='-1';np.textContent='噪声 (12.8K)';np.onclick=()=>selectCluster(-1);pr.appendChild(np);
const cg=document.getElementById('cardGrid');
CLUSTERS.forEach(c=>{
if(c.label===-1)return;
const cd=document.createElement('div');cd.className='cluster-card';cd.dataset.label=c.label;
cd.innerHTML='<div class="lbl">簇 #'+c.label+'</div><div class="meta">'+c.size.toLocaleString()+' 条流 · '+(c.prop*100).toFixed(0)+'%'+(c.silh!==null?' · 轮廓系数: '+c.silh.toFixed(4):'')+'</div><div class="desc">'+c.desc+'</div>';
cd.onclick=()=>selectCluster(c.label);cg.appendChild(cd);
});
drawScatter(null);
document.getElementById('globeHandle').style.left='calc(100% - 20px)';
})();
</script>
</body>
</html>