feat(run_detail): global SVD feature analysis + LLM workflow timeline

This commit is contained in:
PM-pinou
2026-07-24 11:51:23 +08:00
parent 5280ce5819
commit 2c1aad6bd1
6 changed files with 370 additions and 149 deletions
+1
View File
@@ -114,6 +114,7 @@ def cluster_overview(request, display_id):
'llm_timeline_json': llm_timeline_json,
# Globe flow data for the sidebar
'globe_flows_json': json.dumps(_get_globe_flows(run)),
'selected_ds_id': f'upload_{run.display_id}',
})
+68
View File
@@ -33,11 +33,79 @@ def run_list(request):
def run_detail(request, display_id):
"""Details for a single analysis run."""
import json
run = get_object_or_404(AnalysisRun, display_id=display_id)
clusters = run.clusters.all().order_by('-size')
# ── Global SVD features (compute on-demand from the stored run data) ──
svd_features = []
svd_explained_var = []
try:
from analysis.data_loader import load_csv_directory, load_from_db
import polars as pl
from sklearn.decomposition import TruncatedSVD
import numpy as np
import logging
logger = logging.getLogger(__name__)
lf = None
if run.sqlite_table:
lf = load_from_db(run.sqlite_table)
if lf is None and run.csv_glob:
lf, _, _, _, _ = load_csv_directory(run.csv_glob)
if lf is not None:
schema = lf.collect_schema()
numeric_types = (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
pl.Float32, pl.Float64)
num_cols = [n for n, dt in zip(schema.names(), schema.dtypes()) if dt in numeric_types and not n.startswith('_')]
if len(num_cols) >= 3:
n_rows_lf = lf.select(pl.len()).collect(streaming=True).item()
sample = min(int(n_rows_lf), 50000)
df = lf.sample(n=sample, seed=42).select(num_cols).collect(streaming=True) if n_rows_lf > sample else lf.select(num_cols).collect(streaming=True)
mat = np.nan_to_num(df.to_numpy(), nan=0.0)
from sklearn.preprocessing import StandardScaler
mat = StandardScaler().fit_transform(mat)
n_comp = min(20, min(mat.shape) - 1)
if n_comp >= 2:
svd = TruncatedSVD(n_components=n_comp, random_state=42)
svd.fit(mat)
var_ratio = svd.explained_variance_ratio_.tolist()
svd_explained_var = var_ratio
for comp_idx in range(min(10, n_comp)):
loadings = np.abs(svd.components_[comp_idx])
top_idx = np.argsort(loadings)[-5:][::-1]
features = [{'feature': num_cols[i], 'strength': round(float(loadings[i]), 4)} for i in top_idx]
svd_features.append({
'component': comp_idx + 1,
'explained_var': round(var_ratio[comp_idx] * 100, 2),
'cumulative_var': round(sum(var_ratio[:comp_idx + 1]) * 100, 2),
'top_features': features,
})
except Exception as exc:
logger = logging.getLogger(__name__)
logger.warning(f'SVD feature extraction failed for run {run.display_id}: {exc}')
# ── LLM workflow timeline ──
llm_timeline_json = ''
if run.run_type == 'auto':
llm_thinking = run.llm_thinking or ''
tool_calls = run.tool_calls_json or []
if tool_calls or llm_thinking:
llm_timeline_json = json.dumps({
'thinking': llm_thinking,
'tool_calls': tool_calls,
})
return render(request, 'analysis/run_detail.html', {
'run': run,
'clusters': clusters,
'svd_features': svd_features,
'svd_explained_var': svd_explained_var,
'llm_timeline_json': llm_timeline_json,
})
+4 -1
View File
@@ -122,6 +122,9 @@ def globe_view(request):
# Check for ?data=dataset_id parameter (direct data loading from session store)
data_param = request.GET.get('data')
embed_mode = request.GET.get('embed') == '1'
template_name = 'tianxuan/globe_embed.html' if embed_mode else 'tianxuan/globe.html'
if data_param:
store = SessionStore()
flows = []
@@ -134,7 +137,7 @@ def globe_view(request):
flows = _extract_flows_from_df(df, MAX_ROWS_PER_RUN)
except Exception as exc:
logging.getLogger(__name__).warning(f'globe ?data= param failed: {exc}')
return render(request, 'tianxuan/globe.html', {
return render(request, template_name, {
'geo_flows': json.dumps(flows),
'all_runs': all_runs,
'selected_ids': [],
+16 -131
View File
@@ -1,17 +1,17 @@
{% extends 'base.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; }
.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; position:relative; }
.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; 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 { 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:0 6px 6px 0; 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:0; border-radius:0; }
.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; }
@@ -120,9 +120,9 @@
<!-- ── Right Panel: Globe ── -->
<div class="right-panel" id="rightPanel">
<button class="globe-toggle-handle" id="globeHandle" onclick="toggleGlobe()"></button>
<div id="globeContainer"></div>
<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 ── -->
@@ -144,8 +144,7 @@ const colors = ['#4361ee','#f72585','#7209b7','#4cc9f0','#f8961e','#43aa8b','#f9
'#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 }};
let globeOpen = false;
const clusterSizes = {{ cluster_sizes_json|safe }};
// Color pills after DOM ready
@@ -258,7 +257,10 @@ function selectCluster(label) {
document.getElementById('detailPanel').classList.add('open');
}
// Gray out globe if open
if (globeOpen && window.setGlobeFilter) window.setGlobeFilter(label);
if (globeOpen) {
var ifr = document.getElementById('globeIframe');
if (ifr && ifr.contentWindow) ifr.contentWindow.postMessage({ type: 'globe_filter', cluster_label: label }, '*');
}
}
function clearClusterFilter() {
@@ -267,15 +269,14 @@ function clearClusterFilter() {
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);
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'); }
// ══════════════════════════════════════════════════════════
// GLOBE SIDEBAR
// ══════════════════════════════════════════════════════════
function toggleGlobe() {
globeOpen = !globeOpen;
document.getElementById('leftPanel').classList.toggle('globe-open', globeOpen);
@@ -283,122 +284,6 @@ function toggleGlobe() {
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));
}
// ══════════════════════════════════════════════════════════
+138 -15
View File
@@ -1,38 +1,91 @@
{% extends 'base.html' %}
{% block title %}Run #{{ run.display_id }} - TLS Analyzer{% endblock %}
{% block title %}运行 #{{ run.display_id }} - 天璇{% endblock %}
{% block content %}
<style>
.svd-card { background:#f8f9fa; border:1px solid #e0e0e0; border-radius:8px; padding:0.8rem; }
.svd-card h4 { margin:0 0 0.3rem; font-size:0.9rem; }
.var-bar { height:8px; border-radius:4px; background:#4361ee; margin:0.2rem 0; transition:width 0.3s; }
.feat-tag { display:inline-block; background:#e8f0fe; padding:0.1rem 0.4rem; border-radius:3px; font-size:0.75rem; margin:0.1rem; }
</style>
<div class="card">
<h2>Run #{{ run.display_id }} — {{ run.created_at|date:"Y-m-d H:i" }}</h2>
<p>Status: <span class="badge {% if run.status == 'completed' %}badge-success{% elif run.status == 'failed' %}badge-danger{% else %}badge-info{% endif %}">{{ run.get_status_display }}</span></p>
<p>CSV: <code>{% if run.sqlite_table %}已存储至SQLite: {{ run.sqlite_table }}{% else %}{{ run.csv_glob }}{% endif %}</code></p>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;">
<div>
<h2 style="margin:0;">运行 #{{ run.display_id }}</h2>
<p style="margin:0.25rem 0 0;color:#666;font-size:0.9rem;">
{{ run.created_at|date:"Y-m-d H:i" }}
— 状态: <span class="badge {% if run.status == 'completed' %}badge-success{% elif run.status == 'failed' %}badge-danger{% else %}badge-info{% endif %}">{{ run.get_status_display }}</span>
</p>
</div>
<a href="{% url 'analysis:cluster_overview' run.display_id %}" class="btn btn-primary" style="font-size:0.85rem;">聚类概览 →</a>
</div>
{% if run.error_message %}
<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.78rem;margin-top:0.5rem;max-height:200px;overflow:auto;"><code>{{ run.error_message }}</code></pre>
{% endif %}
</div>
<div class="grid-3">
<div class="card" style="text-align:center;">
<div class="stat">{{ run.total_flows|default:"0" }}</div>
<div class="stat-label">Total Flows</div>
<div class="stat-label">总流数</div>
</div>
<div class="card" style="text-align:center;">
<div class="stat">{{ run.entity_count|default:"0" }}</div>
<div class="stat-label">Entities</div>
<div class="stat-label">行数</div>
</div>
<div class="card" style="text-align:center;">
<div class="stat">{{ run.cluster_count|default:"0" }}</div>
<div class="stat-label">Clusters</div>
<div class="stat-label">聚类数</div>
</div>
</div>
<!-- ── Global SVD Features ── -->
{% if svd_features %}
<div class="card">
<h2>Clusters</h2>
<a href="{% url 'analysis:cluster_overview' run.display_id %}" class="btn btn-primary" style="margin-bottom:1rem;">View Cluster Overview</a>
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3>🔬 全域 SVD 特征分析</h3>
<span style="font-size:0.8rem;color:#888;">基于 {{ run.total_flows }} 行数据</span>
</div>
<p style="font-size:0.82rem;color:#666;margin-top:0.3rem;">奇异值分解 (TruncatedSVD) 降维后的各主成分解释方差比例与贡献特征。</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;margin-top:0.75rem;">
{% for svd in svd_features %}
<div class="svd-card">
<h4>PC{{ svd.component }}</h4>
<div style="display:flex;justify-content:space-between;font-size:0.78rem;color:#555;">
<span>解释方差: {{ svd.explained_var }}%</span>
<span>累积: {{ svd.cumulative_var }}%</span>
</div>
<div class="var-bar" style="width:{{ svd.explained_var }}%;"></div>
<div style="margin-top:0.4rem;">
{% for f in svd.top_features %}
<span class="feat-tag">{{ f.feature }}: {{ f.strength }}</span>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- ── LLM 自动分析流程 ── -->
{% if run.run_type == 'auto' and llm_timeline_json %}
<div class="card">
<h3>🧠 LLM 自动分析流程</h3>
<div id="llmTimeline"></div>
</div>
{% endif %}
<!-- ── 聚类列表 ── -->
<div class="card">
<h3>📊 聚类结果</h3>
{% if clusters %}
<table>
<thead>
<tr>
<th>Cluster</th>
<th>Size</th>
<th>Proportion</th>
<th>Silhouette</th>
<th></th>
<th>大小</th>
<th>比例</th>
<th>轮廓系数</th>
<th></th>
</tr>
</thead>
@@ -43,13 +96,83 @@
<td>{{ c.size }}</td>
<td>{{ c.proportion|floatformat:2 }}</td>
<td>{{ c.silhouette_score|floatformat:4|default:"-" }}</td>
<td><a href="{% url 'analysis:cluster_detail' run.display_id c.cluster_label %}" class="btn btn-primary">Detail</a></td>
<td><a href="{% url 'analysis:cluster_detail' run.display_id c.cluster_label %}" class="btn btn-primary">详情</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state"><p>No clusters yet.</p></div>
<div class="empty-state"><p>无聚类结果</p></div>
{% endif %}
</div>
{% if run.run_type == 'auto' and llm_timeline_json %}
<script>
const llmData = {{ llm_timeline_json|safe }};
(function() {
const container = document.getElementById('llmTimeline');
if (!container) return;
function escapeHtml(s) { if (s==null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function prettyJson(o) { try { return JSON.stringify(o,null,2); } catch(e) { return String(o); } }
var thinkingByStep = {}, thinkOrder = [];
if (llmData.thinking) {
var parts = llmData.thinking.split(/\n(?=\[\d+\])/);
for (var pi = 0; pi < parts.length; pi++) {
var m = parts[pi].match(/^\[(\d+)\]\s*(.*)/s);
if (m) {
var step = parseInt(m[1]);
if (!(step in thinkingByStep)) thinkOrder.push(step);
thinkingByStep[step] = (thinkingByStep[step]||'')+(thinkingByStep[step]?'\n':'')+m[2].trim();
}
}
}
var toolCalls = llmData.tool_calls || [];
var toolMap = {};
for (var ti = 0; ti < toolCalls.length; ti++) {
var tc = toolCalls[ti];
if (!toolMap[tc.step]) toolMap[tc.step] = [];
toolMap[tc.step].push(tc);
}
var allSteps = new Set(thinkOrder.concat(Object.keys(toolMap).map(Number)));
var sortedSteps = Array.from(allSteps).sort(function(a,b){return a-b;});
for (var si = 0; si < sortedSteps.length; si++) {
var stp = sortedSteps[si];
var hdr = document.createElement('div');
hdr.style.cssText = 'font-weight:700;font-size:0.85rem;color:#4361ee;padding:0.6rem 0 0.3rem;border-top:1px solid #eee;';
hdr.textContent = '步骤 ' + stp;
container.appendChild(hdr);
if (thinkingByStep[stp] !== undefined) {
var tdiv = document.createElement('div');
tdiv.style.cssText = 'background:#eef2ff;padding:0.5rem;border-radius:6px;font-size:0.78rem;line-height:1.6;color:#333;max-height:300px;overflow:auto;margin-bottom:0.3rem;border:1px solid #d0d8f0;';
tdiv.innerHTML = '<span style="font-weight:600;color:#4361ee;font-size:0.75rem;display:block;margin-bottom:0.25rem;">💭 思考</span>' + escapeHtml(thinkingByStep[stp]);
container.appendChild(tdiv);
}
if (toolMap[stp]) {
for (var tj = 0; tj < toolMap[stp].length; tj++) {
var tc2 = toolMap[stp][tj];
var card = document.createElement('div');
card.style.cssText = 'border:1px solid #e0e0e0;border-radius:6px;margin-bottom:0.3rem;overflow:hidden;background:#fff;';
var inputStr = prettyJson(tc2.input);
var outputStr = prettyJson(tc2.output);
card.innerHTML =
'<div onclick="this.classList.toggle(\'open\');var b=this.nextElementSibling;if(b)b.classList.toggle(\'open\');" style="padding:0.4rem 0.8rem;cursor:pointer;display:flex;justify-content:space-between;align-items:center;background:#f8f9fa;user-select:none;">' +
'<span><span style="background:#43aa8b;color:#fff;border-radius:3px;padding:0 5px;font-size:0.7rem;font-weight:600;margin-right:6px;">🔧</span>' +
'<span style="font-weight:600;font-size:0.8rem;">' + escapeHtml(tc2.name||'?') + '</span></span>' +
'<span style="font-size:0.75rem;color:#888;">▶</span></div>' +
'<div style="display:none;padding:0.5rem 0.8rem;background:#fafbfc;">' +
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输入:</div>' +
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0 0 0.3rem;"><code>' + escapeHtml(inputStr) + '</code></pre>' +
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输出:</div>' +
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0;"><code>' + escapeHtml(outputStr) + '</code></pre>' +
'</div>';
container.appendChild(card);
}
}
}
})();
</script>
{% endif %}
{% endblock %}
+141
View File
@@ -0,0 +1,141 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>3D Globe</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#0a0a1a; overflow:hidden; width:100vw; height:100vh; }
#globeContainer { width:100%; height:100%; }
</style>
</head>
<body>
<div id="globeContainer"></div>
<script src="{% static 'tianxuan/three.min.js' %}"></script>
<script>
const geoFlows = {{ geo_flows|safe }};
const W = window.innerWidth, H = window.innerHeight;
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(45, W/H, 0.1, 1000);
camera.position.set(0, 2, 12);
var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(W, H);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.getElementById('globeContainer').appendChild(renderer.domElement);
// Earth
var earthGeo = new THREE.SphereGeometry(5, 64, 64);
var earthMat = new THREE.MeshPhongMaterial({ color: 0x1a3a5c, emissive: 0x0a1a2a, specular: new THREE.Color(0x333333), shininess: 5 });
var earth = new THREE.Mesh(earthGeo, earthMat);
scene.add(earth);
// Glow
var glow = new THREE.Mesh(new THREE.SphereGeometry(5.1, 64, 64), new THREE.MeshBasicMaterial({ color: 0x224488, transparent: true, opacity: 0.1 }));
scene.add(glow);
// Lights
scene.add(new THREE.AmbientLight(0x222244));
var dl = new THREE.DirectionalLight(0xffffff, 1);
dl.position.set(5, 10, 7);
scene.add(dl);
scene.add(new THREE.DirectionalLight(0x4488ff, 0.3));
// Stars
var starGeo = new THREE.BufferGeometry();
var starPos = new Float32Array(1000 * 3);
for (var i = 0; i < 1000 * 3; i++) starPos[i] = (Math.random() - 0.5) * 200;
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.15 })));
// Lat/lon grid
var gridMat = new THREE.LineBasicMaterial({ color: 0x6699cc, transparent: true, opacity: 0.1 });
for (var lat = -80; lat <= 80; lat += 20) {
var pts = [];
for (var lon = 0; lon <= 360; lon += 5) {
var phi = (90 - lat) * Math.PI / 180;
var theta = (lon + 180) * Math.PI / 180;
pts.push(new THREE.Vector3(-5.02 * Math.sin(phi) * Math.cos(theta), 5.02 * Math.cos(phi), 5.02 * Math.sin(phi) * Math.sin(theta)));
}
scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gridMat));
}
for (var lon = 0; lon < 360; lon += 20) {
var pts = [];
for (var lat = -90; lat <= 90; lat += 5) {
var phi = (90 - lat) * Math.PI / 180;
var theta = (lon + 180) * Math.PI / 180;
pts.push(new THREE.Vector3(-5.02 * Math.sin(phi) * Math.cos(theta), 5.02 * Math.cos(phi), 5.02 * Math.sin(phi) * Math.sin(theta)));
}
scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gridMat));
}
// Flow arcs
var tlsColors = { 'TLSv1.3': 0x4cc9f0, 'TLSv1.2': 0x43aa8b, 'TLSv1.1': 0xf8961e, 'TLSv1.0': 0xf94144 };
var flowGroup = new THREE.Group();
scene.add(flowGroup);
function latLonToVec3(lat, lon, r) {
if (lat == null || lon == null) return null;
var phi = (90 - lat) * Math.PI / 180;
var 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));
}
var arcMeshes = [];
var flowData = Array.isArray(geoFlows) ? geoFlows : (typeof geoFlows === 'string' ? JSON.parse(geoFlows) : []);
flowData.forEach(function(flow) {
var from = latLonToVec3(flow.slat, flow.slon, 5);
var to = latLonToVec3(flow.dlat, flow.dlon, 5);
if (!from || !to) return;
var mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5).normalize().multiplyScalar(7);
var curve = new THREE.QuadraticBezierCurve3(from, mid, to);
var color = tlsColors[flow.tls] || 0x888888;
var mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.6 });
var mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, 20, 0.02, 4, false), mat);
mesh.userData = flow;
flowGroup.add(mesh);
arcMeshes.push(mesh);
});
// Mouse rotation
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) return;
var dx = e.clientX - prevMouse.x, dy = e.clientY - prevMouse.y;
earth.rotation.y += dx * 0.005;
earth.rotation.z += dy * 0.005;
flowGroup.rotation.y = earth.rotation.y;
flowGroup.rotation.z = earth.rotation.z;
prevMouse = { x: e.clientX, y: e.clientY };
});
// Listen for cluster filter from parent
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'globe_filter') {
var label = e.data.cluster_label;
arcMeshes.forEach(function(mesh) {
if (label === null) {
mesh.material.opacity = 0.6;
mesh.material.color.setHex(tlsColors[mesh.userData.tls] || 0x888888);
} else {
mesh.material.opacity = 0.06;
mesh.material.color.setHex(0x666666);
}
});
}
});
// Auto-rotate
(function animate() {
requestAnimationFrame(animate);
if (!isDragging) { earth.rotation.y += 0.003; flowGroup.rotation.y = earth.rotation.y; }
renderer.render(scene, camera);
})();
</script>
</body>
</html>