527 lines
28 KiB
HTML
527 lines
28 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; }
|
|
.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; position:relative; }
|
|
.right-panel.globe-open { flex:0 0 45%; }
|
|
.globe-toggle-handle { position:absolute; left:-24px; top:50%; 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; z-index:10; transition:background 0.15s; }
|
|
.globe-toggle-handle:hover { background:#4361ee; }
|
|
.globe-toggle-handle.open { left:0; border-radius:0; }
|
|
/* ── 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;"><script>document.write(scatterCount)</script> 个点</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">
|
|
<button class="globe-toggle-handle" id="globeHandle" onclick="toggleGlobe()">◀</button>
|
|
<div id="globeContainer"></div>
|
|
</div>
|
|
</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;
|
|
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, globeInitialized = false, globeRenderer = null;
|
|
let globeFlows = {{ globe_flows_json|safe }};
|
|
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 && window.setGlobeFilter) window.setGlobeFilter(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 && window.setGlobeFilter) window.setGlobeFilter(null);
|
|
}
|
|
|
|
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);
|
|
var handle = document.getElementById('globeHandle');
|
|
handle.innerHTML = globeOpen ? '▶' : '◀';
|
|
handle.classList.toggle('open', globeOpen);
|
|
if (globeOpen && !globeInitialized) initGlobe();
|
|
if (globeOpen) setTimeout(function() { if (globeRenderer) globeRenderer.setSize(document.getElementById('globeContainer').clientWidth, document.getElementById('globeContainer').clientHeight); }, 350);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════
|
|
// 3D THREE.JS GLOBE
|
|
// ══════════════════════════════════════════════════════════
|
|
|
|
function initGlobe() {
|
|
if (globeInitialized) return;
|
|
const container = document.getElementById('globeContainer');
|
|
if (!container) return;
|
|
const W = container.clientWidth || 500, H = container.clientHeight || 500;
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x0a0a1a);
|
|
|
|
const camera = new THREE.PerspectiveCamera(45, W / H, 0.1, 100);
|
|
camera.position.set(0, 0, 5.5);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setSize(W, H);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
container.appendChild(renderer.domElement);
|
|
globeRenderer = renderer;
|
|
|
|
// Earth sphere
|
|
const earthGeo = new THREE.SphereGeometry(1.8, 64, 64);
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = 1; canvas.height = 1;
|
|
var ectx = canvas.getContext('2d');
|
|
ectx.fillStyle = '#1a3a5c'; ectx.fillRect(0, 0, 1, 1);
|
|
var tex = new THREE.CanvasTexture(canvas);
|
|
const earthMat = new THREE.MeshPhongMaterial({ map: tex, transparent: true, opacity: 0.9 });
|
|
const earth = new THREE.Mesh(earthGeo, earthMat);
|
|
scene.add(earth);
|
|
|
|
// Atmosphere glow
|
|
const glowGeo = new THREE.SphereGeometry(1.85, 64, 64);
|
|
const glowMat = new THREE.MeshBasicMaterial({ color: 0x224488, transparent: true, opacity: 0.15 });
|
|
scene.add(new THREE.Mesh(glowGeo, glowMat));
|
|
|
|
// Lights
|
|
scene.add(new THREE.AmbientLight(0x404060));
|
|
const sun = new THREE.DirectionalLight(0xffffff, 1);
|
|
sun.position.set(5, 3, 5);
|
|
scene.add(sun);
|
|
scene.add(new THREE.DirectionalLight(0x4488ff, 0.3).position.set(-5, -3, -5));
|
|
|
|
// Arc group
|
|
const arcGroup = new THREE.Group();
|
|
scene.add(arcGroup);
|
|
|
|
// Build arcs from flow data
|
|
const tlsColors = { 'TLSv1.3': 0x4cc9f0, 'TLSv1.2': 0x43aa8b, 'TLSv1.1': 0xf8961e, 'TLSv1.0': 0xf94144 };
|
|
var arcMeshes = [];
|
|
|
|
globeFlows.forEach(function(flow) {
|
|
const from = latLonToVec3(flow.slat, flow.slon, 1.8);
|
|
const to = latLonToVec3(flow.dlat, flow.dlon, 1.8);
|
|
if (!from || !to) return;
|
|
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5).normalize().multiplyScalar(2.6);
|
|
const curve = new THREE.QuadraticBezierCurve3(from, mid, to);
|
|
const tubeGeo = new THREE.TubeGeometry(curve, 20, 0.008, 4, false);
|
|
const color = tlsColors[flow.tls] || 0x888888;
|
|
const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.5 });
|
|
const mesh = new THREE.Mesh(tubeGeo, mat);
|
|
mesh.userData = flow;
|
|
arcGroup.add(mesh);
|
|
arcMeshes.push(mesh);
|
|
});
|
|
|
|
// Stars
|
|
const starGeo = new THREE.BufferGeometry();
|
|
const starPos = new Float32Array(2000 * 3);
|
|
for (let i = 0; i < 2000 * 3; i++) starPos[i] = (Math.random() - 0.5) * 100;
|
|
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
|
|
scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.05 })));
|
|
|
|
// Mouse rotation
|
|
let 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) return;
|
|
const dx = e.clientX - prevMouse.x, dy = e.clientY - prevMouse.y;
|
|
earth.rotation.y += dx * 0.005;
|
|
earth.rotation.x += dy * 0.005;
|
|
arcGroup.rotation.y = earth.rotation.y;
|
|
arcGroup.rotation.x = earth.rotation.x;
|
|
prevMouse = { x: e.clientX, y: e.clientY };
|
|
});
|
|
|
|
// Globe filter function (called when cluster selected)
|
|
window.setGlobeFilter = function(clusterLabel) {
|
|
arcMeshes.forEach(function(mesh) {
|
|
if (clusterLabel === null) { mesh.material.opacity = 0.5; mesh.material.color.setHex(tlsColors[mesh.userData.tls] || 0x888888); }
|
|
else { mesh.material.opacity = 0.08; mesh.material.color.setHex(0x666666); }
|
|
});
|
|
};
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
// Auto-rotate when not dragging
|
|
if (!isDragging) { earth.rotation.y += 0.002; arcGroup.rotation.y = earth.rotation.y; }
|
|
renderer.render(scene, camera);
|
|
}
|
|
animate();
|
|
globeInitialized = true;
|
|
}
|
|
|
|
function latLonToVec3(lat, lon, r) {
|
|
if (lat == null || lon == null) return null;
|
|
const phi = (90 - lat) * Math.PI / 180;
|
|
const theta = (lon + 180) * Math.PI / 180;
|
|
return new THREE.Vector3(-r * Math.sin(phi) * Math.cos(theta), r * Math.cos(phi), r * Math.sin(phi) * Math.sin(theta));
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════
|
|
// 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 %} |