45034813ad
放弃交叉淡入淡出(两条边透明度叠加难以区分), 改为仅显示当前窗口的边(t<0.5→w0, t>=0.5→w1), 不同窗口不同边集一目了然。 Co-Authored-By: Claude <noreply@anthropic.com>
2973 lines
137 KiB
HTML
2973 lines
137 KiB
HTML
{% 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)">
|
||
下一步:筛选 → ▶
|
||
</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)">
|
||
下一步:地图可视化 → ▶
|
||
</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: '© Esri', maxZoom: 18,
|
||
},
|
||
{
|
||
name: 'CartoDB浅色',
|
||
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||
attr: '© 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>` : ''}
|
||
· ${e.comm_count} 次
|
||
${e.total_bytes ? ' · ' + (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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
</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 %}
|