Files
tianxuan/simple_analysis/templates/simple_analysis/simple_analysis.html
T
TianXuan Developer 45034813ad fix: 每帧仅显示当前窗口边,不再混合两窗口
放弃交叉淡入淡出(两条边透明度叠加难以区分),
改为仅显示当前窗口的边(t<0.5→w0, t>=0.5→w1),
不同窗口不同边集一目了然。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 14:48:54 +08:00

2973 lines
137 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
{% extends 'base.html' %}
{% load static %}
{% block title %}简单分析 - 天璇{% endblock %}
{% block content %}
<style>
/* ── Step progress bar ────────────────────────────── */
.sa-progress {
display: flex; justify-content: center; gap: 0; margin-bottom: 2rem;
counter-reset: step;
}
.sa-progress .sp-item {
flex: 1; text-align: center; position: relative; counter-increment: step;
font-size: 0.85rem; color: #999; padding-top: 2rem;
}
.sa-progress .sp-item::before {
content: counter(step);
position: absolute; top: 0; left: 50%; transform: translateX(-50%);
width: 28px; height: 28px; border-radius: 50%;
background: #e0e0e0; color: #666;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.85rem; z-index: 1;
}
.sa-progress .sp-item::after {
content: ''; position: absolute; top: 14px; left: 50%; width: 100%;
height: 2px; background: #e0e0e0; z-index: 0;
}
.sa-progress .sp-item:last-child::after { display: none; }
.sa-progress .sp-item.active { color: #4361ee; font-weight: 600; }
.sa-progress .sp-item.active::before { background: #4361ee; color: #fff; }
.sa-progress .sp-item.done { color: #43aa8b; }
.sa-progress .sp-item.done::before { background: #43aa8b; color: #fff; }
.sa-progress .sp-item.done::after { background: #43aa8b; }
/* ── Step sections ── */
.sa-step { display: none; }
.sa-step.active { display: block; }
/* ── Upload zone ─────────────────────────────────── */
.sa-dropzone {
border: 2px dashed #ccc; border-radius: 12px; padding: 3rem 2rem;
text-align: center; cursor: pointer; transition: all 0.2s;
background: #fafcff;
}
.sa-dropzone:hover, .sa-dropzone.dragover { border-color: #4361ee; background: #eef2ff; }
.sa-dropzone .icon { font-size: 3rem; margin-bottom: 0.5rem; }
.sa-dropzone .hint { color: #999; font-size: 0.85rem; margin-top: 0.5rem; }
.sa-file-list { margin-top: 1rem; }
.sa-file-item {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.4rem 0.6rem; background: #f5f5f5; border-radius: 4px;
margin: 0.25rem 0; font-size: 0.85rem;
}
.sa-file-item .remove { color: #dc3545; cursor: pointer; margin-left: auto; font-weight: 700; }
.sa-file-list-wrap { max-height: 180px; overflow-y: auto; margin-top: 0.5rem; }
/* ── Preview table ───────────────────────────────── */
.sa-preview-wrap {
max-height: 400px; overflow: auto; border: 1px solid #eee; border-radius: 6px;
}
.sa-preview-wrap table { font-size: 0.75rem; }
.sa-preview-wrap th { position: sticky; top: 0; background: #f5f5f5; z-index: 1; white-space: nowrap; }
.sa-preview-wrap td { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Cluster results ─────────────────────────────── */
.sa-cluster-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem; margin: 0.5rem 0; max-height: 400px; overflow-y: auto;
padding-right: 0.25rem;
}
.sa-cluster-card {
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
padding: 0.75rem; text-align: center; cursor: pointer;
transition: all 0.15s;
}
.sa-cluster-card:hover { border-color: #4361ee; box-shadow: 0 2px 8px rgba(67,97,238,0.15); }
.sa-cluster-card .label { font-size: 1.1rem; font-weight: 700; }
.sa-cluster-card .size { font-size: 0.8rem; color: #666; }
.sa-cluster-card.noise { border-style: dashed; opacity: 0.7; }
/* ── Map layout ──────────────────────────────────── */
.sa-map-row { display: flex; gap: 1rem; margin: 1rem 0; }
.sa-map-container { flex: 1; height: 550px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; position: relative; }
#saMap { position: relative; }
#saTrafficCanvas { pointer-events: none !important; }
.sa-legend { width: 240px; max-height: 550px; overflow-y: auto; padding: 0.5rem; background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; }
.sa-legend-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; cursor: pointer; font-size: 0.85rem; }
.sa-legend-item .color-dot { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; }
.sa-legend-item.hidden { opacity: 0.3; }
/* ── Cluster detail panel ────────────────────────── */
.sa-detail-panel {
position: fixed; top: 0; right: -520px; width: 500px; height: 100%;
background: #fff; box-shadow: -4px 0 20px rgba(0,0,0,0.15);
z-index: 9999; transition: right 0.3s; overflow-y: auto;
padding: 1.5rem;
}
.sa-detail-panel.open { right: 0; }
.sa-detail-panel .close-btn {
float: right; font-size: 1.5rem; cursor: pointer; border: none;
background: none; color: #666; padding: 0 0.25rem;
}
.sa-detail-panel .close-btn:hover { color: #dc3545; }
.sa-detail-panel .stat-row { display: flex; gap: 1rem; margin: 0.75rem 0; }
.sa-detail-panel .stat-card {
flex: 1; background: #f5f7fa; border-radius: 6px; padding: 0.75rem; text-align: center;
}
.sa-detail-panel .stat-card .num { font-size: 1.5rem; font-weight: 700; color: #1a1a2e; }
.sa-detail-panel .stat-card .lbl { font-size: 0.75rem; color: #666; }
.sa-tag { display: inline-block; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.75rem; background: #e8f0fe; color: #4361ee; margin: 0.1rem; }
.sa-detail-panel .ip-list { max-height: 200px; overflow-y: auto; font-size: 0.8rem; }
.sa-detail-panel .section-title { font-size: 0.9rem; font-weight: 600; margin: 0.75rem 0 0.35rem; color: #1a1a2e; }
/* ── Chart containers ────────────────────────────── */
.sa-chart-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
.sa-chart-box { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 1rem; }
.sa-chart-box h4 { font-size: 0.9rem; margin-bottom: 0.5rem; color: #555; }
/* ── Loading overlay ─────────────────────────────── */
.sa-loading {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.35); z-index: 99999;
justify-content: center; align-items: center;
}
.sa-loading.show { display: flex; }
.sa-loading .box {
background: #fff; padding: 2rem; border-radius: 12px; text-align: center;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
}
.sa-spinner {
width: 40px; height: 40px; border: 4px solid #e0e0e0;
border-top-color: #4361ee; border-radius: 50%;
animation: sa-spin 0.8s linear infinite; margin: 0 auto 1rem;
}
@keyframes sa-spin { to { transform: rotate(360deg); } }
/* ── Fixed action bar ──────────────────────── */
.sa-action-bar {
position: sticky; top: 0; z-index: 9998;
background: #fff; padding: 0.75rem 1rem; margin-bottom: 1rem;
border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
}
.sa-action-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Preset filter panel ───────────────────── */
.sa-preset-panel {
border: 1px solid #e0e0e0; border-radius: 8px; margin: 0.75rem 0;
overflow: hidden;
}
.sa-preset-header {
padding: 0.5rem 0.75rem; background: #f8faff; cursor: pointer;
display: flex; justify-content: space-between; align-items: center;
font-size: 0.85rem; font-weight: 600; user-select: none;
}
.sa-preset-body {
padding: 0.75rem; display: none; background: #fff;
}
.sa-preset-body.open { display: block; }
.sa-preset-row { display: flex; gap: 0.5rem; margin: 0.35rem 0; align-items: center; flex-wrap: wrap; }
.sa-preset-row label { font-size: 0.8rem; min-width: 60px; color: #555; }
.sa-preset-row select, .sa-preset-row input {
padding: 0.25rem 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.8rem;
}
/* ── Simple card-based filter builder ────────── */
.sa-filter-builder { margin: 0.5rem 0; }
.sa-logic-toggle {
display: flex; gap: 0; margin-bottom: 0.75rem; border-radius: 6px; overflow: hidden;
border: 1px solid #d0d0d0; width: fit-content;
}
.sa-logic-toggle button {
padding: 0.4rem 1rem; border: none; cursor: pointer; font-size: 0.85rem;
font-weight: 600; background: #f5f5f5; color: #666; transition: all 0.15s;
}
.sa-logic-toggle button.active { background: #4361ee; color: #fff; }
.sa-logic-toggle button:first-child { border-right: 1px solid #d0d0d0; }
.sa-filter-row {
display: flex; gap: 0.35rem; align-items: center; margin: 0.25rem 0;
padding: 0.35rem 0.5rem; background: #fafbfc; border-radius: 4px;
border: 1px solid #eee; flex-wrap: wrap;
}
.sa-filter-row select, .sa-filter-row input {
padding: 0.3rem 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.8rem;
}
.sa-filter-row .remove-btn { color: #dc3545; cursor: pointer; font-weight: 700; font-size: 1.1rem; margin-left: auto; }
.sa-filter-row .not-cb { font-size: 0.78rem; color: #888; display: flex; align-items: center; gap: 0.2rem; white-space: nowrap; cursor: pointer; user-select: none; }
.sa-filter-row .not-cb input { cursor: pointer; }
/* Filter group card */
.sa-filter-group-card {
margin: 0.5rem 0; border: 2px solid #c8d6e5; border-radius: 8px;
overflow: hidden; background: #fff;
}
.sa-filter-group-card .sgc-header {
display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem;
background: #eef2f9; font-size: 0.82rem; font-weight: 600; color: #3d5a80;
}
.sa-filter-group-card .sgc-header select {
padding: 0.15rem 0.35rem; border: 1px solid #c8d6e5; border-radius: 3px;
font-size: 0.78rem; font-weight: 600; background: #fff;
}
.sa-filter-group-card .sgc-body { padding: 0.5rem 0.75rem; }
.sa-filter-group-card .sgc-footer {
display: flex; gap: 0.4rem; padding: 0.3rem 0.75rem; border-top: 1px solid #eef2f9;
background: #fafbfc; font-size: 0.75rem;
}
.sa-filter-group-card .sgc-footer button { font-size: 0.73rem; padding: 0.15rem 0.5rem; }
.sa-filter-group-card .sgc-footer .remove-btn { color: #dc3545; cursor: pointer; font-weight: 600; margin-left: auto; }
.sa-filter-actions { display: flex; gap: 0.4rem; margin: 0.5rem 0; flex-wrap: wrap; }
.sa-filter-actions button { font-size: 0.78rem; }
.sa-filter-desc {
font-size: 0.78rem; color: #666; margin-bottom: 0.5rem; padding: 0.35rem 0.6rem;
background: #fafbfc; border-radius: 4px; border-left: 3px solid #4361ee;
min-height: 1.2rem; line-height: 1.5;
}
/* ── Debug panel ───────────────────────────── */
.sa-debug-panel {
border: 1px solid #e0e0e0; border-radius: 8px; margin: 1rem 0;
font-size: 0.78rem; font-family: monospace;
}
.sa-debug-header {
padding: 0.4rem 0.75rem; background: #f5f5f5; cursor: pointer;
display: flex; justify-content: space-between; align-items: center;
font-weight: 600; user-select: none;
}
.sa-debug-body {
padding: 0.75rem; display: none; max-height: 300px; overflow-y: auto;
background: #1a1a2e; color: #a0ffa0; line-height: 1.5;
}
.sa-debug-body.open { display: block; }
.sa-debug-body .row { display: flex; gap: 1rem; margin: 0.2rem 0; }
.sa-debug-body .key { color: #88ddff; min-width: 140px; }
.sa-debug-body .val { color: #fff; }
/* ── Edge layer controls ───────────────────── */
.sa-edge-controls {
display: flex; gap: 0.5rem; align-items: center; margin: 0.5rem 0;
padding: 0.5rem; background: #f8faff; border-radius: 6px; font-size: 0.85rem;
}
/* ── Point tooltip ─────────────────────────── */
.sa-point-tooltip {
font-size: 0.78rem; line-height: 1.5;
}
.sa-point-tooltip .tt-row { display: flex; gap: 0.5rem; }
.sa-point-tooltip .tt-key { color: #888; min-width: 70px; }
/* Country labels on world map */
.sa-country-label {
font-size: 9px; font-weight: 400; color: #5c5c4a;
text-shadow: 0 0 3px #fff, 0 0 3px #fff;
white-space: nowrap; pointer-events: none;
letter-spacing: 0.5px;
}
/* Satellite-mode labels */
.sa-country-label-sat {
color: #fff;
text-shadow: 0 1px 3px rgba(0,0,0,0.8), 0 0 6px rgba(0,0,0,0.5);
font-weight: 500;
letter-spacing: 1px;
}
/* ── Traffic flow animation ────────────────────────── */
.sa-traffic-panel { display:none; position:fixed; bottom:0; left:0; right:0;
background:rgba(15,18,25,0.95); color:#e0e0e0; z-index:2000;
border-top:2px solid #4361ee; padding:0.5rem 1rem; max-height:280px; overflow-y:auto; }
.sa-traffic-panel.open { display:block; }
.sa-traffic-controls { display:flex; align-items:center; gap:1rem; flex-wrap:wrap; }
.sa-traffic-controls button { background:#2a2d3a; color:#ccc; border:1px solid #444;
border-radius:4px; padding:0.3rem 0.7rem; cursor:pointer; font-size:0.8rem; }
.sa-traffic-controls button:hover { background:#4361ee; color:#fff; }
.sa-traffic-controls button.active { background:#4361ee; color:#fff; }
.sa-traffic-slider { flex:1; min-width:200px; }
.sa-traffic-slider input { width:100%; }
.sa-traffic-time { font-size:0.8rem; color:#888; min-width:60px; text-align:center; }
.sa-traffic-info { font-size:0.75rem; color:#999; margin-top:0.3rem; }
.sa-traffic-canvas { position:absolute; top:0; left:0; pointer-events:none; z-index:1000; }
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 900px) {
.sa-map-row { flex-direction: column; }
.sa-legend { width: 100%; max-height: 150px; }
.sa-chart-row { grid-template-columns: 1fr; }
.sa-detail-panel { width: 100%; right: -100%; }
}
</style>
<!-- ── Loading overlay ──────────────────────────────── -->
<div class="sa-loading" id="saLoading">
<div class="box">
<div class="sa-spinner"></div>
<div id="saLoadingMsg" style="color:#666;">处理中...</div>
</div>
</div>
<!-- ── Cluster detail panel ─────────────────────────── -->
<div class="sa-detail-panel" id="saDetailPanel">
<button class="close-btn" onclick="closeDetail()"></button>
<h3 id="saDetailTitle">簇详情</h3>
<div id="saDetailBody"></div>
</div>
<div class="card">
<h2>📊 简单分析</h2>
<p style="color:#666;margin-bottom:1.5rem;">四步完成:上传CSV → 筛选 → 聚类 → 地图可视化</p>
<!-- Progress bar -->
<div class="sa-progress">
<div class="sp-item active" data-step="1">上传CSV</div>
<div class="sp-item" data-step="2">筛选数据</div>
<div class="sp-item" data-step="3">聚类分析</div>
<div class="sp-item" data-step="4">地图可视化</div>
</div>
<!-- Fixed action bar (sticky top) -->
<div class="sa-action-bar" id="saActionBar">
<span style="font-weight:600;color:#4361ee;" id="saStepIndicator">步骤 1:上传 CSV</span>
<span id="saActionInfo" style="color:#999;font-size:0.85rem;"></span>
<div style="margin-left:auto;display:flex;gap:0.4rem;">
<button class="btn btn-primary" id="saBtnPrev" style="display:none;font-size:0.8rem;" onclick="goStep(parseInt(document.querySelector('.sa-step.active')?.id?.replace('saStep','')||2)-1)">← 上一步</button>
<button class="btn btn-primary" id="saBtnNext" style="display:none;font-size:0.8rem;" onclick="goStep(parseInt(document.querySelector('.sa-step.active')?.id?.replace('saStep','')||1)+1)">下一步 →</button>
</div>
</div>
<!-- ══════════ Step 1: Upload ══════════ -->
<div class="sa-step active" id="saStep1">
<h3 style="margin-bottom:0.75rem;">📂 步骤一:上传 CSV</h3>
<div class="sa-dropzone" id="saDropzone"
ondragover="event.preventDefault(); this.classList.add('dragover')"
ondragleave="this.classList.remove('dragover')"
ondrop="handleDrop(event)"
onclick="document.getElementById('saFileInput').click()">
<div class="icon">☁️</div>
<div>拖拽 CSV 文件到此处,或 <a href="#" onclick="event.stopPropagation();document.getElementById('saFileInput').click();return false;">点击选择</a></div>
<div class="hint">支持多文件,自动合并</div>
<input type="file" id="saFileInput" accept=".csv" multiple
style="display:none" onchange="handleFiles(this.files)">
</div>
<div class="sa-file-list-wrap"><div class="sa-file-list" id="saFileList"></div></div>
<!-- Preset filter builder (appears after quick scan, above file list) -->
<div id="saPresetFilterArea" style="display:none;margin-top:0.75rem;">
<div class="sa-preset-panel">
<div class="sa-preset-header" onclick="this.nextElementSibling.classList.toggle('open')">
<span>⚙ 上传预设筛选(可选 — 筛选条件在上传时即生效,未匹配行直接丢弃)</span>
<span style="color:#999;font-size:0.75rem;">展开 ▼</span>
</div>
<div class="sa-preset-body open" id="saPresetBody">
<div class="sa-logic-toggle" id="saPresetLogicToggle">
<button class="active" data-logic="AND" onclick="setPresetLogic('AND', this)">全部满足(且)</button>
<button data-logic="OR" onclick="setPresetLogic('OR', this)">任一满足(或)</button>
</div>
<div class="sa-filter-desc" id="saPresetDesc" style="display:none;"></div>
<div id="saPresetConditions"></div>
<div class="sa-filter-actions">
<button class="btn" onclick="addCond(document.getElementById('saPresetConditions'), 'preset')">+ 条件</button>
<button class="btn" onclick="addGroup(document.getElementById('saPresetConditions'), 'preset')">+ 条件组</button>
</div>
<div style="font-size:0.75rem;color:#999;margin-top:0.35rem;">
筛选条件在上传时实时过滤,减少内存占用。
<span style="color:#4361ee;cursor:pointer;margin-left:0.5rem;" onclick="clearPresetMemory()">清除记忆</span>
</div>
</div>
</div>
<button class="btn btn-primary" style="margin-top:0.75rem;font-size:1rem;" onclick="doUpload()" id="saBtnUpload">
🚀 开始上传并应用预设筛选
</button>
</div>
<div id="saUploadResult" style="display:none;margin-top:1rem;">
<div class="card" style="background:#f8faff;">
<div><strong>总记录数:</strong><span id="saTotalRows">0</span></div>
<div><strong>预设筛选后:</strong><span id="saPresetFiltered">0</span></div>
<div><strong>检测到的列:</strong><span id="saDetectedCols"></span></div>
<div style="margin-top:0.5rem;" id="saMissingCols" class="badge badge-warning"></div>
</div>
<button class="btn btn-primary" style="margin-top:1rem;" onclick="goStep(2)">
下一步:筛选 → &nbsp;
</button>
</div>
</div>
<!-- ══════════ Step 2: Advanced Filter ══════════ -->
<div class="sa-step" id="saStep2">
<h3 style="margin-bottom:0.5rem;">🔍 步骤二:筛选数据</h3>
<div class="card" style="background:#f8faff;">
<div style="font-size:0.85rem;">
<strong>全部:</strong><span id="saFilterTotalBefore" style="font-weight:700;">0</span>
<span style="margin-left:1.5rem;"><strong>筛选后:</strong></span>
<span id="saFilteredCount" style="font-size:1.2rem;font-weight:700;color:#4361ee;">0</span>
</div>
</div>
<!-- Simple card-based filter builder -->
<div class="sa-filter-builder" id="saFilterBuilder">
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.5rem;">
<span style="font-weight:600;font-size:0.9rem;">筛选条件</span>
<div class="sa-logic-toggle" id="saLogicToggle">
<button class="active" data-logic="AND" onclick="setFilterLogic('AND', this)">全部满足(且)</button>
<button data-logic="OR" onclick="setFilterLogic('OR', this)">任一满足(或)</button>
</div>
</div>
<!-- Human-readable description -->
<div class="sa-filter-desc" id="saFilterDesc" style="display:none;"></div>
<div id="saFilterConditions"></div>
<div class="sa-filter-actions">
<button class="btn" onclick="addCond()">+ 条件</button>
<button class="btn" onclick="addGroup()">+ 条件组</button>
<button class="btn btn-primary" onclick="applyAdvancedFilter()" id="saBtnApplyFilter">▶ 应用筛选</button>
</div>
</div>
<div id="saFilterError" style="display:none;margin-top:0.5rem;"></div>
<h4 style="margin:0.75rem 0 0.35rem;">数据预览 <span style="font-size:0.75rem;color:#999;font-weight:400;">(前20行)</span></h4>
<div class="sa-preview-wrap" id="saPreviewWrap">
<table>
<thead><tr id="saPreviewHeader"></tr></thead>
<tbody id="saPreviewBody"></tbody>
</table>
</div>
</div>
<!-- ══════════ Step 3: Cluster ══════════ -->
<div class="sa-step" id="saStep3">
<h3 style="margin-bottom:0.75rem;">🧩 步骤三:IP 网络社区发现</h3>
<div class="card" style="background:#f8faff;">
<p style="color:#666;font-size:0.85rem;margin-bottom:0.75rem;">
基于 IP 通信网络构建图(节点=唯一IP,边=通信关系),按 /24 子网前缀分组后使用 HDBSCAN 地理聚类发现社区。
</p>
<div id="saClusterParams">
<div class="param-row" style="margin:0.5rem 0;">
<label style="font-size:0.85rem;">最小社区规模 (min_cluster_size)</label>
<input type="range" id="saMinClusterSize" min="1" max="50" value="1"
oninput="document.getElementById('saMinClusterVal').textContent=this.value">
<span id="saMinClusterVal" style="font-weight:700;min-width:30px;">5</span>
<span style="font-size:0.75rem;color:#999;margin-left:0.5rem;">小于此规模的子网直接合并为一个社区</span>
</div>
<div class="param-row" style="margin:0.5rem 0;">
<label style="font-size:0.85rem;">聚合半径 (epsilon_radians)</label>
<input type="range" id="saEpsilon" min="100" max="2000" value="500" step="50"
oninput="document.getElementById('saEpsilonVal').textContent=this.value + 'm'">
<span id="saEpsilonVal" style="font-weight:700;min-width:50px;">500m</span>
<span style="font-size:0.75rem;color:#999;margin-left:0.5rem;">同一子网内多近算同一社区</span>
</div>
</div>
<button class="btn btn-primary" onclick="runCluster()" style="margin-top:0.5rem;">
🚀 开始分析
</button>
</div>
<div id="saClusterResult" style="display:none;">
<div class="card" style="background:#f0faf0;margin-top:0.75rem;">
<div class="grid-3">
<div><strong>社区数</strong><div style="font-size:1.5rem;font-weight:700;" id="saNClusters">0</div></div>
<div><strong>节点数</strong><div style="font-size:1.5rem;font-weight:700;" id="saNNoise">0</div></div>
<div><strong>边数</strong><div style="font-size:1.5rem;font-weight:700;" id="saNValid">0</div></div>
</div>
</div>
<button class="btn btn-primary" style="margin-bottom:0.5rem;" onclick="goStep(4)">
下一步:地图可视化 → &nbsp;
</button>
<button class="btn" style="margin-bottom:0.5rem;margin-left:0.5rem;" onclick="goStep(2)">← 返回</button>
<div class="sa-cluster-grid" id="saClusterCards"></div>
</div>
</div>
<!-- ══════════ Step 4: Map ═════════════ -->
<div class="sa-step" id="saStep4">
<h3 style="margin-bottom:0.75rem;">🗺️ 步骤四:可视化交互地图</h3>
<div class="sa-map-row">
<div class="sa-map-container" id="saMap"></div>
<div class="sa-legend" id="saLegend">
<div style="font-weight:600;margin-bottom:0.5rem;">簇图例</div>
<div id="saLegendItems"></div>
</div>
</div>
<!-- Edge layer controls -->
<div class="sa-edge-controls" id="saEdgeControls" style="display:none;">
<span style="font-weight:600;">🔗 边关系</span>
<label style="font-size:0.8rem;">显示IP通联:</label>
<input type="checkbox" id="saShowEdges" onchange="toggleEdges()">
<label style="font-size:0.8rem;">最低频次:</label>
<input type="range" id="saEdgeFreq" min="1" max="50" value="1"
oninput="document.getElementById('saEdgeFreqVal').textContent=this.value;renderEdgesOnMap();">
<span id="saEdgeFreqVal" style="font-size:0.8rem;font-weight:700;">1</span>
<span id="saEdgeCount" style="color:#999;font-size:0.78rem;"></span>
</div>
<div style="margin:0.75rem 0;text-align:center;">
<button class="btn" onclick="exportClusterCSV()">📥 导出点表 (CSV)</button>
<button class="btn" style="margin-left:0.5rem;" onclick="exportEdgesCSV()">📤 导出边表 (CSV)</button>
<button class="btn btn-primary" style="margin-left:0.5rem;" onclick="toggleTrafficPanel()">🌊 动态流通</button>
<button class="btn" style="margin-left:0.5rem;" onclick="resetAll()">🔄 重新开始</button>
</div>
<div class="sa-chart-row" id="saCharts">
<div class="sa-chart-box">
<h4>簇记录数柱状图</h4>
<canvas id="saBarChart" height="200"></canvas>
</div>
<div class="sa-chart-box">
<h4>簇内流量占比</h4>
<canvas id="saPieChart" height="200"></canvas>
</div>
</div>
</div>
<!-- Debug info panel (always visible) -->
<div class="sa-debug-panel" id="saDebugPanel">
<div class="sa-debug-header" onclick="this.nextElementSibling.classList.toggle('open')">
<span>🐛 调试信息</span>
<span style="color:#999;font-size:0.75rem;">点击展开 ▼</span>
</div>
<div class="sa-debug-body" id="saDebugBody">
<div class="row"><span class="key">状态</span><span class="val" id="saDebugStatus">等待上传</span></div>
<div class="row"><span class="key">总记录数</span><span class="val" id="saDebugTotal">0</span></div>
<div class="row"><span class="key">预设筛选后</span><span class="val" id="saDebugPreset"></span></div>
<div class="row"><span class="key">高级筛选后</span><span class="val" id="saDebugFiltered"></span></div>
<div class="row"><span class="key">GeoIP 解析</span><span class="val" id="saDebugGeo"></span></div>
<div class="row"><span class="key">聚类模式</span><span class="val" id="saDebugMode"></span></div>
<div class="row"><span class="key">簇数量</span><span class="val" id="saDebugClusters"></span></div>
<div class="row"><span class="key">单节点簇</span><span class="val" id="saDebugNoise"></span></div>
<div class="row"><span class="key">耗时</span><span class="val" id="saDebugTime"></span></div>
<div class="row"><span class="key">备注</span><span class="val" id="saDebugNote"></span></div>
</div>
</div>
</div>
<!-- ── Leaflet + Chart.js + TopoJSON (local vendor assets) ── -->
<link rel="stylesheet" href="{% static 'simple_analysis/vendor/leaflet.css' %}" />
<script src="{% static 'simple_analysis/vendor/leaflet.js' %}"></script>
<script src="{% static 'simple_analysis/vendor/chart.umd.min.js' %}"></script>
<script src="{% static 'simple_analysis/maps/topojson-client.min.js' %}"></script>
<script>
/* ── Static URL paths for offline assets ─────────────── */
const STATIC_MAPS = '{% static "simple_analysis/maps" %}';
const STATIC_WORLD_MAP = '{% static "simple_analysis/world_map.svg" %}';
/* ── State ────────────────────────────────────────────── */
const SA = {
sessionId: null,
scanId: null, // quick-scan id for lazy column-value queries
colNames: [],
detectedCols: {},
columnsStd: [], // standardized column names
nodes: [],
clusterLabels: [],
clusterMap: {},
dataPoints: {},
map: null,
pointLayer: null,
centerLayer: null,
edgeLayer: null,
edgesData: null,
barChart: null,
pieChart: null,
hiddenClusters: new Set(),
filterConditions: [], // [{col, op, val, logic}, ...]
_loading: false, // anti-duplicate
};
/* ── Navigation ──────────────────────────────────────── */
function goStep(n) {
if (n === 2 && SA.sessionId) {
initFilterBuilder();
}
if (n === 4 && (!SA.clusterMap || !Object.keys(SA.clusterMap).length)) {
showError('请先完成聚类分析(步骤三)');
return;
}
document.querySelectorAll('.sa-step').forEach(el => el.classList.remove('active'));
const stepEl = document.getElementById('saStep' + n);
if (stepEl) stepEl.classList.add('active');
document.querySelectorAll('.sp-item').forEach(el => {
const step = parseInt(el.dataset.step);
el.classList.remove('active', 'done');
if (step === n) el.classList.add('active');
else if (step < n) el.classList.add('done');
});
const names = {1:'上传CSV',2:'筛选数据',3:'聚类分析',4:'地图可视化'};
setText('saStepIndicator', `步骤 ${n}${names[n]||''}`);
const prevBtn = document.getElementById('saBtnPrev');
const nextBtn = document.getElementById('saBtnNext');
if (prevBtn) { prevBtn.style.display = n > 1 ? 'inline-block' : 'none'; prevBtn.disabled = false; }
if (nextBtn) { nextBtn.style.display = n < 4 ? 'inline-block' : 'none'; nextBtn.disabled = false; }
if (n === 4) {
initMap();
renderCharts();
const edgeControls = document.getElementById('saEdgeControls');
if (edgeControls) edgeControls.style.display = 'flex';
}
window.scrollTo(0, 0);
}
/* ── Debounce / Anti-duplicate ─────────────────────────── */
function setBusy(busy, msg) {
SA._loading = busy;
const loadingEl = document.getElementById('saLoading');
const msgEl = document.getElementById('saLoadingMsg');
if (busy) {
if (msgEl) msgEl.textContent = msg || '处理中...';
if (loadingEl) loadingEl.classList.add('show');
try {
document.querySelectorAll('.sa-action-bar button, #saBtnApplyFilter, .sa-filter-actions button').forEach(b => { if (b) b.disabled = true; });
} catch(e) {}
} else {
if (loadingEl) loadingEl.classList.remove('show');
try {
document.querySelectorAll('.sa-action-bar button, #saBtnApplyFilter, .sa-filter-actions button').forEach(b => { if (b) b.disabled = false; });
} catch(e) {}
}
}
function showLoading(msg) { setBusy(true, msg); }
function hideLoading() { setBusy(false); }
/* ── Safe textContent setter ────────────────────────────── */
function setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val != null ? String(val) : '—';
}
/* ── Debug panel update ────────────────────────────────── */
function updateDebug(info) {
if (!info) return;
setText('saDebugStatus', info.status || '—');
if (info.total !== undefined) setText('saDebugTotal', info.total.toLocaleString());
if (info.preset !== undefined) setText('saDebugPreset', info.preset.toLocaleString());
if (info.filtered !== undefined) setText('saDebugFiltered', info.filtered.toLocaleString());
if (info.geo !== undefined) setText('saDebugGeo', info.geo);
if (info.mode !== undefined) setText('saDebugMode', info.mode);
if (info.clusters !== undefined) setText('saDebugClusters', info.clusters);
if (info.noise_ratio !== undefined) setText('saDebugNoise', info.noise_ratio);
if (info.time !== undefined) setText('saDebugTime', info.time + 's');
if (info.note !== undefined) setText('saDebugNote', info.note);
}
/* ── Preset filter restore (old format migration) ────────── */
function restorePreset() {
// Migrate old-format preset to new tree format
try {
const saved = localStorage.getItem('sa_preset');
if (saved) {
const p = JSON.parse(saved);
const items = [];
if (p.cnrs) items.push({type: 'condition', col: 'cnrs', op: 'eq', val: p.cnrs, logic: ''});
if (p.isrs) items.push({type: 'condition', col: 'isrs', op: 'eq', val: p.isrs, logic: ''});
if (p.cnam) items.push({type: 'condition', col: 'cnam', op: 'contains', val: p.cnam, logic: ''});
if (p.snam) items.push({type: 'condition', col: 'snam', op: 'contains', val: p.snam, logic: ''});
if (items.length) {
localStorage.setItem('sa_preset_tree', JSON.stringify({type: 'group', logic: 'AND', items: items}));
}
localStorage.removeItem('sa_preset'); // Migrated
}
} catch(e) {}
}
/* ── Simple filter builder (card-based, AND/OR toggle, NOT checkbox) ── */
const OPS = [
['eq', '等于'], ['neq', '不等于'], ['contains', '包含'], ['not_contains', '不含'],
['gt', '大于'], ['lt', '小于'], ['gte', '≥'], ['lte', '≤'],
['is_empty', '为空'], ['not_empty', '非空'],
];
function initFilterBuilder() {
const c = document.getElementById('saFilterConditions');
if (!c || c.children.length > 0) return;
addCond(c);
}
/* ── Logic toggle ────────────────────────────────────── */
function setFilterLogic(logic, btn) {
btn.parentElement.querySelectorAll('button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
refreshDesc(document.getElementById('saFilterConditions'), 'saFilterDesc');
}
function getLogic(scope) {
if (scope === 'preset') {
const b = document.querySelector('#saPresetLogicToggle .active');
return b ? b.dataset.logic : 'AND';
}
const b = document.querySelector('#saLogicToggle .active');
return b ? b.dataset.logic : 'AND';
}
function setPresetLogic(logic, btn) {
setFilterLogic(logic, btn);
}
/* ── Op change (disable value for is_empty / not_empty) ── */
function onFilterOpChange(opSel) {
const row = opSel.closest('.sa-filter-row');
const valSel = row.querySelector('.sa-filter-val');
if (!valSel) return;
if (opSel.value === 'is_empty' || opSel.value === 'not_empty') {
valSel.disabled = true;
valSel.innerHTML = '<option value="">— 无需值 —</option>';
} else {
valSel.disabled = false;
const colSel = row.querySelector('.sa-filter-col-select');
if (colSel && colSel.value) onFilterColChange(colSel);
}
}
/* ── Col change (fetch top-50 values) ─────────────────── */
function onFilterColChange(colSel) {
const row = colSel.closest('.sa-filter-row');
const valSel = row.querySelector('.sa-filter-val');
if (!colSel || !valSel || !colSel.value) return;
valSel.disabled = false;
valSel.innerHTML = '<option value="">加载中...</option>';
let url = '/simple/column-values/?col=' + encodeURIComponent(colSel.value);
if (SA.sessionId) url += '&session_id=' + SA.sessionId;
else if (SA.scanId) url += '&scan_id=' + SA.scanId;
else { valSel.innerHTML = '<option value="">— 请先选择文件 —</option>'; return; }
fetch(url, {headers: {'X-CSRFToken': getCSRF()}})
.then(r => r.json())
.then(d => {
valSel.innerHTML = '<option value="">— 选择值 —</option>';
if (d.error || !d.values) return;
d.values.forEach(v => {
const o = document.createElement('option');
o.value = v.value; o.textContent = (v.value || '(空)') + ' (' + v.count + ')';
valSel.appendChild(o);
});
})
.catch(() => { valSel.innerHTML = '<option value="">— 选择值 —</option>'; });
}
/* ── Add condition row ─────────────────────────────────── */
function addCond(container, scope) {
if (!container) container = document.getElementById('saFilterConditions');
const scopeStr = scope || (container.id === 'saPresetConditions' ? 'preset' : 'main');
const cols = (SA.columnsStd.length ? SA.columnsStd : SA.colNames).slice().sort((a, b) => a.localeCompare(b));
const div = document.createElement('div');
div.className = 'sa-filter-row';
div.innerHTML = `
<select class="sa-filter-col-select" style="min-width:90px;" onchange="onFilterColChange(this);updateDesc(this)">
<option value="">— 列 —</option>
${cols.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('')}
</select>
<select class="sa-filter-op" style="width:80px;" onchange="onFilterOpChange(this);updateDesc(this)">
${OPS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}
</select>
<select class="sa-filter-val" style="width:130px;"><option value="">— 值 —</option></select>
<label class="not-cb" title="取反此条件"><input type="checkbox" class="sa-filter-not" onchange="updateDesc(this)"> 取反</label>
<span class="remove-btn" onclick="this.parentElement.remove();refreshDesc(this.closest('.sa-filter-builder, .sa-preset-body')?.querySelector('[id$=Conditions]'), '${scopeStr === 'preset' ? 'saPresetDesc' : 'saFilterDesc'}')">✕</span>
`;
container.appendChild(div);
if (scopeStr === 'preset') savePresetTree();
}
/* ── Add group card ────────────────────────────────────── */
function addGroup(container, scope) {
if (!container) container = document.getElementById('saFilterConditions');
const scopeStr = scope || (container.id === 'saPresetConditions' ? 'preset' : 'main');
const gid = 'g_' + Date.now();
const card = document.createElement('div');
card.className = 'sa-filter-group-card';
card.dataset.gid = gid;
card.innerHTML = `
<div class="sgc-header">
📦 条件组 —
<select class="sgc-logic" onchange="refreshDesc(this.closest('.sa-filter-builder, .sa-preset-body')?.querySelector('[id$=Conditions]'), '${scopeStr === 'preset' ? 'saPresetDesc' : 'saFilterDesc'}')">
<option value="AND">全部满足(且)</option>
<option value="OR">任一满足(或)</option>
</select>
<span style="font-size:0.75rem;color:#888;font-weight:400;">— 组内条件</span>
</div>
<div class="sgc-body"></div>
<div class="sgc-footer">
<button class="btn" onclick="addCond(this.closest('.sa-filter-group-card').querySelector('.sgc-body'), '${scopeStr}')">+ 条件</button>
<button class="btn" onclick="addGroup(this.closest('.sa-filter-group-card').querySelector('.sgc-body'), '${scopeStr}')">+ 子组</button>
<span class="remove-btn" onclick="this.closest('.sa-filter-group-card').remove();refreshDesc(document.getElementById('${scopeStr === 'preset' ? 'saPresetConditions' : 'saFilterConditions'}'), '${scopeStr === 'preset' ? 'saPresetDesc' : 'saFilterDesc'}')">✕ 删除组</span>
</div>
`;
container.appendChild(card);
// Start with one condition inside
addCond(card.querySelector('.sgc-body'), scopeStr);
if (scopeStr === 'preset') savePresetTree();
}
/* ── Serialize ─────────────────────────────────────────── */
function serializeFilter(container) {
if (!container) return [];
const items = [];
container.querySelectorAll(':scope > .sa-filter-row, :scope > .sa-filter-group-card').forEach(el => {
if (el.classList.contains('sa-filter-row')) {
const col = el.querySelector('.sa-filter-col-select')?.value;
if (!col) return;
const not = el.querySelector('.sa-filter-not')?.checked || false;
items.push({
type: 'condition', col, op: el.querySelector('.sa-filter-op')?.value || 'eq',
val: el.querySelector('.sa-filter-val')?.value || '',
logic: not ? 'NOT' : '',
});
} else if (el.classList.contains('sa-filter-group-card')) {
const children = serializeFilter(el.querySelector('.sgc-body'));
if (children.length) {
items.push({ type: 'group', logic: el.querySelector('.sgc-logic')?.value || 'AND', items: children });
}
}
});
return items;
}
/* ── Human-readable description ────────────────────────── */
function updateDesc(el) {
const builder = el.closest('.sa-filter-builder');
const presetBody = el.closest('.sa-preset-body');
if (builder) refreshDesc(builder.querySelector('[id$=Conditions]'), 'saFilterDesc');
if (presetBody) refreshDesc(presetBody.querySelector('#saPresetConditions'), 'saPresetDesc');
}
function refreshDesc(container, descId) {
const descEl = document.getElementById(descId);
if (!descEl || !container) return;
const items = serializeFilter(container);
if (!items.length) { descEl.style.display = 'none'; return; }
const logic = descId === 'saPresetDesc' ? getLogic('preset') : getLogic('main');
const joiner = logic === 'OR' ? ' 或 ' : ' 且 ';
descEl.style.display = 'block';
descEl.textContent = '当前筛选:' + describeItems(items, joiner);
}
function describeItems(items, joiner) {
return items.map(item => {
if (item.type === 'group') {
const inner = describeItems(item.items, item.logic === 'OR' ? ' 或 ' : ' 且 ');
return '(' + inner + ')';
}
const opLabel = (OPS.find(o => o[0] === item.op) || ['', item.op])[1];
let s = `${item.col} ${opLabel} "${item.val || '(空)'}"`;
if (item.logic === 'NOT') s = '非(' + s + ')';
return s;
}).join(joiner);
}
/* ── Apply filter ──────────────────────────────────────── */
function applyAdvancedFilter() {
if (SA._loading || !SA.sessionId) return;
const items = serializeFilter(document.getElementById('saFilterConditions'));
if (!items.length) { showError('请至少添加一个筛选条件'); return; }
const logic = getLogic('main');
const payload = { session_id: SA.sessionId, root: { type: 'group', logic, items } };
setBusy(true, '正在筛选...');
fetch('/simple/filter/advanced/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify(payload),
})
.then(r => r.json())
.then(data => {
setBusy(false);
if (data.error) { showError(data.error); return; }
setText('saFilteredCount', (data.filtered_rows || 0).toLocaleString());
setText('saFilterTotalBefore', (data.total_before_filter || 0).toLocaleString());
renderPreview(data.preview_columns, data.preview_rows);
updateDebug({status: '已筛选', filtered: data.filtered_rows, total: data.total_before_filter});
// Scroll to preview
const pw = document.getElementById('saPreviewWrap');
if (pw) pw.scrollIntoView({behavior: 'smooth', block: 'start'});
showSuccess(`筛选完成,${data.filtered_rows} 条记录`);
})
.catch(err => { setBusy(false); showError('筛选失败: ' + err.message); });
}
/* ── Step 1: Upload (two-phase: quick scan → preset filter → upload) ── */
let _pendingFiles = null; // FileList saved after drop for later upload
function handleDrop(e) {
e.preventDefault();
document.getElementById('saDropzone').classList.remove('dragover');
handleFiles(e.dataTransfer.files);
}
function handleFiles(files) {
if (!files.length) return;
_pendingFiles = files;
const list = document.getElementById('saFileList');
list.innerHTML = '';
Array.from(files).forEach(f => {
const div = document.createElement('div');
div.className = 'sa-file-item';
div.innerHTML = `📄 ${f.name} (${(f.size/1024).toFixed(1)} KB) <span class="remove" onclick="removeFile(this)">✕</span>`;
list.appendChild(div);
});
// Phase 1: quick scan for columns only
quickScanFiles(files);
}
function removeFile(el) {
el.parentElement.remove();
const remaining = document.getElementById('saFileList').children.length;
if (!remaining) {
_pendingFiles = null;
document.getElementById('saPresetFilterArea').style.display = 'none';
document.getElementById('saUploadResult').style.display = 'none';
}
}
function quickScanFiles(files) {
const formData = new FormData();
Array.from(files).forEach(f => formData.append('files', f));
setBusy(true, '正在扫描CSV表头...');
fetch('/simple/quick-scan/', {
method: 'POST',
body: formData,
headers: {'X-CSRFToken': getCSRF()},
})
.then(r => r.json())
.then(data => {
setBusy(false);
if (data.error) { showError(data.error); return; }
SA.scanId = data.scan_id; // 保存 scan_id 供后续 column-values 查询
SA.columnsStd = data.columns_std || data.columns || [];
SA.colNames = data.columns || [];
// Show preset filter area
showPresetFilterBuilder(data.columns_std || data.columns || []);
updateDebug({status: '表头已扫描', total: '—', note: (data.columns || []).length + ' 列检测到'});
})
.catch(err => { setBusy(false); showError('扫描失败: ' + err.message); });
}
/* ── Preset filter persistence (v2 simple card-based) ──── */
function showPresetFilterBuilder(columns) {
const area = document.getElementById('saPresetFilterArea');
const container = document.getElementById('saPresetConditions');
if (!area || !container) return;
area.style.display = 'block';
container.innerHTML = '';
const saved = loadPresetTree();
if (saved && saved.items && saved.items.length) {
restoreTree(container, saved, columns);
} else {
addCond(container, 'preset');
}
}
function loadPresetTree() {
try { const r = localStorage.getItem('sa_preset_tree_v2'); if (r) return JSON.parse(r); } catch(e) {}
return null;
}
function savePresetTree() {
const c = document.getElementById('saPresetConditions');
if (!c) return;
const items = serializeFilter(c);
if (items.length) {
localStorage.setItem('sa_preset_tree_v2', JSON.stringify({ type: 'group', logic: getLogic('preset'), items }));
} else {
localStorage.removeItem('sa_preset_tree_v2');
}
}
function clearPresetMemory() {
localStorage.removeItem('sa_preset_tree_v2');
localStorage.removeItem('sa_preset');
const c = document.getElementById('saPresetConditions');
if (c) { c.innerHTML = ''; addCond(c, 'preset'); }
refreshDesc(c, 'saPresetDesc');
showSuccess('已清除预设筛选记忆');
}
function restoreTree(container, tree, columns) {
const sortedCols = columns.slice().sort((a, b) => a.localeCompare(b));
if (tree.logic) {
const toggle = document.getElementById('saPresetLogicToggle');
if (toggle) {
toggle.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.logic === tree.logic));
}
}
tree.items.forEach(item => {
if (item.type === 'group') appendSavedGroup(container, item, sortedCols);
else appendSavedCond(container, item, sortedCols);
});
refreshDesc(container, 'saPresetDesc');
}
function appendSavedCond(container, item, sortedCols) {
const div = document.createElement('div');
div.className = 'sa-filter-row';
const notChecked = item.logic === 'NOT' ? 'checked' : '';
div.innerHTML =
`<select class="sa-filter-col-select" style="min-width:90px;" onchange="onFilterColChange(this);updateDesc(this)">` +
`<option value="">— 列 —</option>` +
sortedCols.map(c => `<option value="${escapeHtml(c)}" ${c === item.col ? 'selected' : ''}>${escapeHtml(c)}</option>`).join('') +
`</select>` +
`<select class="sa-filter-op" style="width:80px;" onchange="onFilterOpChange(this);updateDesc(this)">` +
OPS.map(([v, l]) => `<option value="${v}" ${item.op === v ? 'selected' : ''}>${l}</option>`).join('') +
`</select>` +
`<select class="sa-filter-val" style="width:130px;"><option value="${escapeHtml(item.val || '')}" selected>${escapeHtml(item.val || '—')}</option></select>` +
`<label class="not-cb"><input type="checkbox" class="sa-filter-not" ${notChecked} onchange="updateDesc(this)"> 取反</label>` +
`<span class="remove-btn" onclick="this.parentElement.remove();savePresetTree();refreshDesc(document.getElementById('saPresetConditions'),'saPresetDesc')">✕</span>`;
container.appendChild(div);
}
function appendSavedGroup(container, group, sortedCols) {
const gid = 'g_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
const card = document.createElement('div');
card.className = 'sa-filter-group-card';
card.dataset.gid = gid;
card.innerHTML =
`<div class="sgc-header">📦 条件组 —` +
`<select class="sgc-logic" onchange="refreshDesc(document.getElementById('saPresetConditions'),'saPresetDesc')">` +
`<option value="AND" ${group.logic === 'AND' ? 'selected' : ''}>全部满足(且)</option>` +
`<option value="OR" ${group.logic === 'OR' ? 'selected' : ''}>任一满足(或)</option>` +
`</select><span style="font-size:0.75rem;color:#888;font-weight:400;">— 组内条件</span></div>` +
`<div class="sgc-body"></div>` +
`<div class="sgc-footer">` +
`<button class="btn" onclick="addCond(this.closest('.sa-filter-group-card').querySelector('.sgc-body'), 'preset')">+ 条件</button>` +
`<button class="btn" onclick="addGroup(this.closest('.sa-filter-group-card').querySelector('.sgc-body'), 'preset')">+ 子组</button>` +
`<span class="remove-btn" onclick="this.closest('.sa-filter-group-card').remove();savePresetTree();refreshDesc(document.getElementById('saPresetConditions'),'saPresetDesc')">✕ 删除组</span></div>`;
const inner = card.querySelector('.sgc-body');
(group.items || []).forEach(item => {
if (item.type === 'group') appendSavedGroup(inner, item, sortedCols);
else appendSavedCond(inner, item, sortedCols);
});
container.appendChild(card);
}
function doUpload() {
if (SA._loading) return;
if (!_pendingFiles && !SA.scanId) { showError('请先选择文件'); return; }
const formData = new FormData();
// 如果有 pendingFiles,始终附上(upload API 需要 files
if (_pendingFiles) {
Array.from(_pendingFiles).forEach(f => formData.append('files', f));
}
// 传 scan_id 让后端复用已保存的文件,避免重复写盘
if (SA.scanId) {
formData.append('scan_id', SA.scanId);
}
// Serialize and save preset filter tree
savePresetTree();
const presetItems = serializeFilter(document.getElementById('saPresetConditions'));
if (presetItems.length) {
const tree = { type: 'group', logic: getLogic('preset'), items: presetItems };
formData.append('preset_filters', JSON.stringify({root: tree}));
}
setBusy(true, '正在上传并解析CSV(含预设筛选)...');
fetch('/simple/upload/', {
method: 'POST',
body: formData,
headers: {'X-CSRFToken': getCSRF()},
})
.then(r => r.json())
.then(data => {
setBusy(false);
if (data.error) { showError(data.error); return; }
SA.sessionId = data.session_id;
SA.colNames = data.columns || [];
SA.columnsStd = data.columns_std || data.columns || [];
SA.detectedCols = data.detected_columns || {};
const geoStats = data.geo_stats || {};
updateDebug({
status: '已上传',
total: data.total_rows,
preset: data.preset_filtered,
geo: `${geoStats.ips_resolved || 0} IPs 解析`,
note: data.preset_filtered < data.total_rows ? `预设筛选过滤 ${data.total_rows - data.preset_filtered}` : '',
});
setText('saTotalRows', data.total_rows.toLocaleString());
setText('saPresetFiltered', data.preset_filtered.toLocaleString());
// Detected columns
const dc = data.detected_columns || {};
const found = Object.entries(dc).filter(([,v]) => v).map(([k,v]) => `${k}${v}`);
setText('saDetectedCols', found.join('、') || '(无自动匹配)');
// Missing critical columns
const missing = Object.entries(dc).filter(([,v]) => !v).map(([k]) => k);
const mcEl = document.getElementById('saMissingCols');
if (mcEl) {
if (missing.length) {
mcEl.style.display = 'block';
mcEl.textContent = '未检测到列: ' + missing.join(', ');
} else {
mcEl.style.display = 'none';
mcEl.textContent = '';
}
}
const urEl = document.getElementById('saUploadResult');
if (urEl) { urEl.style.display = 'block'; urEl.scrollIntoView({behavior: 'smooth', block: 'start'}); }
showSuccess(`上传成功!共 ${data.total_rows.toLocaleString()} 条记录(预设筛选后 ${data.preset_filtered.toLocaleString()} 条)`);
})
.catch(err => { setBusy(false); showError('上传失败: ' + err.message); });
}
/* ── Step 2: Filter ───────────────────────────────────── */
function renderPreview(cols, rows) {
const hdr = document.getElementById('saPreviewHeader');
const body = document.getElementById('saPreviewBody');
if (!hdr || !body) return;
hdr.innerHTML = cols.map(c => `<th>${escapeHtml(c)}</th>`).join('');
body.innerHTML = rows.map(row => {
return '<tr>' + cols.map(c => {
const v = row[c];
return `<td title="${escapeHtml(v || '')}">${escapeHtml(v || '')}</td>`;
}).join('') + '</tr>';
}).join('');
}
/* ── Step 3: Cluster ──────────────────────────────────── */
function runCluster() {
const minClusterSize = parseInt(document.getElementById('saMinClusterSize').value);
const epsilonMeters = parseInt(document.getElementById('saEpsilon').value);
const epsilonRad = epsilonMeters / 6371000;
showLoading('正在构建IP通信网络并聚类...');
fetch('/simple/cluster/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify({
session_id: SA.sessionId,
min_cluster_size: minClusterSize,
cluster_selection_epsilon: epsilonRad,
}),
})
.then(r => r.json())
.then(data => {
hideLoading();
if (data.error) { showError(data.error); return; }
SA.nodes = data.nodes || [];
SA.edgesData = data.edges || [];
SA.clusterMap = data.clusters || {};
SA.clusterLabels = data.nodes ? data.nodes.map(n => n.cluster_id) : [];
SA.dataPoints = {};
setText('saNClusters', data.n_clusters);
setText('saNNoise', data.n_nodes || 0);
setText('saNValid', data.n_edges || 0);
const crEl = document.getElementById('saClusterResult');
if (crEl) { crEl.style.display = 'block'; crEl.scrollIntoView({behavior: 'smooth', block: 'start'}); }
renderClusterCards(data.clusters);
updateDebug({
status: '聚类完成',
mode: 'IP网络社区发现',
clusters: String(data.n_clusters),
noise_ratio: '—',
time: data.elapsed_seconds || '—',
note: data.note || '',
});
showSuccess(`${data.n_nodes} 个IP节点, ${data.n_edges} 条边, ${data.n_clusters} 个社区`);
})
.catch(err => { hideLoading(); showError('聚类失败: ' + err.message); });
}
function renderClusterCards(clusters) {
const el = document.getElementById('saClusterCards');
el.innerHTML = '';
if (!clusters || !Object.keys(clusters).length) return;
const sorted = Object.values(clusters).sort((a, b) => b.size - a.size);
const colors = generateColors(sorted.length);
sorted.forEach((c, idx) => {
const div = document.createElement('div');
div.className = 'sa-cluster-card';
const color = c.size <= 1 ? '#b0b0b0' : (colors[idx] || '#4361ee');
div.style.borderLeft = `4px solid ${color}`;
const label = c.size <= 1 ? '独立节点' : '社区 ' + c.label;
div.innerHTML = `
<div class="label" style="color:${color};">${label}</div>
<div class="size">${c.size} 个IP</div>
<div class="size">子网: ${c.subnet || '—'}</div>
`;
div.onclick = () => loadClusterDetail(c.label);
el.appendChild(div);
});
}
/* ── Step 4: Map (static SVG world map + large clickable points) ── */
// World map image path
const WORLD_MAP_URL = STATIC_WORLD_MAP;
// Subnet color palette (high-contrast against map background)
const SUBNET_COLORS = [
'#e63946','#2a9d8f','#f77f00','#7209b7','#4cc9f0','#f72585',
'#e76f51','#264653','#2ec4b6','#ff9f1c','#8338ec','#00b4d8',
'#d00000','#1b4332','#e36414','#3c096c','#0077b6','#bc4742',
'#606c38','#bc6c25','#b5838d','#6d6875','#457b9d','#e07a5f',
];
function getSubnetColor(ip) {
const parts = ip.split('.');
if (parts.length >= 3) {
const h = (parseInt(parts[0]) * 31 + parseInt(parts[1]) * 17 + parseInt(parts[2]) * 7);
return SUBNET_COLORS[Math.abs(h) % SUBNET_COLORS.length];
}
return SUBNET_COLORS[0];
}
/* ── World map: satellite tiles + vector overlay ────── */
function loadOfflineWorldMap(map) {
// Satellite tile providers (free, no API key)
const tileProviders = [
{
name: 'ESRI卫星',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attr: '&copy; Esri', maxZoom: 18,
},
{
name: 'CartoDB浅色',
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attr: '&copy; OSM', subdomains: 'abcd', maxZoom: 19,
},
];
// Offline vector fallback files
const landFiles = [
{ url: STATIC_MAPS + '/land-10m.json', obj: 'land', label: '10m' },
{ url: STATIC_MAPS + '/land-50m.json', obj: 'land', label: '50m' },
];
const countryFiles = [
{ url: STATIC_MAPS + '/countries-10m.json', obj: 'countries', label: '10m' },
{ url: STATIC_MAPS + '/countries-50m.json', obj: 'countries', label: '50m' },
];
function sanitizeName(name) {
if (!name) return '';
return name
.replace(/Å/g,'A').replace(/å/g,'a').replace(/Á/g,'A').replace(/á/g,'a')
.replace(/À/g,'A').replace(/à/g,'a').replace(/Â/g,'A').replace(/â/g,'a')
.replace(/Ã/g,'A').replace(/ã/g,'a').replace(/Ä/g,'A').replace(/ä/g,'a')
.replace(/Ç/g,'C').replace(/ç/g,'c').replace(/É/g,'E').replace(/é/g,'e')
.replace(/È/g,'E').replace(/è/g,'e').replace(/Ê/g,'E').replace(/ê/g,'e')
.replace(/Ë/g,'E').replace(/ë/g,'e').replace(/Í/g,'I').replace(/í/g,'i')
.replace(/Î/g,'I').replace(/î/g,'i').replace(/Ï/g,'I').replace(/ï/g,'i')
.replace(/Ñ/g,'N').replace(/ñ/g,'n').replace(/Ó/g,'O').replace(/ó/g,'o')
.replace(/Ô/g,'O').replace(/ô/g,'o').replace(/Õ/g,'O').replace(/õ/g,'o')
.replace(/Ö/g,'O').replace(/ö/g,'o').replace(/Ø/g,'O').replace(/ø/g,'o')
.replace(/Ú/g,'U').replace(/ú/g,'u').replace(/Ü/g,'U').replace(/ü/g,'u')
.replace(/Ý/g,'Y').replace(/ý/g,'y').replace(/Æ/g,'AE').replace(/æ/g,'ae')
.replace(/Œ/g,'OE').replace(/œ/g,'oe').replace(/ß/g,'ss');
}
let tileLayer = null;
let tileLoaded = false;
let vectorLoaded = false;
// ── Step 1: Try satellite tiles ──
function tryTile(providerIdx) {
if (providerIdx >= tileProviders.length) {
console.log('在线瓦片不可用,加载离线矢量地图');
loadVector(0);
return;
}
const p = tileProviders[providerIdx];
const opts = { attribution: p.attr, maxZoom: p.maxZoom };
if (p.subdomains) opts.subdomains = p.subdomains;
const testTile = p.url
.replace('{s}', p.subdomains ? p.subdomains[0] : 'a')
.replace('{z}', '2').replace('{y}', '1').replace('{x}', '1')
.replace('{r}', '');
fetch(testTile)
.then(r => { if (!r.ok) throw new Error(); return r.blob(); })
.then(() => {
tileLayer = L.tileLayer(p.url, opts).addTo(map);
tileLoaded = true;
console.log('在线地图: ' + p.name);
// Still load vector borders overlay
loadVector(0);
})
.catch(() => tryTile(providerIdx + 1));
}
// ── Step 2: Vector overlay (land + borders) ──
function loadVector(landIdx) {
if (landIdx >= landFiles.length) { return; }
fetch(landFiles[landIdx].url)
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(topo => {
if (!tileLoaded) {
// No satellite — opaque land fill
L.geoJSON(topojson.feature(topo, topo.objects.land), {
style: { fillColor: '#2a2a2a', color: '#444', weight: 0.5, fillOpacity: 1 },
interactive: false,
}).addTo(map);
map.getContainer().style.background = '#1a1a2e';
}
console.log('矢量陆地: ' + landFiles[landIdx].label);
loadBorders(0);
})
.catch(() => loadVector(landIdx + 1));
}
function loadBorders(idx) {
if (idx >= countryFiles.length) { vectorLoaded = true; return; }
fetch(countryFiles[idx].url)
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(topo => {
const geojson = topojson.feature(topo, topo.objects.countries);
const feats = geojson.features || [];
const isSat = tileLoaded;
L.geoJSON(geojson, {
style: {
fillColor: 'transparent', fillOpacity: 0,
color: isSat ? 'rgba(255,255,255,0.55)' : '#666',
weight: isSat ? 1.0 : 0.6,
opacity: isSat ? 0.7 : 0.5,
dashArray: isSat ? null : '3 2',
},
interactive: false,
}).addTo(map);
// Labels
const labelGroup = L.layerGroup();
feats.forEach(function(f) {
const name = sanitizeName((f.properties && f.properties.name) || '');
if (!name || name.length > 25) return;
let c = null;
const g = f.geometry;
if (g && g.type === 'Polygon') c = ringCenter(g.coordinates[0]);
else if (g && g.type === 'MultiPolygon') {
let m = 0;
for (const p of g.coordinates) {
if (p[0].length > m) { m = p[0].length; c = ringCenter(p[0]); }
}
}
if (c && Math.abs(c[0]) < 82) {
L.marker(c, { icon: L.divIcon({
className: 'sa-country-label' + (isSat ? ' sa-country-label-sat' : ''),
html: name, iconSize: null,
iconAnchor: [name.length * 3.5, 7],
}), interactive: false }).addTo(labelGroup);
}
});
map.on('zoomend', function() {
map.getZoom() >= 3 ? labelGroup.addTo(map) : map.removeLayer(labelGroup);
});
if (map.getZoom() >= 3) labelGroup.addTo(map);
vectorLoaded = true;
console.log('矢量国界: ' + countryFiles[idx].label + ', ' + feats.length + '国');
})
.catch(() => loadBorders(idx + 1));
}
function ringCenter(coords) {
let la = 0, lo = 0, n = coords.length;
for (let i = 0; i < n; i++) { la += coords[i][1]; lo += coords[i][0]; }
return n > 0 ? [la / n, lo / n] : null;
}
tryTile(0);
}
function initMap() {
const container = document.getElementById('saMap');
if (!container) return;
container.style.height = '550px';
container.style.width = '100%';
// Set ocean background color (world map rendered via L.imageOverlay)
container.style.background = '#1a1a2e'; // dark ocean (overridden by satellite tiles)
if (SA.map) { SA.map.invalidateSize(); renderMapData(); return; }
// Wait until container has real dimensions, max 5 seconds
let attempts = 0;
function tryCreateMap() {
if (container.offsetHeight > 10) {
SA.map = L.map('saMap', {
center: [20, 0], zoom: 2, minZoom: 2, maxZoom: 14,
worldCopyJump: true, preferCanvas: true,
attributionControl: false, zoomControl: true,
zoomAnimation: false,
});
SA.map.setMaxBounds([[-85, -180], [85, 180]]);
loadOfflineWorldMap(SA.map);
SA.map.on('zoomend moveend', () => renderMapData());
SA.map.invalidateSize();
renderMapData();
console.log('Map created. Height:', container.offsetHeight, 'Markers:', SA.pointLayer ? SA.pointLayer.getLayers().length : 0);
} else if (attempts < 50) {
attempts++;
setTimeout(tryCreateMap, 100);
} else {
// Fallback: force-create even with 0 height
SA.map = L.map('saMap', {
center: [20, 0], zoom: 2, minZoom: 2, maxZoom: 14,
worldCopyJump: true, preferCanvas: true,
attributionControl: false, zoomControl: true,
zoomAnimation: false,
});
SA.map.setMaxBounds([[-85, -180], [85, 180]]);
loadOfflineWorldMap(SA.map);
SA.map.on('zoomend moveend', () => renderMapData());
setTimeout(() => { SA.map.invalidateSize(); renderMapData(); }, 500);
console.log('Map created (fallback).');
}
}
tryCreateMap();
}
// ── Render IP nodes (large, clickable, high-contrast colors) ──
function renderMapData() {
if (!SA.map) return;
if (SA.pointLayer) SA.map.removeLayer(SA.pointLayer);
if (SA.centerLayer) SA.map.removeLayer(SA.centerLayer);
const nodes = SA.nodes || [];
const clusters = SA.clusterMap || {};
if (!nodes.length) return;
const bounds = SA.map.getBounds();
const markers = [];
const centerMarkers = [];
nodes.forEach(n => {
if (n.lat == null || n.lon == null) return;
if (!bounds.contains([n.lat, n.lon])) return;
if (SA.hiddenClusters.has(n.cluster_id)) return;
const color = getSubnetColor(n.ip);
const peerCount = n.peer_count || 0;
// High-degree servers: larger, brighter
// Singletons: smaller, dimmer
const isHighDeg = peerCount > 20;
const isSingleton = peerCount <= 1;
const radius = isHighDeg ? 10 : (isSingleton ? 4 : 7);
const fillOpacity = isHighDeg ? 0.95 : (isSingleton ? 0.5 : 0.85);
const weight = isHighDeg ? 3 : (isSingleton ? 1 : 2);
const borderColor = isHighDeg ? '#222' : (isSingleton ? '#ccc' : '#fff');
const circle = L.circleMarker([n.lat, n.lon], {
radius, fillColor: color, color: borderColor, weight,
opacity: 0.95, fillOpacity,
interactive: true, bubblingMouseEvents: false,
});
circle.bindTooltip(buildNodeTooltip(n), {sticky: true, direction: 'top', offset: [0, -8]});
circle.on('click', function(e) {
L.DomEvent.stopPropagation(e);
loadNodeDetail(n.ip);
});
markers.push(circle);
});
// Community center markers
const colors = generateColors(20);
const clusterColorMap = {};
Object.values(clusters).sort((a, b) => b.size - a.size).forEach((c, i) => {
clusterColorMap[c.label] = colors[i % colors.length];
});
Object.entries(clusters).forEach(([cid, cinfo]) => {
const cidInt = parseInt(cid);
if (cinfo.size <= 1 || SA.hiddenClusters.has(cidInt)) return;
if (cinfo.center_lat == null || cinfo.center_lon == null) return;
if (!bounds.contains([cinfo.center_lat, cinfo.center_lon])) return;
const color = clusterColorMap[cidInt] || '#4361ee';
const circle = L.circleMarker([cinfo.center_lat, cinfo.center_lon], {
radius: 10, fillColor: color, color: '#fff', weight: 3,
opacity: 0.95, fillOpacity: 0.7,
interactive: true, bubblingMouseEvents: false,
});
circle.bindTooltip(
`<div class="sa-point-tooltip"><b>社区 ${cidInt}</b><br>${cinfo.size} IP · ${cinfo.subnet||''}<br>点击查看详情</div>`,
{direction: 'top'}
);
circle.on('click', function(e) { L.DomEvent.stopPropagation(e); loadClusterDetail(cidInt); });
centerMarkers.push(circle);
});
SA.pointLayer = L.layerGroup(markers).addTo(SA.map);
SA.centerLayer = L.layerGroup(centerMarkers).addTo(SA.map);
renderLegend();
// Restore cluster focus if active
if (SA._clusterFocus && _clusterFocusLayer && !SA.map.hasLayer(_clusterFocusLayer)) {
_clusterFocusLayer.addTo(SA.map);
}
}
function buildNodeTooltip(n) {
const p = n.ip ? n.ip.split('.') : [];
const sn = p.length >= 3 ? p[0]+'.'+p[1]+'.'+p[2]+'.0/24' : '';
const pc = n.peer_count || 0;
const label = pc > 20 ? ' 🔥服务器' : (n.is_server ? ' 🖥' : '');
const degHint = pc > 0 ? `<span style="color:#e76f51;">${pc} 对端</span><br>` : '';
return `<div class="sa-point-tooltip"><b>${escapeHtml(n.ip)}</b>${label}<br>` +
(n.host ? `<span style="color:#2a9d8f;">${escapeHtml(n.host)}</span><br>` : '') +
degHint +
(sn ? `<span style="color:#888;">子网: ${sn}</span><br>` : '') +
`${escapeHtml(n.city||'')}, ${escapeHtml(n.country||'')}<br>` +
(n.isp ? `<span style="color:#666;">${escapeHtml(n.isp)}</span><br>` : '') +
(n.org ? `<span style="color:#666;">${escapeHtml(n.org)}</span><br>` : '') +
`<span style="color:#4361ee;">🖱 点击查看详情</span></div>`;
}
function renderLegend() {
const el = document.getElementById('saLegendItems');
const clusters = SA.clusterMap || {};
const sorted = Object.values(clusters).sort((a, b) => b.size - a.size);
if (!sorted.length) { el.innerHTML = ''; return; }
const colors = generateColors(sorted.length);
// Show top 20 communities + subnet info
const top = sorted.slice(0, 20);
const html = top.map((c, idx) => {
const color = colors[idx % colors.length];
const hidden = SA.hiddenClusters.has(c.label);
const label = c.size <= 1 ? '独立节点' : '社区 ' + c.label;
return `<div class="sa-legend-item${hidden ? ' hidden' : ''}"
onclick="toggleCluster(${c.label})">
<span class="color-dot" style="background:${color}"></span>
<span>${label}</span>
<span style="color:#999;font-size:0.75rem;">${c.size} IP · ${c.subnet || ''}</span>
</div>`;
});
if (sorted.length > 20) {
html.push(`<div style="font-size:0.75rem;color:#999;padding:0.3rem;">... 还有 ${sorted.length - 20} 个社区</div>`);
}
el.innerHTML = html.join('');
}
function toggleCluster(label) {
if (SA.hiddenClusters.has(label)) SA.hiddenClusters.delete(label);
else SA.hiddenClusters.add(label);
renderMapData();
}
/* ── Charts ───────────────────────────────────────────── */
function renderCharts() {
const clusters = SA.clusterMap || {};
const sorted = Object.values(clusters).sort((a, b) => b.size - a.size);
if (!sorted.length) return;
const labels = sorted.map(c => '社区 ' + c.label);
const sizes = sorted.map(c => c.size);
const colors = generateColors(sorted.length);
// Bar chart
const barCtx = document.getElementById('saBarChart');
if (!barCtx) return;
const barCtx2d = barCtx.getContext('2d');
if (SA.barChart) SA.barChart.destroy();
SA.barChart = new Chart(barCtx2d, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'IP数',
data: sizes,
backgroundColor: colors,
borderRadius: 4,
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
}
});
// Pie chart
const pieCtx = document.getElementById('saPieChart');
if (!pieCtx) return;
const pieCtx2d = pieCtx.getContext('2d');
if (SA.pieChart) SA.pieChart.destroy();
SA.pieChart = new Chart(pieCtx2d, {
type: 'pie',
data: {
labels: labels,
datasets: [{
data: sizes,
backgroundColor: colors,
borderWidth: 1,
borderColor: '#fff',
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'right', labels: { font: { size: 11 } } }
}
}
});
}
/* ── Node Detail ─────────────────────────────────────── */
function loadNodeDetail(ip) {
showLoading('加载节点详情...');
fetch(`/simple/node/?session_id=${SA.sessionId}&ip=${encodeURIComponent(ip)}`, {
headers: {'X-CSRFToken': getCSRF()},
})
.then(r => r.json())
.then(data => {
hideLoading();
if (data.error) { showError(data.error); return; }
showNodeDetailPanel(data);
})
.catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}
function showNodeDetailPanel(data) {
const panel = document.getElementById('saDetailPanel');
const body = document.getElementById('saDetailBody');
if (!panel || !body) return;
const n = data.node || {};
const edges = data.related_edges || [];
// Build edges HTML with click handlers (show peer_host, peer_org)
let edgeHtml = '';
if (edges.length) {
edgeHtml = `<div class="section-title">🔗 关联边 (${data.n_edges} 条) — 点击查看详情</div>
<div style="max-height:250px;overflow-y:auto;font-size:0.78rem;">`;
edges.slice(0, 30).forEach((e, i) => {
const peer = e.peer_ip || (e.source === n.ip ? e.target : e.source);
const dir = (e.source === n.ip || e.peer_ip === e.target) ? '→' : '←';
const peerHost = e.peer_host || e.dst_host || e.src_host || '';
const peerOrg = e.peer_org || '';
edgeHtml += `<div class="edge-row" data-edge-idx="${i}"
style="margin:0.2rem 0;padding:0.3rem 0.5rem;background:#f8faff;border-radius:4px;cursor:pointer;border:1px solid #e8ecf0;"
onmouseover="this.style.background='#eef2ff'" onmouseout="this.style.background='#f8faff'"
onclick="highlightEdge(${i});loadEdgeDetail(SA._highlightedNodeEdges[${i}])">
${dir} <b>${escapeHtml(peer)}</b>
${peerHost ? ` <span style="color:#888;font-size:0.75rem;">${escapeHtml(peerHost)}</span>` : ''}
&nbsp;· ${e.comm_count}
${e.total_bytes ? '&nbsp;· ' + (e.total_bytes/1024).toFixed(1) + ' KB' : ''}
</div>`;
});
if (edges.length > 30) edgeHtml += `<div style="color:#999;font-size:0.75rem;">... 还有 ${edges.length - 30} 条边</div>`;
edgeHtml += '</div>';
}
// Build texts section
const texts = data.node_texts || n.texts || [];
let textsHtml = '';
if (texts.length) {
textsHtml = `<div class="section-title">📝 关联文本</div>`;
texts.forEach(t => {
textsHtml += `<div style="font-size:0.75rem;padding:0.25rem 0.5rem;background:#fafbfc;border-radius:3px;margin:0.15rem 0;word-break:break-all;max-height:60px;overflow:hidden;">${escapeHtml(t)}</div>`;
});
}
// Build peer IP list
const peerIps = data.peer_ips || [];
let peersHtml = '';
if (peerIps.length) {
peersHtml = `<div class="section-title">👥 对端 IP (${data.n_peers || peerIps.length} 个)</div>
<div class="ip-list" style="max-height:150px;overflow-y:auto;">`;
peerIps.slice(0, 30).forEach(pip => {
peersHtml += `<span class="sa-tag" style="cursor:pointer;color:#4361ee;margin:0.1rem;" onclick="loadNodeDetail('${escapeHtml(pip)}')">${escapeHtml(pip)}</span>`;
});
if (peerIps.length > 30) peersHtml += `<div style="font-size:0.75rem;color:#999;">... 还有 ${peerIps.length - 30} 个</div>`;
peersHtml += '</div>';
}
const srvBadge = n.is_server ? ' 🖥 服务器' : '';
// Enriched node attributes
const attrToHtml = (items, title, icon) => {
if (!items || !items.length) return '';
return `<div class="section-title">${icon} ${title}</div>
<div>${items.slice(0,5).map(i => `<span class="sa-tag">${escapeHtml(i.value||i)} (${i.count||''})</span>`).join(' ')}</div>`;
};
// Traffic stats row
const totalBytes = n.total_bytes || data.total_flow_bytes || 0;
const totalPkts = n.total_packets;
const avgDur = n.avg_duration;
const connRate = n.conn_success_rate;
let trafficHtml = '<div class="stat-row">';
trafficHtml += `<div class="stat-card"><div class="num">${totalBytes ? (totalBytes/1024).toFixed(1)+' KB' : '—'}</div><div class="lbl">总流量</div></div>`;
if (totalPkts != null) trafficHtml += `<div class="stat-card"><div class="num">${totalPkts.toLocaleString()}</div><div class="lbl">总包数</div></div>`;
if (avgDur != null) trafficHtml += `<div class="stat-card"><div class="num">${avgDur}s</div><div class="lbl">平均持续时间</div></div>`;
trafficHtml += '</div>';
if (connRate != null || n.conn_count) {
trafficHtml += '<div class="stat-row">';
if (connRate != null) trafficHtml += `<div class="stat-card"><div class="num">${connRate}%</div><div class="lbl">连接成功率</div></div>`;
if (n.conn_count) trafficHtml += `<div class="stat-card"><div class="num">${n.conn_count}</div><div class="lbl">总连接数</div></div>`;
trafficHtml += '</div>';
}
// Time range
if (n.first_seen || n.last_seen) {
trafficHtml += '<div class="stat-row">';
if (n.first_seen) trafficHtml += `<div class="stat-card"><div class="num" style="font-size:0.7rem;">${n.first_seen.slice(0,19)}</div><div class="lbl">首次出现</div></div>`;
if (n.last_seen) trafficHtml += `<div class="stat-card"><div class="num" style="font-size:0.7rem;">${n.last_seen.slice(0,19)}</div><div class="lbl">末次出现</div></div>`;
trafficHtml += '</div>';
}
body.innerHTML =
`<div class="stat-row">` +
`<div class="stat-card"><div class="num" style="font-size:1rem;word-break:break-all;">${escapeHtml(n.ip)}${srvBadge}</div><div class="lbl">IP 地址</div></div>` +
(n.host ? `<div class="stat-card"><div class="num" style="font-size:0.8rem;">${escapeHtml(n.host)}</div><div class="lbl">Host</div></div>` : '') +
`</div>` +
`<div class="stat-row">` +
(n.city ? `<div class="stat-card"><div class="num">${escapeHtml(n.city)}</div><div class="lbl">城市</div></div>` : '') +
(n.country ? `<div class="stat-card"><div class="num">${escapeHtml(n.country)}</div><div class="lbl">国家</div></div>` : '') +
(n.isp ? `<div class="stat-card"><div class="num" style="font-size:0.85rem;">${escapeHtml(n.isp)}</div><div class="lbl">运营商</div></div>` : '') +
(n.org ? `<div class="stat-card"><div class="num" style="font-size:0.85rem;">${escapeHtml(n.org)}</div><div class="lbl">组织</div></div>` : '') +
`</div>` +
`<div class="stat-row">` +
`<div class="stat-card"><div class="num">${data.n_peers || 0}</div><div class="lbl">对端IP数</div></div>` +
`<div class="stat-card"><div class="num">${n.asn || '—'}</div><div class="lbl">ASN</div></div></div>` +
trafficHtml +
attrToHtml(n.top_snis, 'SNI / 域名', '🌐') +
attrToHtml(n.top_tls_vers, 'TLS 版本', '🔒') +
attrToHtml(n.top_ciphers, '加密套件', '🔑') +
attrToHtml(n.top_ports, '目的端口', '🔌') +
attrToHtml(n.top_categories, '分类标签', '🏷') +
textsHtml +
peersHtml +
edgeHtml;
setText('saDetailTitle', `节点: ${n.ip}`);
// Add traffic view button
const titleEl = document.getElementById('saDetailTitle');
if (titleEl) {
titleEl.innerHTML += ` <button class=\"btn\" style=\"font-size:0.7rem;padding:0.15rem 0.5rem;margin-left:0.5rem;\" onclick=\"showEntityTrafficView('${escapeHtml(n.ip)}')\">📡 动态流量</button>`;
}
panel.classList.add('open');
// Store edges for map highlighting
SA._highlightedNodeEdges = edges;
// Highlight edges on map
highlightNodeEdges();
}
// ── Highlight edges for selected node ────────────────────
function highlightNodeEdges() {
// Save old edge layer before replacing
if (_edgeLayer) {
_savedEdgeLayer = _edgeLayer;
SA.map.removeLayer(_edgeLayer);
}
const edges = SA._highlightedNodeEdges || [];
if (!edges.length) return;
const ipCoord = {};
(SA.nodes || []).forEach(n => {
if (n.lat != null && n.lon != null) ipCoord[n.ip] = [n.lat, n.lon];
});
const polys = [];
edges.forEach((e, i) => {
const src = ipCoord[e.source];
const dst = ipCoord[e.target];
if (!src || !dst) return;
const w = Math.max(2, Math.min((e.comm_count || 1) / 3 + 1, 5));
// 底层粗透明线用于点击捕获
const hitPoly = L.polyline([src, dst], {
color: '#ff3333', weight: 12, opacity: 0,
interactive: true, bubblingMouseEvents: false,
});
// 上层细线用于展示
const visPoly = L.polyline([src, dst], {
color: '#ff3333', weight: w, opacity: 0.8,
interactive: false, bubblingMouseEvents: true,
});
const pair = L.layerGroup([hitPoly, visPoly]);
hitPoly.bindTooltip(
`${escapeHtml(e.source)}${escapeHtml(e.target)}<br>` +
`${e.comm_count} 次 · ${(e.total_bytes/1024).toFixed(1)} KB` +
`<br><span style="color:#aaa;">🖱 点击查看详情</span>`,
{sticky: true, direction: 'top'}
);
hitPoly.on('click', function(ev) {
L.DomEvent.stopPropagation(ev);
loadEdgeDetail(e);
});
hitPoly.on('mouseover', function() {
visPoly.setStyle({ color: '#e60000', weight: Math.min(w + 2, 8), opacity: 1 });
});
hitPoly.on('mouseout', function() {
visPoly.setStyle({ color: '#ff3333', weight: w, opacity: 0.8 });
});
polys.push(pair);
});
if (polys.length) {
_edgeLayer = L.layerGroup(polys).addTo(SA.map);
// Fit map to show these edges
const allPts = [];
polys.forEach(p => {
const latlngs = p.getLayers()[0].getLatLngs();
allPts.push(latlngs[0]); allPts.push(latlngs[1]);
});
if (allPts.length) {
try { SA.map.fitBounds(L.latLngBounds(allPts), {padding: [50, 50], maxZoom: 8}); } catch(e) {}
}
}
}
function highlightEdge(idx) {
// Visual feedback when edge is clicked in the panel
if (!_edgeLayer) return;
const layers = _edgeLayer.getLayers();
layers.forEach((l, i) => {
// Each entry is a layerGroup containing [hitPoly, visPoly]
const children = l.getLayers ? l.getLayers() : [];
const visPoly = children.length >= 2 ? children[1] : children[0];
if (visPoly && visPoly.setStyle) {
visPoly.setStyle({
color: i === idx ? '#e60000' : '#ff6666',
weight: i === idx ? 6 : 2,
opacity: i === idx ? 1 : 0.3
});
}
});
}
/* ── Edge rendering on map ────────────────────────────── */
let _edgeLayer = null;
let _savedEdgeLayer = null; // Saved edge layer when node is highlighted
function renderEdgesOnMap() {
if (!SA.map) return;
// Don't overwrite highlighted node edges
if (SA._highlightedNodeEdges && SA._highlightedNodeEdges.length) return;
if (_edgeLayer) SA.map.removeLayer(_edgeLayer);
if (!document.getElementById('saShowEdges')?.checked) {
setText('saEdgeCount', '');
return;
}
if (!SA.edgesData || !SA.edgesData.length) {
setText('saEdgeCount', '0 条边');
return;
}
const nodes = SA.nodes || [];
const ipCoord = {};
nodes.forEach(n => {
if (n.lat != null && n.lon != null) ipCoord[n.ip] = [n.lat, n.lon];
});
const minFreq = parseInt(document.getElementById('saEdgeFreq')?.value || 1);
const maxCount = Math.max(...SA.edgesData.map(e => e.comm_count || 1), 1);
const edgePolys = [];
let shown = 0;
SA.edgesData.forEach(e => {
if ((e.comm_count || 0) < minFreq) return;
const srcCoord = ipCoord[e.source];
const dstCoord = ipCoord[e.target];
if (!srcCoord || !dstCoord) return;
if (shown >= 300) return;
shown++;
// 线宽:最小2px,最大6px,按频次占比缩放
const ratio = (e.comm_count || 1) / maxCount;
const weight = Math.max(2, Math.min(ratio * 5 + 1.5, 6));
const opacity = 0.45 + ratio * 0.35; // 0.45 ~ 0.80
// 底层的粗透明线 —— 用于点击捕获(不可见但可交互)
const hitPoly = L.polyline([srcCoord, dstCoord], {
color: '#ff6b6b', weight: 12, opacity: 0,
interactive: true, bubblingMouseEvents: false,
});
// 上层的细线 —— 用于展示
const visPoly = L.polyline([srcCoord, dstCoord], {
color: '#ff6b6b', weight: weight, opacity: opacity,
interactive: false, bubblingMouseEvents: true,
});
// 将两条线绑定为一个组
const pair = L.layerGroup([hitPoly, visPoly]);
hitPoly.bindTooltip(
`<b>${escapeHtml(e.source)}${escapeHtml(e.target)}</b><br>` +
`${e.comm_count} 次 · ${e.total_bytes ? (e.total_bytes/1024).toFixed(1)+'KB' : ''}` +
`<br><span style="color:#aaa;">🖱 点击查看详情</span>`,
{sticky: true, direction: 'top'}
);
hitPoly.on('click', function(ev) {
L.DomEvent.stopPropagation(ev);
loadEdgeDetail(e);
});
// Hover 高亮
hitPoly.on('mouseover', function() {
visPoly.setStyle({ color: '#e63946', weight: Math.min(weight + 2, 8), opacity: 0.95 });
});
hitPoly.on('mouseout', function() {
visPoly.setStyle({ color: '#ff6b6b', weight: weight, opacity: opacity });
});
edgePolys.push(pair);
});
if (edgePolys.length) _edgeLayer = L.layerGroup(edgePolys).addTo(SA.map);
setText('saEdgeCount', `${shown} 条边`);
}
function loadEdgeDetail(e) {
const panel = document.getElementById('saDetailPanel');
const body = document.getElementById('saDetailBody');
if (!panel || !body) return;
// Destroy previous chart if any
if (SA._edgeTimeChart) { SA._edgeTimeChart.destroy(); SA._edgeTimeChart = null; }
setText('saDetailTitle', `边详情`);
const titleEl2 = document.getElementById('saDetailTitle');
if (titleEl2) {
titleEl2.innerHTML += ` <button class=\"btn\" style=\"font-size:0.7rem;padding:0.15rem 0.5rem;margin-left:0.5rem;\" onclick=\"showEntityTrafficView('${escapeHtml(e.source)}')\">📡 源IP流量</button>`;
}
const toBeijing = (ts) => {
if (!ts) return '—';
try {
// Handle various timestamp formats
let clean = ts.replace(/\.\d+$/, '').replace(' ', 'T');
if (!clean.endsWith('Z') && !clean.includes('+')) clean += 'Z';
const d = new Date(clean);
if (isNaN(d.getTime())) return ts.slice(0, 19);
const bj = new Date(d.getTime() + 8 * 3600000);
return bj.toISOString().replace('T', ' ').slice(0, 19) + ' (北京)';
} catch(e) { return ts.slice(0, 19); }
};
const avgBytes = e.comm_count > 0 ? (e.total_bytes / e.comm_count).toFixed(1) : '—';
const timeCount = e.time_count || 0;
const peakHours = e.peak_hours || '';
const srcHost = e.src_host || '';
const dstHost = e.dst_host || '';
const srcOrg = e.src_org || '';
const dstOrg = e.dst_org || '';
const srcCity = e.src_city || '';
const dstCity = e.dst_city || '';
const srcCountry = e.src_country || '';
const dstCountry = e.dst_country || '';
// Enriched edge attributes helper
const edgeAttrToHtml = (items, title, icon) => {
if (!items || !items.length) return '';
return `<div class="section-title">${icon} ${title}</div>
<div>${items.slice(0,4).map(i => `<span class="sa-tag" style="font-size:0.75rem;">${escapeHtml(i.value||i)} (${i.count||''})</span>`).join(' ')}</div>`;
};
// Extra traffic stats row
const totalPkts = e.total_packets;
const avgDur = e.avg_duration;
let extraStats = '';
if (totalPkts != null || avgDur != null) {
extraStats = '<div class="stat-row">';
if (totalPkts != null) extraStats += `<div class="stat-card"><div class="num">${Number(totalPkts).toLocaleString()}</div><div class="lbl">总包数</div></div>`;
if (avgDur != null) extraStats += `<div class="stat-card"><div class="num">${avgDur}s</div><div class="lbl">平均持续时间</div></div>`;
extraStats += '</div>';
}
body.innerHTML =
`<div class="stat-row">` +
`<div class="stat-card"><div class="num">${e.comm_count}</div><div class="lbl">通信次数</div></div>` +
`<div class="stat-card"><div class="num">${(e.total_bytes/1024).toFixed(1)} KB</div><div class="lbl">总流量</div></div>` +
`<div class="stat-card"><div class="num">${avgBytes} B</div><div class="lbl">平均单次流量</div></div></div>` +
extraStats +
`<div class="stat-row">` +
`<div class="stat-card"><div class="num" style="font-size:0.8rem;">${toBeijing(e.first_seen)}</div><div class="lbl">首次通信</div></div>` +
`<div class="stat-card"><div class="num" style="font-size:0.8rem;">${toBeijing(e.last_seen)}</div><div class="lbl">末次通信</div></div></div>` +
(timeCount ? `<div class="stat-row"><div class="stat-card"><div class="num">${timeCount}</div><div class="lbl">时间样本数</div></div>` : '') +
(peakHours ? `<div class="stat-card"><div class="num">${peakHours}:00</div><div class="lbl">高峰时段 (UTC)</div></div>` : '') +
(timeCount || peakHours ? `</div>` : '') +
`<div class="section-title">源 IP</div>
<div><span class="sa-tag" style="cursor:pointer;color:#4361ee;" onclick="loadNodeDetail('${escapeHtml(e.source)}')">${escapeHtml(e.source)}</span>
${srcHost ? ` <span style="font-size:0.75rem;color:#666;">${escapeHtml(srcHost)}</span>` : ''}
${srcOrg ? ` <span style="font-size:0.75rem;color:#888;">· ${escapeHtml(srcOrg)}</span>` : ''}
${srcCity ? ` <span style="font-size:0.75rem;color:#999;">· ${escapeHtml(srcCity)}${srcCountry ? ', ' + escapeHtml(srcCountry) : ''}</span>` : ''}
</div>` +
`<div class="section-title">目的 IP</div>
<div><span class="sa-tag" style="cursor:pointer;color:#4361ee;" onclick="loadNodeDetail('${escapeHtml(e.target)}')">${escapeHtml(e.target)}</span>
${dstHost ? ` <span style="font-size:0.75rem;color:#666;">${escapeHtml(dstHost)}</span>` : ''}
${dstOrg ? ` <span style="font-size:0.75rem;color:#888;">· ${escapeHtml(dstOrg)}</span>` : ''}
${dstCity ? ` <span style="font-size:0.75rem;color:#999;">· ${escapeHtml(dstCity)}${dstCountry ? ', ' + escapeHtml(dstCountry) : ''}</span>` : ''}
</div>` +
edgeAttrToHtml(e.top_snis, 'SNI / 域名', '🌐') +
edgeAttrToHtml(e.top_tls_vers, 'TLS 版本', '🔒') +
edgeAttrToHtml(e.top_ciphers, '加密套件', '🔑') +
edgeAttrToHtml(e.top_ports, '目的端口', '🔌') +
edgeAttrToHtml(e.top_categories, '分类标签', '🏷') +
`<div class="section-title">📈 流量时序图 (北京时间)</div>
<div id="saEdgeTsChart" style="position:relative; width:100%; height:220px;">
<canvas id="saEdgeTimeCanvas" style="width:100%;height:100%;"></canvas>
</div>` +
`<div style="font-size:0.7rem;color:#999;text-align:center;margin-top:0.25rem;" id="saEdgeTsNote"></div>`;
panel.classList.add('open');
// ── Fetch time-series data ──
const tsContainer = document.getElementById('saEdgeTsChart');
const tsNote = document.getElementById('saEdgeTsNote');
if (!tsContainer || !tsNote) return;
tsNote.textContent = '加载中...';
fetch('/simple/edge-time-series/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify({session_id: SA.sessionId, source: e.source, target: e.target}),
})
.then(r => r.json())
.then(data => {
if (data.error) {
tsNote.textContent = '⚠ ' + data.error;
tsContainer.style.display = 'none';
return;
}
const series = data.time_series || [];
if (!series.length) {
tsNote.textContent = '无时间数据';
tsContainer.style.display = 'none';
return;
}
// Build config for dual-axis chart
const labels = series.map(d => d.t);
const countData = series.map(d => d.count);
const datasets = [{
label: '通信次数',
data: countData,
borderColor: '#4361ee',
backgroundColor: 'rgba(67,97,238,0.08)',
fill: true,
tension: 0.3,
pointRadius: series.length > 60 ? 0 : 2,
borderWidth: 2,
yAxisID: 'y',
}];
if (data.has_bytes) {
const bytesData = series.map(d => d.bytes / 1024);
datasets.push({
label: '流量 (KB)',
data: bytesData,
borderColor: '#e63946',
backgroundColor: 'rgba(230,57,70,0.06)',
fill: true,
tension: 0.3,
pointRadius: series.length > 60 ? 0 : 2,
borderWidth: 2,
yAxisID: 'y1',
});
}
const ctx = document.getElementById('saEdgeTimeCanvas');
if (!ctx) return;
SA._edgeTimeChart = new Chart(ctx.getContext('2d'), {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
display: true,
position: 'top',
labels: { boxWidth: 12, font: { size: 11 }, padding: 16 }
},
tooltip: {
callbacks: {
title: function(items) { return '🕐 ' + items[0].label + ' (北京)'; }
}
}
},
scales: {
x: {
display: true,
ticks: {
font: { size: 10 },
maxTicksLimit: series.length > 30 ? 12 : series.length,
maxRotation: 45,
},
grid: { display: false }
},
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: true, text: '次数', font: { size: 10 } },
beginAtZero: true,
ticks: { font: { size: 10 } },
grid: { color: '#f0f0f0' }
},
y1: {
type: 'linear',
display: data.has_bytes,
position: 'right',
title: { display: true, text: 'KB', font: { size: 10 } },
beginAtZero: true,
ticks: { font: { size: 10 } },
grid: { display: false }
}
}
}
});
const spanH = data.span_hours || 0;
const bucketM = data.bucket_minutes || 0;
tsNote.textContent = `${series.length} 个时间桶 · 跨度 ${spanH.toFixed(1)}h · 桶粒度 ${bucketM}min · ${data.n_raw} 条原始记录`;
})
.catch(err => {
tsNote.textContent = '⚠ 加载失败: ' + err.message;
tsContainer.style.display = 'none';
});
}
function toggleEdges() {
if (document.getElementById('saShowEdges')?.checked) {
renderEdgesOnMap();
} else {
if (_edgeLayer && SA.map) { SA.map.removeLayer(_edgeLayer); _edgeLayer = null; }
setText('saEdgeCount', '');
}
}
/* ── Export ────────────────────────────────────────────── */
function exportClusterCSV() {
// Export nodes CSV with all attributes
const nodes = SA.nodes || [];
if (!nodes.length) { showError('无数据可导出'); return; }
const headers = ['ip', 'cluster_id', 'lat', 'lon', 'city', 'country', 'isp', 'is_server',
'host', 'org', 'asn', 'peer_count', 'connected_ips'];
const rows = [headers.join(',')];
nodes.forEach(n => {
const peerIps = (n.peer_ips || []).join('; ');
rows.push([n.ip, n.cluster_id, n.lat || '', n.lon || '',
(n.city || '').replace(/,/g, ' '),
n.country || '', (n.isp || '').replace(/,/g, ' '),
n.is_server ? '1' : '0',
(n.host || '').replace(/,/g, ' '),
(n.org || '').replace(/,/g, ' '),
n.asn || '',
n.peer_count || 0,
peerIps].join(','));
});
downloadCSV(rows.join('\n'), `nodes_${SA.sessionId}.csv`);
}
function exportEdgesCSV() {
const edges = SA.edgesData || [];
if (!edges.length) { showError('无边数据可导出'); return; }
const headers = ['src_ip', 'dst_ip', 'comm_count', 'total_bytes', 'first_seen', 'last_seen',
'peak_hours', 'time_count',
'src_host', 'dst_host', 'src_org', 'dst_org',
'src_city', 'dst_city', 'src_country', 'dst_country',
'src_cluster', 'dst_cluster'];
const rows = [headers.join(',')];
edges.forEach(e => {
rows.push([e.source, e.target, e.comm_count, e.total_bytes || 0,
e.first_seen || '', e.last_seen || '',
e.peak_hours || '', e.time_count || 0,
(e.src_host || '').replace(/,/g, ' '), (e.dst_host || '').replace(/,/g, ' '),
(e.src_org || '').replace(/,/g, ' '), (e.dst_org || '').replace(/,/g, ' '),
e.src_city || '', e.dst_city || '',
e.src_country || '', e.dst_country || '',
e.src_cluster != null ? e.src_cluster : '',
e.dst_cluster != null ? e.dst_cluster : ''].join(','));
});
downloadCSV(rows.join('\n'), `edges_${SA.sessionId}.csv`);
}
function downloadCSV(csv, filename) {
const blob = new Blob(['' + csv], {type: 'text/csv;charset=utf-8;'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
showSuccess('导出成功: ' + filename);
}
/* ── Page init ─────────────────────────────────────────── */
(function() {
restorePreset();
// Check GeoIP precision
fetch('/simple/geoip-status/', {headers: {'X-CSRFToken': getCSRF()}})
.then(r => r.json())
.then(d => {
updateDebug({geo: d.source || '—'});
if (!d.using_mmdb) {
const el = document.getElementById('saDebugNote');
if (el) el.textContent = '提示: 放入 GeoLite2-City.mmdb 到 data/ 可获精确坐标';
}
})
.catch(() => {});
})();
/* ── Cluster detail + focus ────────────────────────────── */
let _clusterFocusLayer = null; // LayerGroup for cluster-focused edges
let _clusterNodeLayer = null; // LayerGroup for focused node markers
function loadClusterDetail(label) {
showLoading('加载集群详情...');
fetch('/simple/cluster-nodes-edges/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify({session_id: SA.sessionId, cluster_label: label}),
})
.then(r => r.json())
.then(data => {
hideLoading();
if (data.error) { showError(data.error); return; }
// Ensure map exists — go to step 4 if needed
if (!SA.map) {
goStep(4);
// Wait for map to finish initializing, then focus
let attempts = 0;
function waitThenFocus() {
if (SA.map) {
focusClusterOnMap(data);
showClusterDetailPanelV2(data);
} else if (attempts < 30) {
attempts++;
setTimeout(waitThenFocus, 200);
} else {
// Fallback: just show panel without map focus
showClusterDetailPanelV2(data);
}
}
setTimeout(waitThenFocus, 500);
} else {
focusClusterOnMap(data);
showClusterDetailPanelV2(data);
}
})
.catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}
function focusClusterOnMap(data) {
if (!SA.map) return;
clearClusterFocus();
// 1. Zoom to bounds
const bounds = data.geo_bounds;
if (bounds) {
try {
SA.map.fitBounds(
[[bounds.lat_min, bounds.lon_min], [bounds.lat_max, bounds.lon_max]],
{padding: [60, 60], maxZoom: 10}
);
} catch(e) {}
}
// 2. Highlight cluster nodes with larger markers
const clusterNodes = data.nodes || [];
const clusterIps = new Set(clusterNodes.map(n => n.ip));
const clusterColors = {};
clusterNodes.forEach(n => {
clusterColors[n.ip] = getSubnetColor(n.ip);
});
// Redraw all markers in focus mode
const allNodes = SA.nodes || [];
const markers = [];
const centerMarkers = [];
allNodes.forEach(n => {
if (n.lat == null || n.lon == null) return;
const inCluster = clusterIps.has(n.ip);
const color = inCluster ? (clusterColors[n.ip] || '#4361ee') : '#ccc';
const radius = inCluster ? 10 : 4;
const opacity = inCluster ? 1 : 0.35;
const circle = L.circleMarker([n.lat, n.lon], {
radius, fillColor: color, color: inCluster ? '#333' : '#ddd',
weight: inCluster ? 2 : 1, opacity, fillOpacity: opacity,
interactive: true, bubblingMouseEvents: false,
});
circle.bindTooltip(buildNodeTooltip(n), {sticky: true, direction: 'top', offset: [0, -8]});
circle.on('click', function(e) { L.DomEvent.stopPropagation(e); loadNodeDetail(n.ip); });
markers.push(circle);
});
// Community center markers for other clusters
const clusters = SA.clusterMap || {};
const colors = generateColors(20);
const clusterColorMap = {};
Object.values(clusters).sort((a, b) => b.size - a.size).forEach((c, i) => {
clusterColorMap[c.label] = colors[i % colors.length];
});
Object.entries(clusters).forEach(([cid, cinfo]) => {
const cidInt = parseInt(cid);
if (cidInt === data.cluster_label) return; // Skip focused cluster
if (cinfo.size <= 1 || SA.hiddenClusters.has(cidInt)) return;
if (cinfo.center_lat == null || cinfo.center_lon == null) return;
const color = clusterColorMap[cidInt] || '#4361ee';
const circle = L.circleMarker([cinfo.center_lat, cinfo.center_lon], {
radius: 8, fillColor: color, color: '#fff', weight: 2,
opacity: 0.7, fillOpacity: 0.5, interactive: true, bubblingMouseEvents: false,
});
circle.bindTooltip(`<b>社区 ${cidInt}</b><br>${cinfo.size} IP`, {direction: 'top'});
circle.on('click', function(e) { L.DomEvent.stopPropagation(e); loadClusterDetail(cidInt); });
centerMarkers.push(circle);
});
// Replace point layers
if (SA.pointLayer) SA.map.removeLayer(SA.pointLayer);
if (SA.centerLayer) SA.map.removeLayer(SA.centerLayer);
SA.pointLayer = L.layerGroup(markers).addTo(SA.map);
SA.centerLayer = L.layerGroup(centerMarkers).addTo(SA.map);
// 3. Render cluster edges (internal + external) as clickable
const allEdges = [...(data.internal_edges || []), ...(data.external_edges || [])];
if (allEdges.length && SA.map) {
const ipCoord = {};
(SA.nodes || []).forEach(n => {
if (n.lat != null && n.lon != null) ipCoord[n.ip] = [n.lat, n.lon];
});
const edgePolys = [];
allEdges.forEach((e, i) => {
const src = ipCoord[e.source];
const dst = ipCoord[e.target];
if (!src || !dst) return;
const isInternal = clusterIps.has(e.source) && clusterIps.has(e.target);
const w = Math.max(1.5, Math.min((e.comm_count || 1) / 5 + 1, 4));
const edgeColor = isInternal ? '#e63946' : '#f77f00';
const hitPoly = L.polyline([src, dst], {
color: edgeColor, weight: 10, opacity: 0,
interactive: true, bubblingMouseEvents: false,
});
const visPoly = L.polyline([src, dst], {
color: edgeColor, weight: w, opacity: 0.75,
interactive: false, bubblingMouseEvents: true,
});
const pair = L.layerGroup([hitPoly, visPoly]);
hitPoly.bindTooltip(
`${escapeHtml(e.source)}${escapeHtml(e.target)}<br>` +
`${e.comm_count} 次 · ${(e.total_bytes/1024).toFixed(1)} KB` +
`<br><span style="color:#aaa;">🖱 点击查看详情</span>`,
{sticky: true, direction: 'top'}
);
hitPoly.on('click', function(ev) {
L.DomEvent.stopPropagation(ev);
loadEdgeDetail(e);
});
hitPoly.on('mouseover', function() {
visPoly.setStyle({ color: '#e60000', weight: Math.min(w + 3, 8), opacity: 1 });
});
hitPoly.on('mouseout', function() {
visPoly.setStyle({ color: edgeColor, weight: w, opacity: 0.75 });
});
edgePolys.push(pair);
});
_clusterFocusLayer = L.layerGroup(edgePolys).addTo(SA.map);
}
// 4. Store focus state
SA._clusterFocus = data;
// Hide general edge layer if visible
if (_edgeLayer && SA.map) { SA.map.removeLayer(_edgeLayer); }
}
function clearClusterFocus() {
if (_clusterFocusLayer && SA.map) { SA.map.removeLayer(_clusterFocusLayer); _clusterFocusLayer = null; }
SA._clusterFocus = null;
SA._highlightedNodeEdges = null;
// Restore normal markers
if (SA.map && SA.nodes && SA.nodes.length) {
renderMapData();
}
// Restore general edge layer
if (_edgeLayer) {
_edgeLayer.addTo(SA.map);
} else if (document.getElementById('saShowEdges')?.checked) {
renderEdgesOnMap();
}
}
function showClusterDetailPanelV2(data) {
const panel = document.getElementById('saDetailPanel');
const body = document.getElementById('saDetailBody');
if (!panel || !body) return;
setText('saDetailTitle', `社区 ${data.cluster_label} 详情`);
const ci = data.cluster_info || {};
const bounds = data.geo_bounds;
let geoHtml = '';
if (bounds) {
geoHtml = `<div class="section-title">📍 地理范围</div>
<div style="font-size:0.78rem;">lat: ${bounds.lat_min} ~ ${bounds.lat_max}<br>lon: ${bounds.lon_min} ~ ${bounds.lon_max}</div>`;
}
const distToHtml = (items, title) => {
if (!items || !items.length) return '';
const maxC = Math.max(...items.map(i => i.count));
return `<div class="section-title">${title}</div>
<div>${items.slice(0,5).map(i =>
`<span class="sa-tag" style="font-size:${0.75 + 0.25*i.count/maxC}rem;">${escapeHtml(i.value)} (${i.count})</span>`
).join(' ')}</div>`;
};
// Internal edges list (clickable)
const intEdges = data.internal_edges || [];
let intEdgeHtml = '';
if (intEdges.length) {
intEdgeHtml = `<div class="section-title">🔴 内部边 (${intEdges.length})</div>
<div style="max-height:150px;overflow-y:auto;font-size:0.75rem;">`;
intEdges.slice(0, 20).forEach((e, i) => {
intEdgeHtml += `<div style="padding:0.15rem 0.3rem;cursor:pointer;border-radius:3px;"
onmouseover="this.style.background='#fef0f0'" onmouseout="this.style.background=''"
onclick="loadEdgeDetail(SA._clusterFocus.internal_edges[${i}])">
${escapeHtml(e.source)}${escapeHtml(e.target)} · ${e.comm_count}次 · ${(e.total_bytes/1024).toFixed(1)}KB</div>`;
});
if (intEdges.length > 20) intEdgeHtml += `<div style="color:#999;">... 还有 ${intEdges.length-20} 条</div>`;
intEdgeHtml += '</div>';
}
// External edges list (clickable)
const extEdges = data.external_edges || [];
let extEdgeHtml = '';
if (extEdges.length) {
extEdgeHtml = `<div class="section-title">🟠 外部关联边 (${extEdges.length})</div>
<div style="max-height:150px;overflow-y:auto;font-size:0.75rem;">`;
extEdges.slice(0, 20).forEach((e, i) => {
extEdgeHtml += `<div style="padding:0.15rem 0.3rem;cursor:pointer;border-radius:3px;"
onmouseover="this.style.background='#fff8f0'" onmouseout="this.style.background=''"
onclick="loadEdgeDetail(SA._clusterFocus.external_edges[${i}])">
${escapeHtml(e.source)}${escapeHtml(e.target)} · ${e.comm_count}次 · ${(e.total_bytes/1024).toFixed(1)}KB</div>`;
});
if (extEdges.length > 20) extEdgeHtml += `<div style="color:#999;">... 还有 ${extEdges.length-20} 条</div>`;
extEdgeHtml += '</div>';
}
// Node IPs (clickable)
const clusterNodes = data.nodes || [];
let ipHtml = `<div class="section-title">🔗 节点 (${clusterNodes.length})</div><div class="ip-list">`;
clusterNodes.slice(0, 50).forEach(n => {
ipHtml += `<span class="sa-tag" style="cursor:pointer;color:#4361ee;margin:0.1rem;"
onclick="loadNodeDetail('${escapeHtml(n.ip)}')">${escapeHtml(n.ip)}</span>`;
});
if (clusterNodes.length > 50) ipHtml += `<div style="font-size:0.75rem;color:#999;">... 还有 ${clusterNodes.length-50} 个</div>`;
ipHtml += '</div>';
// Adjacent nodes
const adjNodes = data.adjacent_nodes || [];
let adjHtml = '';
if (adjNodes.length) {
adjHtml = `<div class="section-title">🌐 邻接节点 (${adjNodes.length})</div><div class="ip-list">`;
adjNodes.slice(0, 30).forEach(n => {
adjHtml += `<span class="sa-tag" style="cursor:pointer;color:#e76f51;margin:0.1rem;"
onclick="loadNodeDetail('${escapeHtml(n.ip)}')">${escapeHtml(n.ip)}</span>`;
});
if (adjNodes.length > 30) adjHtml += `<div style="font-size:0.75rem;color:#999;">... 还有 ${adjNodes.length-30} 个</div>`;
adjHtml += '</div>';
}
body.innerHTML =
`<div style="margin-bottom:0.5rem;">
<button class="btn" onclick="clearClusterFocus()" style="font-size:0.75rem;padding:0.2rem 0.5rem;">🗺 恢复全局视图</button>
</div>
<div class="stat-row">
<div class="stat-card"><div class="num">${data.n_nodes || 0}</div><div class="lbl">节点数</div></div>
<div class="stat-card"><div class="num">${data.n_internal_edges || 0}</div><div class="lbl">内部边</div></div>
<div class="stat-card"><div class="num">${data.n_external_edges || 0}</div><div class="lbl">外部边</div></div>
</div>
<div class="stat-row">
<div class="stat-card"><div class="num">${(data.total_internal_bytes/1024).toFixed(1)} KB</div><div class="lbl">内部流量</div></div>
<div class="stat-card"><div class="num">${(data.total_external_bytes/1024).toFixed(1)} KB</div><div class="lbl">关联流量</div></div>
</div>
${geoHtml}
${distToHtml(data.top_isps, '🏢 ISP 分布')}
${distToHtml(data.top_cities, '🏙 城市分布')}
${distToHtml(data.top_countries, '🌍 国家分布')}
${intEdgeHtml}
${extEdgeHtml}
${ipHtml}
${adjHtml}`;
panel.classList.add('open');
}
/* ── Close detail ───────────────────────────────────────── */
function closeDetail() {
document.getElementById('saDetailPanel').classList.remove('open');
// Destroy edge time-series chart
if (SA._edgeTimeChart) { SA._edgeTimeChart.destroy(); SA._edgeTimeChart = null; }
// Clear cluster focus if active
if (SA._clusterFocus) { clearClusterFocus(); }
// Remove highlighted node edges, restore saved general edge layer
if (_edgeLayer && SA._highlightedNodeEdges && SA._highlightedNodeEdges.length) {
SA.map.removeLayer(_edgeLayer);
_edgeLayer = null;
}
SA._highlightedNodeEdges = null;
// Restore saved edge layer or re-render if toggle on
if (_savedEdgeLayer) {
_edgeLayer = _savedEdgeLayer;
_savedEdgeLayer = null;
_edgeLayer.addTo(SA.map);
} else if (document.getElementById('saShowEdges')?.checked) {
renderEdgesOnMap();
}
}
/* ── Reset ─────────────────────────────────────────────── */
function resetAll() {
SA.sessionId = null;
SA.scanId = null;
SA.nodes = [];
SA.edgesData = null;
SA.clusterLabels = [];
SA.clusterMap = {};
SA.dataPoints = {};
SA.hiddenClusters = new Set();
_pendingFiles = null;
if (_edgeLayer && SA.map) { SA.map.removeLayer(_edgeLayer); _edgeLayer = null; }
document.getElementById('saFileList').innerHTML = '';
document.getElementById('saUploadResult').style.display = 'none';
document.getElementById('saPresetFilterArea').style.display = 'none';
document.getElementById('saClusterResult').style.display = 'none';
document.getElementById('saPreviewWrap').querySelector('table').innerHTML = '';
if (SA.map) { SA.map.remove(); SA.map = null; }
if (SA.barChart) { SA.barChart.destroy(); SA.barChart = null; }
if (SA.pieChart) { SA.pieChart.destroy(); SA.pieChart = null; }
if (SA._edgeTimeChart) { SA._edgeTimeChart.destroy(); SA._edgeTimeChart = null; }
clearClusterFocus();
updateDebug({status: '等待上传'});
goStep(1);
}
/* ── Helpers ──────────────────────────────────────────── */
function getCSRF() {
const name = 'csrftoken';
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : '';
}
function generateColors(n) {
const palette = [
'#4361ee', '#43aa8b', '#f72585', '#f4a261', '#e76f51',
'#264653', '#2a9d8f', '#e9c46a', '#8ecae6', '#219ebc',
'#023047', '#ffb703', '#fb8500', '#6a4c93', '#ff6b6b',
'#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9',
];
return Array.from({length: n}, (_, i) => palette[i % palette.length]);
}
/* ── Traffic Flow Animation (crossfade window transitions) ── */
let _trafficData = null; // {windows:[{ts,edges,active_nodes}], nodes_coords, window_seconds, _minTs, _maxTs}
let _trafficAnimId = null;
let _trafficPlaying = false;
let _trafficSpeed = 1;
let _trafficCurrentTime = 0;
let _trafficPrevFrameTs = 0;
let _trafficSeekTarget = null;
// Canvas overlay
let _trafficCanvasId = 'saTrafficCanvas';
function toggleTrafficPanel() {
const panel = document.getElementById('saTrafficPanel');
if (!panel) return;
if (panel.classList.contains('open')) {
trafficStop();
panel.classList.remove('open');
_removeFlowCanvas();
} else {
panel.classList.add('open');
loadTrafficData();
}
}
function _ensureFlowCanvas() {
if (!SA.map) return null;
let c = document.getElementById(_trafficCanvasId);
if (!c) {
c = document.createElement('canvas');
c.id = _trafficCanvasId;
c.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:10000;';
SA.map.getContainer().appendChild(c);
}
const s = SA.map.getSize();
if (c.width !== s.x || c.height !== s.y) {
c.width = s.x; c.height = s.y;
c.style.width = s.x + 'px'; c.style.height = s.y + 'px';
}
return c;
}
function _removeFlowCanvas() {
cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null;
const c = document.getElementById(_trafficCanvasId);
if (c) c.remove();
}
function tsToBJ(ts) { const d = new Date(ts * 1000); return d.toISOString().replace('T',' ').slice(0,19); }
function loadTrafficData() {
if (!SA.sessionId) { showError('请先完成聚类分析'); return; }
showLoading('加载流通数据...');
fetch('/simple/traffic-timeline/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify({session_id: SA.sessionId, window_seconds: 10, max_edges: 300}),
})
.then(r => r.json())
.then(d => {
hideLoading();
if (d.error) { showError(d.error); return; }
// Edge positions computed fresh each frame — no cache needed
let ws = d.window_seconds || 10;
let minTs = d.min_ts || (d.windows[0]?.ts || 0);
let maxTs = d.max_ts || (d.windows[d.n_windows-1]?.ts_end || minTs + ws);
let wins = d.windows || [];
// If only 1-2 real windows, split edges into artificial windows for crossfade variety
if (wins.length <= 2 && wins.length > 0) {
const allEdges = [];
const seen = new Set();
wins.forEach(w => (w.edges||[]).forEach(e => {
const k = e.source + '|' + e.target;
if (!seen.has(k)) { seen.add(k); allEdges.push({...e}); }
}));
allEdges.sort((a,b) => (b.count||0) - (a.count||0));
// More windows with fewer edges each = more visible change
const nWindows = Math.min(20, Math.ceil(allEdges.length / 20));
const groupSize = Math.max(15, Math.ceil(allEdges.length / nWindows));
const newWins = [];
let ti = minTs;
for (let g = 0; g < allEdges.length; g += groupSize) {
const groupEdges = allEdges.slice(g, g + groupSize);
const activeNodes = new Set();
groupEdges.forEach(e => { activeNodes.add(e.source); activeNodes.add(e.target); });
newWins.push({ts: ti, t_label: tsToBJ(ti).slice(11,19), edges: groupEdges, active_nodes: [...activeNodes]});
ti += ws;
}
wins = newWins;
d.windows = wins;
maxTs = ti;
}
if (maxTs - minTs < 10) maxTs = minTs + Math.max(60, ws * 6);
_trafficData = d;
d._minTs = minTs; d._maxTs = maxTs; d._ws = ws;
_trafficCurrentTime = minTs;
document.getElementById('saTrafficSlider').min = 0;
document.getElementById('saTrafficSlider').max = 1000;
document.getElementById('saTrafficSlider').value = 0;
document.getElementById('saTrafficInfo').textContent =
(d.windows||[]).length + ' 窗口 · ' + Object.keys(d.nodes_coords||{}).length + ' 节点 · 跨度' + ((maxTs-minTs)/60).toFixed(0) + 'min';
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
trafficStart();
})
.catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}
function reloadTrafficData() { trafficStop(); loadTrafficData(); }
// ── getWindowMix: find window index + crossfade factor ──
function getWindowMix() {
if (!_trafficData) return {i:0, t:0};
const wins = _trafficData.windows || [];
if (!wins.length) return {i:0, t:0};
const ws = _trafficData._ws;
const minTs = _trafficData._minTs;
// Which window does currentTime fall into?
let idx = Math.floor((_trafficCurrentTime - minTs) / ws);
idx = Math.max(0, Math.min(wins.length - 1, idx));
// Crossfade: how far between this window's ts and next window's ts?
const wTs = wins[idx].ts;
const nextTs = idx + 1 < wins.length ? wins[idx+1].ts : wTs + ws;
let t = 0;
if (nextTs > wTs) t = Math.max(0, Math.min(1, (_trafficCurrentTime - wTs) / (nextTs - wTs)));
return {i: idx, t};
}
function trafficSeek(val) {
if (!_trafficData) return;
const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
_trafficSeekTarget = minTs + (val / 1000) * (maxTs - minTs);
}
function trafficStepForward() {
if (!_trafficData) return;
const ws = _trafficData._ws;
_trafficSeekTarget = Math.min(_trafficData._maxTs, _trafficCurrentTime + ws);
}
function trafficStepBack() {
if (!_trafficData) return;
const ws = _trafficData._ws;
_trafficSeekTarget = Math.max(_trafficData._minTs, _trafficCurrentTime - ws);
}
function trafficTogglePlay() { if (_trafficPlaying) trafficPause(); else trafficStart(); }
function trafficStart() {
if (!_trafficData || _trafficPlaying) return;
_trafficPlaying = true; _trafficPrevFrameTs = 0;
document.getElementById('saTrafficPlayBtn').textContent = '⏸ 暂停';
_trafficAnimId = requestAnimationFrame(trafficAnimate);
}
function trafficPause() {
_trafficPlaying = false;
document.getElementById('saTrafficPlayBtn').textContent = '▶ 播放';
cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null;
}
function trafficStop() {
trafficPause(); _trafficData = null;
_removeFlowCanvas();
document.getElementById('saTrafficTime').textContent = '--:--:--';
document.getElementById('saTrafficInfo').textContent = '';
if (SA.pointLayer) SA.pointLayer.getLayers().forEach(l => {
if (l.setStyle) try { l.setStyle({fillOpacity:0.85,opacity:0.95,weight:2,color:'#fff'}); } catch(e) {}
});
}
function trafficSetSpeed(speed, btn) {
_trafficSpeed = speed;
document.querySelectorAll('#saTrafficPanel button[data-speed]').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function trafficAnimate(timestamp) {
if (!_trafficPlaying || !_trafficData) return;
if (!_trafficPrevFrameTs) _trafficPrevFrameTs = timestamp;
const rawDt = (timestamp - _trafficPrevFrameTs) / 1000;
_trafficPrevFrameTs = timestamp;
const dt = Math.min(rawDt, 0.5) * _trafficSpeed;
const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
const span = Math.max(1, maxTs - minTs);
if (_trafficSeekTarget != null) {
_trafficCurrentTime = _trafficSeekTarget; _trafficSeekTarget = null;
} else {
_trafficCurrentTime += dt;
if (_trafficCurrentTime > maxTs) _trafficCurrentTime = minTs + ((_trafficCurrentTime - maxTs) % span);
}
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / span) * 1000;
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
renderTrafficCrossfade();
_trafficAnimId = requestAnimationFrame(trafficAnimate);
}
function renderTrafficCrossfade() {
const c = _ensureFlowCanvas();
if (!c || !SA.map || !_trafficData) return;
const ctx = c.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, c.width, c.height);
const wins = _trafficData.windows || [];
if (!wins.length) return;
const coords = _trafficData.nodes_coords || {};
const mix = getWindowMix();
const w0 = wins[mix.i] || null;
const w1 = mix.i + 1 < wins.length ? wins[mix.i + 1] : null;
const t = mix.t;
// Each frame: add w0 edges with fade-out, w1 edges with fade-in
// Only show current window edges — clean switch, no blending
// Brief fade-in for newly appearing edges
const curEdges = t < 0.5 ? (w0 ? w0.edges : []) : (w1 ? w1.edges : []);
const edgeMap = {};
curEdges.forEach(e => {
const k = e.source + '|' + e.target;
edgeMap[k] = {source:e.source, target:e.target, weight:1.0, count:e.count||0};
});
const edges = Object.values(edgeMap);
if (!edges.length) return;
const maxCount = Math.max(1, ...edges.map(e => e.count));
const activeNodes = new Set();
if (w0 && 1-t > 0.1) (w0.active_nodes||[]).forEach(n => activeNodes.add(n));
if (w1 && t > 0.1) (w1.active_nodes||[]).forEach(n => activeNodes.add(n));
document.getElementById('saTrafficInfo').textContent =
edges.length + ' 边 · ' + activeNodes.size + ' 节点 · ' + tsToBJ(_trafficCurrentTime);
edges.forEach(e => {
const sc = coords[e.source], dc = coords[e.target];
if (!sc || !dc) return;
const p1 = SA.map.latLngToContainerPoint([sc[0], sc[1]]);
const p2 = SA.map.latLngToContainerPoint([dc[0], dc[1]]);
if (!p1 || !p2) return;
const midX = (p1.x + p2.x) / 2;
const midY = (p1.y + p2.y) / 2 - Math.abs(p2.x - p1.x) * 0.1;
const ratio = Math.min(1, e.count / maxCount);
const alpha = 0.6 + 0.4 * ratio;
// Edge trace with src→dst color gradient
const grad = ctx.createLinearGradient(p1.x, p1.y, p2.x, p2.y);
grad.addColorStop(0, 'rgba(46,196,182,' + alpha.toFixed(2) + ')');
grad.addColorStop(1, 'rgba(230,57,70,' + alpha.toFixed(2) + ')');
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.quadraticCurveTo(midX, midY, p2.x, p2.y);
ctx.strokeStyle = grad;
ctx.lineWidth = 1.5 + 4 * ratio;
ctx.stroke();
// Flowing particles
const speedMul = 0.5 + 1.5 * ratio;
const nP = Math.ceil(3 + 6 * ratio);
for (let pi = 0; pi < nP; pi++) {
const pt = ((Date.now() / 2000 * speedMul + pi / nP) % 1);
const px = (1-pt)*(1-pt)*p1.x + 2*(1-pt)*pt*midX + pt*pt*p2.x;
const py = (1-pt)*(1-pt)*p1.y + 2*(1-pt)*pt*midY + pt*pt*p2.y;
const cr = Math.round(46 + 184*pt), cg = Math.round(196 - 139*pt), cb = Math.round(182 - 112*pt);
ctx.beginPath();
ctx.arc(px, py, 2 + 3*ratio, 0, Math.PI*2);
ctx.fillStyle = 'rgba(' + cr + ',' + cg + ',' + cb + ',' + (0.6+0.4*e.weight) + ')';
ctx.shadowColor = 'rgba(' + cr + ',' + cg + ',' + cb + ',0.6)';
ctx.shadowBlur = 5;
ctx.fill();
ctx.shadowBlur = 0;
}
});
if (SA.pointLayer && !SA._clusterFocus) {
SA.pointLayer.getLayers().forEach(l => {
if (!l.setStyle) return;
try {
const ct = l.getTooltip()?.getContent?.() || '';
const m = ct.match(/\b(\d+\.\d+\.\d+\.\d+)\b/);
if (m) l.setStyle(activeNodes.has(m[1])
? {fillOpacity:0.95,opacity:1,weight:3,color:'#fff'}
: {fillOpacity:0.2,opacity:0.3,weight:1,color:'#999'});
} catch(e) {}
});
}
}
/* ── Entity traffic view in detail panel ────────────── */
function showEntityTrafficView(ip) {
const panel = document.getElementById('saDetailPanel');
const body = document.getElementById('saDetailBody');
if (!panel || !body) return;
setText('saDetailTitle', `📡 动态流量: ${ip}`);
showLoading('加载实体流量数据...');
fetch('/simple/traffic-timeline/entity/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify({session_id: SA.sessionId, entity_ip: ip, window_seconds: 60}),
})
.then(r => r.json())
.then(d => {
hideLoading();
if (d.error) { showError(d.error); return; }
renderEntityTrafficPanel(ip, d);
})
.catch(err => { hideLoading(); showError(err.message); });
}
function renderEntityTrafficPanel(ip, data) {
const body = document.getElementById('saDetailBody');
if (!body) return;
if (SA._edgeTimeChart) { SA._edgeTimeChart.destroy(); SA._edgeTimeChart = null; }
const distHtml = (data.hourly_distribution || []).map(h =>
`<div style="display:inline-block;width:${Math.max(1,h.count/Math.max(...data.hourly_distribution.map(x=>x.count))*100)}%;height:12px;background:#4361ee;margin-right:1px;" title="${h.hour}时: ${h.count}次"></div>`
).join('');
body.innerHTML =
`<div class="stat-row">
<div class="stat-card"><div class="num">${data.n_peers||0}</div><div class="lbl">关联对端</div></div>
<div class="stat-card"><div class="num">${data.total_count||0}</div><div class="lbl">总通信次数</div></div>
<div class="stat-card"><div class="num">${(data.total_bytes/1024).toFixed(1)} KB</div><div class="lbl">总流量</div></div>
</div>
<div class="section-title">📊 24小时活跃分布 (北京时间)</div>
<div style="display:flex;align-items:flex-end;height:30px;gap:1px;">${distHtml}</div>
<div style="display:flex;justify-content:space-between;font-size:0.6rem;color:#999;margin-top:0.2rem;">
<span>0时</span><span>6时</span><span>12时</span><span>18时</span><span>23时</span></div>
<div class="section-title">🕐 逐窗口流入/流出</div>
<div style="max-height:180px;overflow-y:auto;font-size:0.75rem;">
<canvas id="saEntityTrafficChart" height="160"></canvas>
</div>
<div class="section-title">🌐 对端IP (${data.n_peers||0})</div>
<div>${(data.peers||[]).slice(0,30).map(p => `<span class="sa-tag" style="cursor:pointer;color:#4361ee;margin:0.1rem;" onclick="loadNodeDetail('${escapeHtml(p)}')">${escapeHtml(p)}</span>`).join(' ')}</div>`;
document.getElementById('saDetailPanel').classList.add('open');
// Render chart
const ctx = document.getElementById('saEntityTrafficChart');
if (!ctx) return;
const wins = data.windows || [];
const labels = wins.map(w => w.t_label);
const outData = wins.map(w => w.out_bytes / 1024);
const inData = wins.map(w => w.in_bytes / 1024);
SA._edgeTimeChart = new Chart(ctx.getContext('2d'), {
type: 'bar',
data: {
labels,
datasets: [
{label: '流出 KB', data: outData, backgroundColor: '#e63946', barPercentage: 0.8},
{label: '流入 KB', data: inData, backgroundColor: '#4361ee', barPercentage: 0.8},
]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { font: {size: 10}, boxWidth: 10 } } },
scales: {
x: { ticks: { font: {size: 8}, maxTicksLimit: 15, maxRotation: 45 } },
y: { beginAtZero: true, ticks: { font: {size: 9} } }
}
}
});
}
function escapeHtml(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<!-- Traffic Flow Panel (outside script block) -->
<div class="sa-traffic-panel" id="saTrafficPanel">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
<b style="font-size:0.9rem;">🌊 动态流通 (北京时间)</b>
<div style="display:flex;gap:0.5rem;align-items:center;">
<span style="font-size:0.75rem;">窗口:</span>
<select id="saTrafficWindow" onchange="reloadTrafficData()" style="background:#2a2d3a;color:#ccc;border:1px solid #444;border-radius:3px;padding:0.15rem 0.3rem;font-size:0.75rem;">
<option value="10">10s</option><option value="30">30s</option><option value="60" selected>1min</option><option value="300">5min</option><option value="600">10min</option>
</select>
<button onclick="toggleTrafficPanel()" style="background:transparent;color:#888;border:none;font-size:1.2rem;cursor:pointer;"></button>
</div>
</div>
<div class="sa-traffic-controls">
<button onclick="trafficStepBack()" title="后退"></button>
<button id="saTrafficPlayBtn" onclick="trafficTogglePlay()">▶ 播放</button>
<button onclick="trafficStepForward()" title="前进"></button>
<span style="font-size:0.75rem;">速度:</span>
<button data-speed="0.5" onclick="trafficSetSpeed(0.5,this)">0.5x</button>
<button data-speed="1" onclick="trafficSetSpeed(1,this)" class="active">1x</button>
<button data-speed="2" onclick="trafficSetSpeed(2,this)">2x</button>
<button data-speed="4" onclick="trafficSetSpeed(4,this)">4x</button>
<button data-speed="8" onclick="trafficSetSpeed(8,this)">8x</button>
<div class="sa-traffic-slider">
<input type="range" id="saTrafficSlider" min="0" max="0" value="0" oninput="trafficSeek(this.value)">
</div>
<span class="sa-traffic-time" id="saTrafficTime">--:--:--</span>
</div>
<div class="sa-traffic-info" id="saTrafficInfo"></div>
</div>
{% endblock %}