357 lines
19 KiB
HTML
357 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-hans">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
<title>原型 — 聚类UI交互验证</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; transition:all 0.3s; min-height:calc(100vh - 48px); }
|
|
.left-panel { flex:1; min-width:0; transition:flex 0.3s; padding:1rem; overflow-y:auto; }
|
|
.left-panel.globe-open { flex:0 0 55%; }
|
|
.right-panel { flex:0 0 0; overflow:hidden; transition:flex 0.3s; }
|
|
.right-panel.globe-open { flex:0 0 45%; }
|
|
|
|
/* toggle handle */
|
|
.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 0.3s ease; }
|
|
.toggle-handle:hover { background:#4361ee; }
|
|
|
|
/* scatter */
|
|
.scatter-wrap { position:relative; background:#fff; border-radius:8px; padding:0.75rem; margin-bottom:1rem; box-shadow:0 1px 3px rgba(0,0,0,0.1); }
|
|
.scatter-wrap h3 { font-size:0.95rem; margin-bottom:0.5rem; }
|
|
.scatter-wrap canvas { width:100%; height:360px; display:block; cursor:crosshair; border-radius:4px; background:#fafbfc; }
|
|
|
|
/* pills */
|
|
.pill-row { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:0.75rem; }
|
|
.pill { display:inline-flex; align-items:center; gap:3px; padding:2px 10px; border-radius:12px; font-size:0.75rem; cursor:pointer; border:2px solid transparent; transition:all 0.15s; color:#fff; white-space:nowrap; }
|
|
.pill.all { background:#e8e8e8; color:#333; }
|
|
.pill.active { border-color:#333; font-weight:700; }
|
|
|
|
/* cluster cards */
|
|
.card-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.75rem; margin-bottom:1rem; }
|
|
.cluster-card { background:#fff; border:1px solid #e0e0e0; border-radius:8px; padding:0.8rem; cursor:pointer; transition:box-shadow 0.15s; }
|
|
.cluster-card:hover { box-shadow:0 2px 8px rgba(0,0,0,0.1); }
|
|
.cluster-card.selected { border-color:#4361ee; box-shadow:0 0 0 2px rgba(67,97,238,0.2); }
|
|
.cluster-card .label { font-weight:700; font-size:1rem; }
|
|
.cluster-card .meta { font-size:0.78rem; color:#666; margin-top:0.2rem; }
|
|
.cluster-card .desc { font-size:0.8rem; color:#333; margin-top:0.4rem; padding:0.4rem; background:#f8f9fa; border-left:3px solid #4361ee; border-radius:0 4px 4px 0; line-height:1.5; }
|
|
|
|
/* detail panel */
|
|
.detail-panel { position:fixed; bottom:0; left:0; right:0; z-index:100; transform:translateY(100%); transition:transform 0.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,0.15); max-height:40vh; overflow-y:auto; padding:0.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; line-height:1; border:none; background:none; }
|
|
.detail-close:hover { color:#333; }
|
|
.detail-body h3 { font-size:1rem; margin-bottom:0.3rem; }
|
|
.detail-body p { font-size:0.85rem; color:#555; margin:0.2rem 0; }
|
|
.detail-body table { width:100%; border-collapse:collapse; font-size:0.8rem; margin-top:0.5rem; }
|
|
.detail-body th { background:#f5f5f5; padding:0.25rem 0.4rem; text-align:left; font-weight:600; border-bottom:2px solid #ddd; }
|
|
.detail-body td { padding:0.2rem 0.4rem; border-bottom:1px solid #eee; }
|
|
.detail-body td code { font-size:0.72rem; }
|
|
|
|
/* 3D地球容器 */
|
|
#globeContainer { width:100%; height:calc(100vh - 48px); background:#0a0a1a; position:relative; }
|
|
#globeContainer canvas { display:block; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="header">
|
|
<h1>聚类概览 — 运行 #9</h1>
|
|
<span>200 条流 · 3 个簇 · 12 个噪声点</span>
|
|
</div>
|
|
|
|
<div class="main-layout" id="mainLayout">
|
|
<!-- Left: cluster content -->
|
|
<div class="left-panel" id="leftPanel">
|
|
<!-- Scatter -->
|
|
<div class="scatter-wrap">
|
|
<h3>UMAP 嵌入 <span style="font-weight:400;font-size:0.8rem;color:#888;" id="ptCount">加载中...</span></h3>
|
|
<canvas id="scatterChart"></canvas>
|
|
</div>
|
|
|
|
<!-- Cluster pills -->
|
|
<div class="pill-row" id="pillRow">
|
|
<span class="pill all active" data-lbl="all" onclick="clearSel()">全部</span>
|
|
</div>
|
|
|
|
<!-- Cluster cards -->
|
|
<div class="card-grid" id="cardGrid"></div>
|
|
</div>
|
|
|
|
<!-- Right: 3D Globe -->
|
|
<div class="right-panel" id="rightPanel">
|
|
<div id="globeContainer"></div>
|
|
</div>
|
|
|
|
<!-- Toggle handle -->
|
|
<button class="toggle-handle" id="globeHandle" onclick="toggleGlobe()">◀</button>
|
|
</div>
|
|
|
|
<!-- Bottom detail panel -->
|
|
<div class="detail-panel" id="detailPanel">
|
|
<div class="detail-body" id="detailBody">
|
|
<button class="detail-close" onclick="closeDetail()">✕</button>
|
|
<div class="detail-handle" onclick="closeDetail()"></div>
|
|
<div id="detailContent"><p style="color:#999;">选择一个簇或数据点查看详情</p></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="static/tianxuan/three.min.js"></script>
|
|
<script>
|
|
// ═══════════════════════════════════════════════
|
|
// MOCK DATA
|
|
// ═══════════════════════════════════════════════
|
|
const MOCK_CLUSTERS = [
|
|
{ label:0, size:78, prop:0.39, silh:0.72, desc:'低延迟 (23ms),比全局均值高3.1σ;大数据包 (1520 bytes),高2.3σ;系统端口 (443)' },
|
|
{ label:1, size:65, prop:0.33, silh:0.68, desc:'高TLS1.3比例 (92%),比均值高1.8σ;短会话 (12s),低1.5σ;ECDHE曲线 X25519' },
|
|
{ label:2, size:45, prop:0.23, silh:0.54, desc:'高连接频率 (85次/min),比均值高4.2σ;非标准端口 (8080/8443),目标多域名' },
|
|
{ label:-1, size:12, prop:0.06, silh:null, desc:'噪声点 — 异常TLS指纹、零散目标、低流量' },
|
|
];
|
|
|
|
const COLORS = ['#4361ee','#f72585','#7209b7','#4cc9f0','#f8961e','#43aa8b','#f94144','#577590','#e9c46a','#9b5de5'];
|
|
function colorFor(lbl) { return lbl===-1?'rgba(150,150,150,0.7)':COLORS[Math.abs(lbl)%COLORS.length]; }
|
|
|
|
// Generate mock UMAP scatter data (200 points)
|
|
const SCATTER = [];
|
|
const RNG = (seed) => { let s=seed%2147483647; return ()=> { s=s*16807%2147483647; return (s-1)/2147483646; }; };
|
|
const rng = RNG(42);
|
|
const centers = {0:[2,1.5],1:[-1.8,0.5],2:[0.5,-2], '-1':[3.5,-1.5]};
|
|
const spreads = {0:[0.6,0.5],1:[0.7,0.6],2:[0.5,0.7], '-1':[1.2,0.8]};
|
|
for (const c of MOCK_CLUSTERS) {
|
|
for (let i=0;i<c.size;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+(rng()-0.5)*2*sx, y:cy+(rng()-0.5)*2*sy,
|
|
label:c.label, entity:`模拟主机 ${1000+i}`
|
|
});
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// SCATTER 2D CANVAS
|
|
// ═══════════════════════════════════════════════
|
|
let selCluster = null, selPoint = null;
|
|
let globeOpen = false;
|
|
|
|
function drawScatter(highlight) {
|
|
const c=document.getElementById('scatterChart');
|
|
if(!c)return;
|
|
const W=c.parentElement.clientWidth||700, H=360;
|
|
c.width=W*2; c.height=H*2; c.style.width=W+'px'; c.style.height=H+'px';
|
|
const ctx=c.getContext('2d'); ctx.scale(2,2);
|
|
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);
|
|
// grid
|
|
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();}
|
|
// points
|
|
SCATTER.forEach(d=>{
|
|
const cx=toX(d.x),cy=toY(d.y);
|
|
let radius=3,color=colorFor(d.label);
|
|
if(highlight!==null&&highlight!==undefined){
|
|
if(d.label===highlight){color=colorFor(d.label);radius=5;}
|
|
else{color='rgba(200,200,200,0.15)';radius=2;}
|
|
}
|
|
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle=color;ctx.fill();
|
|
});
|
|
// labels
|
|
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();
|
|
|
|
c.onclick=function(e){
|
|
const r=c.getBoundingClientRect();
|
|
const mx=e.clientX-r.left, my=e.clientY-r.top;
|
|
const sx=mx/W*pw, sy=my/H*ph;
|
|
let best=null,bestD=10;
|
|
for(const d of SCATTER){
|
|
const dx=toX(d.x)-mx, dy=toY(d.y)-my;
|
|
const dist=Math.sqrt(dx*dx+dy*dy);
|
|
if(dist<bestD){bestD=dist;best=d;}
|
|
}
|
|
if(best){selCluster=MOCK_CLUSTERS.find(c=>c.label===best.label);selPoint=best;showDetail(selCluster,selPoint);}
|
|
};
|
|
document.getElementById('ptCount').textContent=SCATTER.length+' 个点';
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// CLUSTER SELECTION
|
|
// ═══════════════════════════════════════════════
|
|
function selectCluster(lbl){
|
|
selCluster=MOCK_CLUSTERS.find(c=>c.label===lbl);
|
|
selPoint=null;
|
|
drawScatter(lbl);
|
|
document.querySelectorAll('.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,null);
|
|
}
|
|
function clearSel(){
|
|
selCluster=null;selPoint=null;
|
|
drawScatter(null);
|
|
document.querySelectorAll('.pill,.cluster-card').forEach(p=>p.classList.remove('active','selected'));
|
|
closeDetail();
|
|
if(globeOpen)globeFilter(null);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// DETAIL PANEL
|
|
// ═══════════════════════════════════════════════
|
|
function showDetail(cluster,point){
|
|
const dc=document.getElementById('detailContent');
|
|
let html='';
|
|
if(point){
|
|
html+='<h3>数据点详情</h3><table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody>'+
|
|
'<tr><td>标识</td><td><code>'+point.entity+'</code></td></tr>'+
|
|
'<tr><td>所属簇</td><td>#'+(point.label===-1?'噪声':point.label)+'</td></tr>'+
|
|
'<tr><td>UMAP-x</td><td>'+point.x.toFixed(4)+'</td></tr>'+
|
|
'<tr><td>UMAP-y</td><td>'+point.y.toFixed(4)+'</td></tr>'+
|
|
'</tbody></table>';
|
|
}else if(cluster){
|
|
html+='<h3>簇 #'+(cluster.label===-1?'噪声':cluster.label)+'</h3>'+
|
|
'<p>大小: '+cluster.size+' | 占比: '+(cluster.prop*100).toFixed(0)+'%'+
|
|
(cluster.silh!==null?' | 轮廓系数: '+cluster.silh.toFixed(4):'')+'</p>'+
|
|
'<p style="background:#f8f9fa;border-left:3px solid #4361ee;padding:0.5rem;margin:0.5rem 0;border-radius:0 4px 4px 0;font-size:0.85rem;line-height:1.6;">'+cluster.desc+'</p>'+
|
|
'<table><thead><tr><th>特征</th><th>区分度</th><th>均值</th><th>标准差</th></tr></thead><tbody>'+
|
|
'<tr><td>延迟 (4dur)</td><td style="color:#2e7d32;">+3.2</td><td>23ms</td><td>5.1</td></tr>'+
|
|
'<tr><td>数据包大小 (8ppk)</td><td style="color:#2e7d32;">+2.3</td><td>1520B</td><td>128</td></tr>'+
|
|
'<tr><td>TLS 版本 (0ver)</td><td style="color:#c62828;">-1.8</td><td>1.2</td><td>0.3</td></tr>'+
|
|
'</tbody></table>';
|
|
}
|
|
dc.innerHTML=html;
|
|
document.getElementById('detailPanel').classList.add('open');
|
|
}
|
|
function closeDetail(){document.getElementById('detailPanel').classList.remove('open');}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// GLOBE SIDEBAR
|
|
// ═══════════════════════════════════════════════
|
|
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&&!window._globeInit)initGlobe3D();
|
|
}
|
|
|
|
function globeFilter(label){
|
|
if(!window._arcMeshes)return;
|
|
window._arcMeshes.forEach(m=>{
|
|
if(label===null){m.material.opacity=0.5;m.material.color.setHex(parseInt(m.userData.color,16));}
|
|
else{m.material.opacity=0.06;m.material.color.setHex(0x666666);}
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// 3D GLOBE (Three.js, simplified)
|
|
// ═══════════════════════════════════════════════
|
|
function initGlobe3D(){
|
|
if(window._globeInit)return;
|
|
const cont=document.getElementById('globeContainer');
|
|
if(!cont)return;
|
|
const W=cont.clientWidth||450, 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
|
|
const earth=new THREE.Mesh(new THREE.SphereGeometry(2,48,48),new THREE.MeshPhongMaterial({color:0x1a3a5c,emissive:0x0a1a2a}));
|
|
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})));
|
|
// 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 s=new THREE.BufferGeometry();const p=new Float32Array(600*3);
|
|
for(let i=0;i<600*3;i++)p[i]=(Math.random()-0.5)*100;
|
|
s.setAttribute('position',new THREE.BufferAttribute(p,3));
|
|
scene.add(new THREE.Points(s,new THREE.PointsMaterial({color:0xffffff,size:0.15})));
|
|
|
|
// mock flows → arcs
|
|
const tlsC={'TLSv1.3':0x4cc9f0,'TLSv1.2':0x43aa8b,'TLSv1.1':0xf8961e,'TLSv1.0':0xf94144};
|
|
const arcGrp=new THREE.Group();scene.add(arcGrp);
|
|
const arcs=[];
|
|
function llz(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));}
|
|
|
|
// generate ~30 mock flows
|
|
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 tlsOpts=['TLSv1.3','TLSv1.2','TLSv1.3','TLSv1.2','TLSv1.1'];
|
|
for(let i=0;i<30;i++){
|
|
const src=cities[i%cities.length],dst=cities[(i+3)%cities.length];
|
|
const f=llz(src.lat,src.lon,2),t=llz(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=tlsOpts[i%tlsOpts.length];
|
|
const mat=new THREE.MeshBasicMaterial({color:tlsC[c],transparent:true,opacity:0.5});
|
|
const mesh=new THREE.Mesh(new THREE.TubeGeometry(curve,16,0.015,4,false),mat);
|
|
mesh.userData={color:tlsC[c].toString(16)};
|
|
arcGrp.add(mesh);arcs.push(mesh);
|
|
}
|
|
window._arcMeshes=arcs;
|
|
|
|
// drag
|
|
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;arcGrp.rotation.y=earth.rotation.y;arcGrp.rotation.x=earth.rotation.x;pm={x:e.clientX,y:e.clientY};};
|
|
|
|
!function anim(){requestAnimationFrame(anim);if(!isD){earth.rotation.y+=0.003;arcGrp.rotation.y=earth.rotation.y;}ren.render(scene,cam);}();
|
|
window._globeInit=true;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// RENDER PILLS & CARDS
|
|
// ═══════════════════════════════════════════════
|
|
(function init(){
|
|
const pr=document.getElementById('pillRow');
|
|
MOCK_CLUSTERS.forEach(c=>{
|
|
if(c.label===-1)return;
|
|
const p=document.createElement('span');p.className='pill';p.style.background=colorFor(c.label);
|
|
p.dataset.lbl=c.label;p.textContent='#'+c.label+' ('+c.size+')';p.onclick=()=>selectCluster(c.label);
|
|
pr.appendChild(p);
|
|
});
|
|
// noise pill
|
|
const np=document.createElement('span');np.className='pill';np.style.background='#999';
|
|
np.dataset.lbl='-1';np.textContent='噪声 ('+MOCK_CLUSTERS.find(c=>c.label===-1).size+')';np.onclick=()=>selectCluster(-1);
|
|
pr.appendChild(np);
|
|
|
|
// cards
|
|
const cg=document.getElementById('cardGrid');
|
|
MOCK_CLUSTERS.forEach(c=>{
|
|
if(c.label===-1)return;
|
|
const card=document.createElement('div');card.className='cluster-card';card.dataset.label=c.label;
|
|
card.innerHTML='<div class="label">簇 #'+c.label+'</div>'+
|
|
'<div class="meta">'+c.size+' 个实体 · '+(c.prop*100).toFixed(0)+'%'+(c.silh!==null?' · 轮廓系数: '+c.silh.toFixed(4):'')+'</div>'+
|
|
'<div class="desc">'+c.desc+'</div>';
|
|
card.onclick=()=>selectCluster(c.label);
|
|
cg.appendChild(card);
|
|
});
|
|
|
|
drawScatter(null);
|
|
// set initial handle pos
|
|
document.getElementById('globeHandle').style.left='calc(100% - 20px)';
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|