Files
tianxuan/templates/analysis/cluster_overview.html
T

414 lines
22 KiB
HTML

{% extends 'base.html' %}
{% load static %}
{% block title %}聚类概览 — 运行 #{{ run.display_id }}{% endblock %}
{% block content %}
<style>
/* ── 布局 ── */
.main-layout { display:flex; gap:0; transition:all 0.3s; position:relative; }
.left-panel { flex:1; min-width:0; transition:flex 0.3s; }
.left-panel.globe-open { flex:0 0 55%; }
.right-panel { flex:0 0 0; overflow:hidden; transition:flex 0.3s; border-left:1px solid #e0e0e0; }
.right-panel.globe-open { flex:0 0 45%; }
.globe-toggle-handle { position:absolute; z-index:10; top:50%; left:calc(100% - 24px); transform:translateY(-50%); width:24px; height:48px; background:#1a1a2e; color:#fff; border:none; border-radius:6px 0 0 6px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:14px; transition:left 0.3s, background 0.15s; }
.globe-toggle-handle:hover { background:#4361ee; }
.globe-toggle-handle.open { left:calc(55% - 24px); border-radius:6px 0 0 6px; }
/* ── Scatter ── */
.scatter-wrapper { position:relative; cursor:crosshair; }
.scatter-wrapper canvas { width:100%; height:400px; display:block; }
#scatter3D { width:100%; height:450px; }
/* ── Detail Panel ── */
.detail-overlay { 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-overlay.open { transform:translateY(0); }
.detail-content { background:#fff; border-radius:16px 16px 0 0; box-shadow:0 -4px 24px rgba(0,0,0,0.15); max-height:45vh; overflow-y:auto; padding:1rem 2rem 2rem; }
.detail-handle { width:40px; height:4px; background:#ccc; border-radius:2px; margin:0.5rem auto; cursor:pointer; }
.detail-handle:hover { background:#999; }
.detail-close { float:right; cursor:pointer; font-size:1.5rem; color:#999; line-height:1; }
.detail-close:hover { color:#333; }
/* ── Globe ── */
#globeContainer { width:100%; height:calc(100vh - 120px); position:sticky; top:0; }
/* ── Legend pills ── */
.legend-pills { display:flex; flex-wrap:wrap; gap:4px 8px; padding:6px 0; }
.pill { display:inline-flex; align-items:center; gap:4px; padding:2px 10px; border-radius:12px; font-size:0.75rem; cursor:pointer; border:2px solid transparent; transition:all 0.15s; white-space:nowrap; }
.pill:hover { opacity:0.8; }
.pill.active { border-color:#333; font-weight:600; }
.cluster-card { border:1px solid #e0e0e0; border-radius:8px; padding:1rem; background:#fff; 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 #4361ee33; }
.nl-summary { background:#f8f9fa; border-left:3px solid #4361ee; padding:0.6rem; margin:0.5rem 0; border-radius:0 4px 4px 0; font-size:0.85rem; line-height:1.6; color:#333; }
.feat-positive { color:#2e7d32; }
.feat-negative { color:#c62828; }
</style>
<script src="{% static 'tianxuan/three.min.js' %}"></script>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;">
<div>
<h2 style="margin:0;">聚类概览</h2>
<p style="margin:0.25rem 0 0;color:#666;font-size:0.9rem;">
运行 #{{ run.display_id }} — {{ run.total_flows }} 条流, {{ run.cluster_count }} 个簇
{% if noise %}, {{ noise.size }} 个噪声点{% endif %}
</p>
</div>
<div style="display:flex;gap:0.5rem;">
<a href="{% url 'analysis:run_detail' run.display_id %}" class="btn btn-sm">← 运行详情</a>
</div>
</div>
</div>
<div class="main-layout">
<div class="left-panel" id="leftPanel">
<!-- ── UMAP 2D / 3D ── -->
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;">
<h3 style="margin:0;">UMAP 嵌入 <span style="font-weight:400;font-size:0.8rem;color:#888;" id="pointCount">计算中...</span></h3>
<span id="viewToggle" style="display:none;">
<button id="btn2D" class="btn btn-sm" style="font-weight:bold;padding:2px 10px;">2D</button>
<button id="btn3D" class="btn btn-sm btn-outline" style="padding:2px 10px;">3D</button>
</span>
</div>
<div class="scatter-wrapper" style="margin-top:0.5rem;">
<canvas id="scatterChart" style="width:100%;height:400px;"></canvas>
<div id="scatterLegend" class="legend-pills"></div>
</div>
<div id="scatter3DContainer" style="display:none;">
<div id="scatter3D"></div>
<div id="scatter3DLegend" class="legend-pills"></div>
</div>
</div>
<!-- ── Noise Analysis ── -->
{% if noise %}
<div class="card" style="background:#fff8e1;border-color:#ffe082;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3>⚠️ 噪声点 <span style="font-weight:400;font-size:0.8rem;">{{ noise.size }} 个实体</span></h3>
<a href="{% url 'analysis:cluster_detail' run.display_id -1 %}" class="btn btn-sm">详情 →</a>
</div>
{% if noise_summary %}<div class="nl-summary" style="border-left-color:#e65100;background:#fff3e0;">{{ noise_summary }}</div>{% endif %}
</div>
{% endif %}
<!-- ── Cluster Grid ── -->
<div class="legend-pills" style="margin-bottom:0.5rem;">
<span class="pill" style="background:#e8e8e8;" onclick="clearClusterFilter()">全部</span>
{% for c in clusters %}
<span class="pill" id="pill-{{ c.cluster_label }}" data-label="{{ c.cluster_label }}" onclick="selectCluster({{ c.cluster_label }})">#{{ c.cluster_label }} ({{ c.size }})</span>
{% endfor %}
{% if noise %}<span class="pill" style="background:#999;color:#fff;" onclick="selectCluster(-1)">Noise ({{ noise.size }})</span>{% endif %}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
{% for c in clusters %}
<div class="cluster-card" data-label="{{ c.cluster_label }}" onclick="selectCluster({{ c.cluster_label }})">
<div style="font-weight:700;font-size:1rem;">簇 #{{ c.cluster_label }}</div>
<div style="font-size:0.8rem;color:#666;margin-top:0.2rem;">{{ c.size }} 个实体{% if c.proportion %} · {{ c.proportion|floatformat:1 }}%{% endif %}</div>
{% if c.nl_summary %}<div class="nl-summary">{{ c.nl_summary }}</div>{% endif %}
</div>
{% empty %}
<div class="card"><div class="empty-state"><p>No clusters</p></div></div>
{% endfor %}
</div>
{% if geo_count > 0 %}
<div class="card" style="margin-top:1rem;">
<h3>🌍 地理分布</h3>
<div class="scatter-wrapper"><canvas id="geoChart" style="width:100%;height:300px;"></canvas><div id="geoLegend" class="legend-pills"></div></div>
</div>
{% endif %}
</div><!-- /left-panel -->
<!-- ── Right Panel: Globe ── -->
<div class="right-panel" id="rightPanel">
<iframe id="globeIframe" src="/globe/?embed=1&data={{ selected_ds_id|default:'' }}" style="width:100%;height:100%;border:none;"></iframe>
</div>
<button class="globe-toggle-handle" id="globeHandle" onclick="toggleGlobe()"></button>
</div><!-- /main-layout -->
<!-- ── Bottom Detail Panel ── -->
<div class="detail-overlay" id="detailPanel">
<div class="detail-content">
<div class="detail-handle" onclick="closeDetail()"></div>
<span class="detail-close" onclick="closeDetail()"></span>
<div id="detailBody"></div>
</div>
</div>
<script>
const scatterData = {{ scatter_data_json|safe }};
const scatterCount = scatterData.length;
document.getElementById('pointCount').textContent = scatterCount + ' 个点';
const hasZ = {{ has_z|yesno:"true,false" }};
const colors = ['#4361ee','#f72585','#7209b7','#4cc9f0','#f8961e','#43aa8b','#f94144','#577590','#e9c46a','#9b5de5',
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2','#7f7f7f','#bcbd22','#17becf',
'#aec7e8','#ffbb78','#98df8a','#ff9896','#c5b0d5','#c49c94','#f7b6d2','#c7c7c7','#dbdb8d','#9edae5',
'#393b79','#637939','#8c6d31','#843c39','#7b4173','#5254a3','#8ca252','#bd9e39','#ad494a','#a55194'];
let selectedCluster = null, selectedPoint = null;
let globeOpen = false;
const clusterSizes = {{ cluster_sizes_json|safe }};
// Color pills after DOM ready
(function() {
clusterSizes.forEach(function(c) {
var el = document.getElementById('pill-' + c.label);
if (el) { el.style.background = colorFor(c.label); el.style.color = '#fff'; }
});
var noisePill = document.querySelector('.pill[data-label="-1"]');
if (noisePill) { noisePill.style.background = '#999'; noisePill.style.color = '#fff'; }
})();
function colorFor(label) { return label === -1 ? 'rgba(150,150,150,0.8)' : colors[Math.abs(label) % colors.length]; }
// ══════════════════════════════════════════════════════════
// SCATTER — 2D Canvas with click interaction
// ══════════════════════════════════════════════════════════
function drawScatter(canvasId, data, xLabel, yLabel, highlightLabel) {
const canvas = document.getElementById(canvasId);
if (!canvas || data.length === 0) return;
const rect = canvas.parentElement.getBoundingClientRect();
const W = rect.width || 600, H = 400;
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const pad = { top: 30, bottom: 45, left: 60, right: 30 };
const pw = W - pad.left - pad.right, ph = H - pad.top - pad.bottom;
const xs = data.map(d => d.x), ys = data.map(d => d.y);
const xMin = Math.min(...xs), xMax = Math.max(...xs);
const 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;
const toY = v => pad.top + ph - (v - yMin) / yR * ph;
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) { const y = pad.top + i * ph / 5; ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W - pad.right, y); ctx.stroke(); }
ctx.fillStyle = '#999'; ctx.font = '12px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(xLabel, W / 2, H - 3);
ctx.save(); ctx.translate(16, H / 2); ctx.rotate(-Math.PI / 2); ctx.fillText(yLabel, 0, 0); ctx.restore();
data.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 radius = d.label === -1 ? 2 : 4;
if (highlightLabel !== null && highlightLabel !== undefined) {
if (d.label === highlightLabel) { color = d.label === -1 ? 'rgba(150,150,150,0.9)' : colors[Math.abs(d.label) % colors.length]; radius = 6; }
else { color = 'rgba(200,200,200,0.2)'; radius = 2; }
}
ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = color; ctx.fill();
});
// Click handler
canvas.onclick = function(e) {
const rr = canvas.getBoundingClientRect();
const mx = e.clientX - rr.left, my = e.clientY - rr.top;
// Find closest point within 8px
let best = null, bestDist = 8;
for (let i = 0; i < data.length; i++) {
const d = data[i];
const dx = toX(d.x) - mx, dy = toY(d.y) - my;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestDist) { bestDist = dist; best = d; }
}
if (best) {
if (best.label === -1) selectCluster(-1);
else selectCluster(best.label);
showEntityDetail(best);
}
};
const legendEl = document.getElementById(canvasId.replace('Chart', 'Legend'));
if (legendEl) {
legendEl.innerHTML = '';
var seen = {};
data.forEach(d => { if (!seen[d.label]) { seen[d.label] = true; legendEl.innerHTML += '<span class="pill" style="background:' + colorFor(d.label) + ';color:#fff;cursor:pointer;" onclick="selectCluster(' + d.label + ')">' + (d.label === -1 ? 'Noise' : '#' + d.label) + '</span>'; } });
}
}
function showEntityDetail(point) {
const body = document.getElementById('detailBody');
body.innerHTML = '<h3>实体: ' + point.entity + '</h3>' +
'<p>所属簇: ' + (point.label === -1 ? '噪声' : '#' + point.label) + '</p>' +
'<p>UMAP 坐标: (' + point.x.toFixed(4) + ', ' + point.y.toFixed(4) + ')</p>' +
'<a href="?entity=' + encodeURIComponent(point.entity) + '" class="btn btn-sm btn-primary">查看画像</a>';
document.getElementById('detailPanel').classList.add('open');
}
// ══════════════════════════════════════════════════════════
// CLUSTER SELECTION
// ══════════════════════════════════════════════════════════
function selectCluster(label) {
selectedCluster = label;
selectedPoint = null;
// Highlight cards
document.querySelectorAll('.cluster-card').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.label) === label));
// Highlight scatter
drawScatter('scatterChart', scatterData, 'UMAP-1', 'UMAP-2', label);
// Focus legend pills
document.querySelectorAll('.pill').forEach(el => el.classList.toggle('active', parseInt(el.dataset.label) === label));
// Show cluster detail in bottom panel
const clusterInfo = clusterSizes.find(c => c.label === label);
if (clusterInfo) {
const body = document.getElementById('detailBody');
body.innerHTML = '<h3>簇 #' + label + '</h3>' +
'<p>大小: ' + clusterInfo.size + ' | 轮廓系数: ' + (clusterInfo.silhouette != null ? clusterInfo.silhouette.toFixed(4) : '-') + '</p>' +
'<a href="/clusters/{{ run.display_id }}/' + label + '/" class="btn btn-sm btn-primary">详细分析 →</a>';
document.getElementById('detailPanel').classList.add('open');
}
// Gray out globe if open
if (globeOpen) {
var ifr = document.getElementById('globeIframe');
if (ifr && ifr.contentWindow) ifr.contentWindow.postMessage({ type: 'globe_filter', cluster_label: label }, '*');
}
}
function clearClusterFilter() {
selectedCluster = null;
drawScatter('scatterChart', scatterData, 'UMAP-1', 'UMAP-2', null);
document.querySelectorAll('.cluster-card').forEach(el => el.classList.remove('selected'));
document.querySelectorAll('.pill').forEach(el => el.classList.remove('active'));
document.getElementById('detailPanel').classList.remove('open');
if (globeOpen) {
var ifr = document.getElementById('globeIframe');
if (ifr && ifr.contentWindow) ifr.contentWindow.postMessage({ type: 'globe_filter', cluster_label: null }, '*');
}
}
function closeDetail() { document.getElementById('detailPanel').classList.remove('open'); }
function toggleGlobe() {
globeOpen = !globeOpen;
document.getElementById('leftPanel').classList.toggle('globe-open', globeOpen);
document.getElementById('rightPanel').classList.toggle('globe-open', globeOpen);
var handle = document.getElementById('globeHandle');
handle.innerHTML = globeOpen ? '▶' : '◀';
handle.classList.toggle('open', globeOpen);
}
// ══════════════════════════════════════════════════════════
// 3D Three.js SCATTER (for 3D UMAP toggle)
// ══════════════════════════════════════════════════════════
var scatter3DRenderer = null, scatter3DScene = null, scatter3DCamera = null, scatter3DPoints = null;
var scatter3DInitialized = false, is3DMode = false, scatterData3D = scatterData;
function hasWebGL2() { try { var c = document.createElement('canvas'); return !!c.getContext('webgl2'); } catch(e) { return false; } }
(function() {
var supports3D = hasWebGL2();
if (supports3D && hasZ) document.getElementById('viewToggle').style.display = 'inline';
var btn2D = document.getElementById('btn2D'), btn3D = document.getElementById('btn3D');
var c2d = document.querySelector('#scatterChart').parentElement;
var c3d = document.getElementById('scatter3DContainer');
function show2D() {
is3DMode = false; c2d.style.display = ''; c3d.style.display = 'none';
btn2D.style.fontWeight = 'bold'; btn2D.className = 'btn btn-sm';
btn3D.style.fontWeight = 'normal'; btn3D.className = 'btn btn-sm btn-outline';
drawScatter('scatterChart', scatterData, 'UMAP-1', 'UMAP-2', selectedCluster);
}
function show3D() {
is3DMode = true; c2d.style.display = 'none'; c3d.style.display = '';
btn3D.style.fontWeight = 'bold'; btn3D.className = 'btn btn-sm';
btn2D.style.fontWeight = 'normal'; btn2D.className = 'btn btn-sm btn-outline';
if (!scatter3DInitialized) initScatter3D();
}
btn2D.addEventListener('click', show2D);
btn3D.addEventListener('click', show3D);
window.addEventListener('resize', function() {
if (!scatter3DRenderer || !is3DMode) return;
var cont = document.getElementById('scatter3D');
if (!cont) return;
var W = cont.clientWidth || 600;
scatter3DRenderer.setSize(W, 450);
scatter3DCamera.aspect = W / 450;
scatter3DCamera.updateProjectionMatrix();
});
})();
function initScatter3D() {
if (scatter3DInitialized) return;
var container = document.getElementById('scatter3D');
if (!container || scatterData.length === 0) return;
var W = container.clientWidth || 600, H = 450;
var xs = scatterData.map(d => d.x), ys = scatterData.map(d => d.y), zs = scatterData.map(d => d.z || 0);
var xMin = Math.min(...xs), xMax = Math.max(...xs), yMin = Math.min(...ys), yMax = Math.max(...ys), zMin = Math.min(...zs), zMax = Math.max(...zs);
var cx = (xMin + xMax) / 2, cy = (yMin + yMax) / 2, cz = (zMin + zMax) / 2;
var range = Math.max(xMax - xMin, yMax - yMin, zMax - zMin) || 1;
function norm(v, c, r) { return ((v - c) / r) * 3; }
var scene = new THREE.Scene(); scene.background = new THREE.Color(0xf8f9fa);
var camera = new THREE.PerspectiveCamera(50, W / H, 0.1, 50); camera.position.set(4.5, 3.5, 6); camera.lookAt(0, 0, 0);
var renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(W, H); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
scatter3DRenderer = renderer; scatter3DScene = scene; scatter3DCamera = camera;
var positions = new Float32Array(scatterData.length * 3);
var pointColors = new Float32Array(scatterData.length * 3);
for (var i = 0; i < scatterData.length; i++) {
var d = scatterData[i];
positions[i*3] = norm(d.x, cx, range); positions[i*3+1] = norm(d.y, cy, range); positions[i*3+2] = d.z != null ? norm(d.z, cz, range) : 0;
var c3 = new THREE.Color(d.label === -1 ? '#999999' : colors[Math.abs(d.label) % colors.length]);
pointColors[i*3] = c3.r; pointColors[i*3+1] = c3.g; pointColors[i*3+2] = c3.b;
}
var geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(pointColors, 3));
var mat = new THREE.PointsMaterial({ size: 0.15, vertexColors: true, sizeAttenuation: true, depthWrite: true, transparent: true, opacity: 0.85 });
var points = new THREE.Points(geo, mat);
scene.add(points);
scatter3DPoints = points;
var isDragging = false, prevMouse = { x: 0, y: 0 };
renderer.domElement.addEventListener('mousedown', function(e) { isDragging = true; prevMouse = { x: e.clientX, y: e.clientY }; });
window.addEventListener('mouseup', function() { isDragging = false; });
window.addEventListener('mousemove', function(e) {
if (!isDragging || !is3DMode) return;
var dx = e.clientX - prevMouse.x, dy = e.clientY - prevMouse.y;
points.rotation.y += dx * 0.005; points.rotation.x += dy * 0.005;
points.rotation.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, points.rotation.x));
prevMouse = { x: e.clientX, y: e.clientY };
});
renderer.domElement.addEventListener('wheel', function(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)); }, { passive: false });
function animate() { if (!is3DMode) { requestAnimationFrame(animate); return; } requestAnimationFrame(animate); renderer.render(scene, camera); }
animate();
scatter3DInitialized = true;
}
// ══════════════════════════════════════════════════════════
// GEO SCATTER
// ══════════════════════════════════════════════════════════
{% if geo_count > 0 %}
const geoData = {{ geo_data_json|safe }};
(function() {
const canvas = document.getElementById('geoChart');
if (!canvas) return;
const W = canvas.parentElement.clientWidth || 600, H = 300;
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const pad = { top: 20, bottom: 30, left: 50, right: 20 };
const pw = W - pad.left - pad.right, ph = H - pad.top - pad.bottom;
const xs = geoData.map(d => d.x), ys = geoData.map(d => d.y);
const xMin = Math.min(...xs), xMax = Math.max(...xs), yMin = Math.min(...ys), yMax = Math.max(...ys);
const toX = v => pad.left + (v - xMin) / (xMax - xMin || 1) * pw;
const toY = v => pad.top + ph - (v - yMin) / (yMax - yMin || 1) * ph;
ctx.clearRect(0, 0, W, H);
geoData.forEach(d => {
ctx.beginPath(); ctx.arc(toX(d.x), toY(d.y), d.label === -1 ? 2 : 4, 0, Math.PI * 2);
ctx.fillStyle = colorFor(d.label); ctx.fill();
});
var leg = document.getElementById('geoLegend');
if (leg) { leg.innerHTML = ''; var seen = {}; geoData.forEach(d => { if (!seen[d.label]) { seen[d.label] = true; leg.innerHTML += '<span class="pill" style="background:' + colorFor(d.label) + ';color:#fff;">' + (d.label === -1?'Noise':'#'+d.label) + '</span>'; } }); }
})();
{% endif %}
// Initial draw
drawScatter('scatterChart', scatterData, 'UMAP-1', 'UMAP-2', null);
</script>
{% endblock %}