{% extends 'base.html' %}
{% load static %}
{% block title %}简单分析 - 天璇{% endblock %}
{% block content %}
<style>
    /* ── Step progress bar ────────────────────────────── */
    .sa-progress {
        display: flex; justify-content: center; gap: 0; margin-bottom: 2rem;
        counter-reset: step;
    }
    .sa-progress .sp-item {
        flex: 1; text-align: center; position: relative; counter-increment: step;
        font-size: 0.85rem; color: #999; padding-top: 2rem;
    }
    .sa-progress .sp-item::before {
        content: counter(step);
        position: absolute; top: 0; left: 50%; transform: translateX(-50%);
        width: 28px; height: 28px; border-radius: 50%;
        background: #e0e0e0; color: #666;
        display: flex; align-items: center; justify-content: center;
        font-weight: 700; font-size: 0.85rem; z-index: 1;
    }
    .sa-progress .sp-item::after {
        content: ''; position: absolute; top: 14px; left: 50%; width: 100%;
        height: 2px; background: #e0e0e0; z-index: 0;
    }
    .sa-progress .sp-item:last-child::after { display: none; }
    .sa-progress .sp-item.active { color: #4361ee; font-weight: 600; }
    .sa-progress .sp-item.active::before { background: #4361ee; color: #fff; }
    .sa-progress .sp-item.done { color: #43aa8b; }
    .sa-progress .sp-item.done::before { background: #43aa8b; color: #fff; }
    .sa-progress .sp-item.done::after { background: #43aa8b; }

    /* ── Step sections ── */
    .sa-step { display: none; }
    .sa-step.active { display: block; }

    /* ── Upload zone ─────────────────────────────────── */
    .sa-dropzone {
        border: 2px dashed #ccc; border-radius: 12px; padding: 3rem 2rem;
        text-align: center; cursor: pointer; transition: all 0.2s;
        background: #fafcff;
    }
    .sa-dropzone:hover, .sa-dropzone.dragover { border-color: #4361ee; background: #eef2ff; }
    .sa-dropzone .icon { font-size: 3rem; margin-bottom: 0.5rem; }
    .sa-dropzone .hint { color: #999; font-size: 0.85rem; margin-top: 0.5rem; }
    .sa-file-list { margin-top: 1rem; }
    .sa-file-item {
        display: flex; align-items: center; gap: 0.5rem;
        padding: 0.4rem 0.6rem; background: #f5f5f5; border-radius: 4px;
        margin: 0.25rem 0; font-size: 0.85rem;
    }
    .sa-file-item .remove { color: #dc3545; cursor: pointer; margin-left: auto; font-weight: 700; }
    .sa-file-list-wrap { max-height: 180px; overflow-y: auto; margin-top: 0.5rem; }

    /* ── Preview table ───────────────────────────────── */
    .sa-preview-wrap {
        max-height: 400px; overflow: auto; border: 1px solid #eee; border-radius: 6px;
    }
    .sa-preview-wrap table { font-size: 0.75rem; }
    .sa-preview-wrap th { position: sticky; top: 0; background: #f5f5f5; z-index: 1; white-space: nowrap; }
    .sa-preview-wrap td { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

    /* ── Cluster results ─────────────────────────────── */
    .sa-cluster-grid {
        display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
        gap: 0.75rem; margin: 0.5rem 0; max-height: 400px; overflow-y: auto;
        padding-right: 0.25rem;
    }
    .sa-cluster-card {
        background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
        padding: 0.75rem; text-align: center; cursor: pointer;
        transition: all 0.15s;
    }
    .sa-cluster-card:hover { border-color: #4361ee; box-shadow: 0 2px 8px rgba(67,97,238,0.15); }
    .sa-cluster-card .label { font-size: 1.1rem; font-weight: 700; }
    .sa-cluster-card .size { font-size: 0.8rem; color: #666; }
    .sa-cluster-card.noise { border-style: dashed; opacity: 0.7; }

    /* ── Map layout ──────────────────────────────────── */
    .sa-map-row { display: flex; gap: 1rem; margin: 1rem 0; }
    .sa-map-container { flex: 1; height: 550px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; position: relative; }
    #saMap { position: relative; }
    #saTrafficCanvas { pointer-events: none !important; }
    .sa-legend { width: 240px; max-height: 550px; overflow-y: auto; padding: 0.5rem; background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; }
    .sa-legend-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; cursor: pointer; font-size: 0.85rem; }
    .sa-legend-item .color-dot { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; }
    .sa-legend-item.hidden { opacity: 0.3; }

    /* ── Cluster detail panel ────────────────────────── */
    .sa-detail-panel {
        position: fixed; top: 0; right: -520px; width: 500px; height: 100%;
        background: #fff; box-shadow: -4px 0 20px rgba(0,0,0,0.15);
        z-index: 9999; transition: right 0.3s; overflow-y: auto;
        padding: 1.5rem;
    }
    .sa-detail-panel.open { right: 0; }
    .sa-detail-panel .close-btn {
        float: right; font-size: 1.5rem; cursor: pointer; border: none;
        background: none; color: #666; padding: 0 0.25rem;
    }
    .sa-detail-panel .close-btn:hover { color: #dc3545; }
    .sa-detail-panel .stat-row { display: flex; gap: 1rem; margin: 0.75rem 0; }
    .sa-detail-panel .stat-card {
        flex: 1; background: #f5f7fa; border-radius: 6px; padding: 0.75rem; text-align: center;
    }
    .sa-detail-panel .stat-card .num { font-size: 1.5rem; font-weight: 700; color: #1a1a2e; }
    .sa-detail-panel .stat-card .lbl { font-size: 0.75rem; color: #666; }
    .sa-tag { display: inline-block; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.75rem; background: #e8f0fe; color: #4361ee; margin: 0.1rem; }
    .sa-detail-panel .ip-list { max-height: 200px; overflow-y: auto; font-size: 0.8rem; }
    .sa-detail-panel .section-title { font-size: 0.9rem; font-weight: 600; margin: 0.75rem 0 0.35rem; color: #1a1a2e; }

    /* ── Chart containers ────────────────────────────── */
    .sa-chart-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
    .sa-chart-box { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 1rem; }
    .sa-chart-box h4 { font-size: 0.9rem; margin-bottom: 0.5rem; color: #555; }

    /* ── Loading overlay ─────────────────────────────── */
    .sa-loading {
        display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
        background: rgba(0,0,0,0.35); z-index: 99999;
        justify-content: center; align-items: center;
    }
    .sa-loading.show { display: flex; }
    .sa-loading .box {
        background: #fff; padding: 2rem; border-radius: 12px; text-align: center;
        box-shadow: 0 8px 32px rgba(0,0,0,0.2);
    }
    .sa-spinner {
        width: 40px; height: 40px; border: 4px solid #e0e0e0;
        border-top-color: #4361ee; border-radius: 50%;
        animation: sa-spin 0.8s linear infinite; margin: 0 auto 1rem;
    }
    @keyframes sa-spin { to { transform: rotate(360deg); } }

    /* ── Fixed action bar ──────────────────────── */
    .sa-action-bar {
        position: sticky; top: 0; z-index: 9998;
        background: #fff; padding: 0.75rem 1rem; margin-bottom: 1rem;
        border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
        display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
    }
    .sa-action-bar button:disabled { opacity: 0.5; cursor: not-allowed; }

    /* ── Preset filter panel ───────────────────── */
    .sa-preset-panel {
        border: 1px solid #e0e0e0; border-radius: 8px; margin: 0.75rem 0;
        overflow: hidden;
    }
    .sa-preset-header {
        padding: 0.5rem 0.75rem; background: #f8faff; cursor: pointer;
        display: flex; justify-content: space-between; align-items: center;
        font-size: 0.85rem; font-weight: 600; user-select: none;
    }
    .sa-preset-body {
        padding: 0.75rem; display: none; background: #fff;
    }
    .sa-preset-body.open { display: block; }
    .sa-preset-row { display: flex; gap: 0.5rem; margin: 0.35rem 0; align-items: center; flex-wrap: wrap; }
    .sa-preset-row label { font-size: 0.8rem; min-width: 60px; color: #555; }
    .sa-preset-row select, .sa-preset-row input {
        padding: 0.25rem 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.8rem;
    }

    /* ── Simple card-based filter builder ────────── */
    .sa-filter-builder { margin: 0.5rem 0; }
    .sa-logic-toggle {
        display: flex; gap: 0; margin-bottom: 0.75rem; border-radius: 6px; overflow: hidden;
        border: 1px solid #d0d0d0; width: fit-content;
    }
    .sa-logic-toggle button {
        padding: 0.4rem 1rem; border: none; cursor: pointer; font-size: 0.85rem;
        font-weight: 600; background: #f5f5f5; color: #666; transition: all 0.15s;
    }
    .sa-logic-toggle button.active { background: #4361ee; color: #fff; }
    .sa-logic-toggle button:first-child { border-right: 1px solid #d0d0d0; }
    .sa-filter-row {
        display: flex; gap: 0.35rem; align-items: center; margin: 0.25rem 0;
        padding: 0.35rem 0.5rem; background: #fafbfc; border-radius: 4px;
        border: 1px solid #eee; flex-wrap: wrap;
    }
    .sa-filter-row select, .sa-filter-row input {
        padding: 0.3rem 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.8rem;
    }
    .sa-filter-row .remove-btn { color: #dc3545; cursor: pointer; font-weight: 700; font-size: 1.1rem; margin-left: auto; }
    .sa-filter-row .not-cb { font-size: 0.78rem; color: #888; display: flex; align-items: center; gap: 0.2rem; white-space: nowrap; cursor: pointer; user-select: none; }
    .sa-filter-row .not-cb input { cursor: pointer; }
    /* Filter group card */
    .sa-filter-group-card {
        margin: 0.5rem 0; border: 2px solid #c8d6e5; border-radius: 8px;
        overflow: hidden; background: #fff;
    }
    .sa-filter-group-card .sgc-header {
        display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem;
        background: #eef2f9; font-size: 0.82rem; font-weight: 600; color: #3d5a80;
    }
    .sa-filter-group-card .sgc-header select {
        padding: 0.15rem 0.35rem; border: 1px solid #c8d6e5; border-radius: 3px;
        font-size: 0.78rem; font-weight: 600; background: #fff;
    }
    .sa-filter-group-card .sgc-body { padding: 0.5rem 0.75rem; }
    .sa-filter-group-card .sgc-footer {
        display: flex; gap: 0.4rem; padding: 0.3rem 0.75rem; border-top: 1px solid #eef2f9;
        background: #fafbfc; font-size: 0.75rem;
    }
    .sa-filter-group-card .sgc-footer button { font-size: 0.73rem; padding: 0.15rem 0.5rem; }
    .sa-filter-group-card .sgc-footer .remove-btn { color: #dc3545; cursor: pointer; font-weight: 600; margin-left: auto; }
    .sa-filter-actions { display: flex; gap: 0.4rem; margin: 0.5rem 0; flex-wrap: wrap; }
    .sa-filter-actions button { font-size: 0.78rem; }
    .sa-filter-desc {
        font-size: 0.78rem; color: #666; margin-bottom: 0.5rem; padding: 0.35rem 0.6rem;
        background: #fafbfc; border-radius: 4px; border-left: 3px solid #4361ee;
        min-height: 1.2rem; line-height: 1.5;
    }

    /* ── Debug panel ───────────────────────────── */
    .sa-debug-panel {
        border: 1px solid #e0e0e0; border-radius: 8px; margin: 1rem 0;
        font-size: 0.78rem; font-family: monospace;
    }
    .sa-debug-header {
        padding: 0.4rem 0.75rem; background: #f5f5f5; cursor: pointer;
        display: flex; justify-content: space-between; align-items: center;
        font-weight: 600; user-select: none;
    }
    .sa-debug-body {
        padding: 0.75rem; display: none; max-height: 300px; overflow-y: auto;
        background: #1a1a2e; color: #a0ffa0; line-height: 1.5;
    }
    .sa-debug-body.open { display: block; }
    .sa-debug-body .row { display: flex; gap: 1rem; margin: 0.2rem 0; }
    .sa-debug-body .key { color: #88ddff; min-width: 140px; }
    .sa-debug-body .val { color: #fff; }

    /* ── Edge layer controls ───────────────────── */
    .sa-edge-controls {
        display: flex; gap: 0.5rem; align-items: center; margin: 0.5rem 0;
        padding: 0.5rem; background: #f8faff; border-radius: 6px; font-size: 0.85rem;
    }

    /* ── Point tooltip ─────────────────────────── */
    .sa-point-tooltip {
        font-size: 0.78rem; line-height: 1.5;
    }
    .sa-point-tooltip .tt-row { display: flex; gap: 0.5rem; }
    .sa-point-tooltip .tt-key { color: #888; min-width: 70px; }

    /* Country labels on world map */
    .sa-country-label {
        font-size: 9px; font-weight: 400; color: #5c5c4a;
        text-shadow: 0 0 3px #fff, 0 0 3px #fff;
        white-space: nowrap; pointer-events: none;
        letter-spacing: 0.5px;
    }
    /* Satellite-mode labels */
    .sa-country-label-sat {
        color: #fff;
        text-shadow: 0 1px 3px rgba(0,0,0,0.8), 0 0 6px rgba(0,0,0,0.5);
        font-weight: 500;
        letter-spacing: 1px;
    }

    /* ── Traffic flow animation ────────────────────────── */
    .sa-traffic-panel { display:none; position:fixed; bottom:0; left:0; right:0;
        background:rgba(15,18,25,0.95); color:#e0e0e0; z-index:2000;
        border-top:2px solid #4361ee; padding:0.5rem 1rem; max-height:280px; overflow-y:auto; }
    .sa-traffic-panel.open { display:block; }
    .sa-traffic-controls { display:flex; align-items:center; gap:1rem; flex-wrap:wrap; }
    .sa-traffic-controls button { background:#2a2d3a; color:#ccc; border:1px solid #444;
        border-radius:4px; padding:0.3rem 0.7rem; cursor:pointer; font-size:0.8rem; }
    .sa-traffic-controls button:hover { background:#4361ee; color:#fff; }
    .sa-traffic-controls button.active { background:#4361ee; color:#fff; }
    .sa-traffic-slider { flex:1; min-width:200px; }
    .sa-traffic-slider input { width:100%; }
    .sa-traffic-time { font-size:0.8rem; color:#888; min-width:60px; text-align:center; }
    .sa-traffic-info { font-size:0.75rem; color:#999; margin-top:0.3rem; }
    .sa-traffic-canvas { position:absolute; top:0; left:0; pointer-events:none; z-index:1000; }

    /* ── Responsive ──────────────────────────────────── */
    @media (max-width: 900px) {
        .sa-map-row { flex-direction: column; }
        .sa-legend { width: 100%; max-height: 150px; }
        .sa-chart-row { grid-template-columns: 1fr; }
        .sa-detail-panel { width: 100%; right: -100%; }
    }
</style>

<!-- ── Loading overlay ──────────────────────────────── -->
<div class="sa-loading" id="saLoading">
    <div class="box">
        <div class="sa-spinner"></div>
        <div id="saLoadingMsg" style="color:#666;">处理中...</div>
    </div>
</div>

<!-- ── Cluster detail panel ─────────────────────────── -->
<div class="sa-detail-panel" id="saDetailPanel">
    <button class="close-btn" onclick="closeDetail()">✕</button>
    <h3 id="saDetailTitle">簇详情</h3>
    <div id="saDetailBody"></div>
</div>

<div class="card">
    <h2>📊 简单分析</h2>
    <p style="color:#666;margin-bottom:1.5rem;">四步完成：上传CSV → 筛选 → 聚类 → 地图可视化</p>

    <!-- Progress bar -->
    <div class="sa-progress">
        <div class="sp-item active" data-step="1">上传CSV</div>
        <div class="sp-item" data-step="2">筛选数据</div>
        <div class="sp-item" data-step="3">聚类分析</div>
        <div class="sp-item" data-step="4">地图可视化</div>
    </div>

    <!-- Fixed action bar (sticky top) -->
    <div class="sa-action-bar" id="saActionBar">
        <span style="font-weight:600;color:#4361ee;" id="saStepIndicator">步骤 1：上传 CSV</span>
        <span id="saActionInfo" style="color:#999;font-size:0.85rem;"></span>
        <div style="margin-left:auto;display:flex;gap:0.4rem;">
            <button class="btn btn-primary" id="saBtnPrev" style="display:none;font-size:0.8rem;" onclick="goStep(parseInt(document.querySelector('.sa-step.active')?.id?.replace('saStep','')||2)-1)">← 上一步</button>
            <button class="btn btn-primary" id="saBtnNext" style="display:none;font-size:0.8rem;" onclick="goStep(parseInt(document.querySelector('.sa-step.active')?.id?.replace('saStep','')||1)+1)">下一步 →</button>
        </div>
    </div>

    <!-- ══════════ Step 1: Upload ══════════ -->
    <div class="sa-step active" id="saStep1">
        <h3 style="margin-bottom:0.75rem;">📂 步骤一：上传 CSV</h3>

        <div class="sa-dropzone" id="saDropzone"
             ondragover="event.preventDefault(); this.classList.add('dragover')"
             ondragleave="this.classList.remove('dragover')"
             ondrop="handleDrop(event)"
             onclick="document.getElementById('saFileInput').click()">
            <div class="icon">☁️</div>
            <div>拖拽 CSV 文件到此处，或 <a href="#" onclick="event.stopPropagation();document.getElementById('saFileInput').click();return false;">点击选择</a></div>
            <div class="hint">支持多文件，自动合并</div>
            <input type="file" id="saFileInput" accept=".csv" multiple
                   style="display:none" onchange="handleFiles(this.files)">
        </div>

        <div class="sa-file-list-wrap"><div class="sa-file-list" id="saFileList"></div></div>

        <!-- Preset filter builder (appears after quick scan, above file list) -->
        <div id="saPresetFilterArea" style="display:none;margin-top:0.75rem;">
            <div class="sa-preset-panel">
                <div class="sa-preset-header" onclick="this.nextElementSibling.classList.toggle('open')">
                    <span>⚙ 上传预设筛选（可选 — 筛选条件在上传时即生效，未匹配行直接丢弃）</span>
                    <span style="color:#999;font-size:0.75rem;">展开 ▼</span>
                </div>
                <div class="sa-preset-body open" id="saPresetBody">
                    <div class="sa-logic-toggle" id="saPresetLogicToggle">
                        <button class="active" data-logic="AND" onclick="setPresetLogic('AND', this)">全部满足（且）</button>
                        <button data-logic="OR" onclick="setPresetLogic('OR', this)">任一满足（或）</button>
                    </div>
                    <div class="sa-filter-desc" id="saPresetDesc" style="display:none;"></div>
                    <div id="saPresetConditions"></div>
                    <div class="sa-filter-actions">
                        <button class="btn" onclick="addCond(document.getElementById('saPresetConditions'), 'preset')">+ 条件</button>
                        <button class="btn" onclick="addGroup(document.getElementById('saPresetConditions'), 'preset')">+ 条件组</button>
                    </div>
                    <div style="font-size:0.75rem;color:#999;margin-top:0.35rem;">
                        筛选条件在上传时实时过滤，减少内存占用。
                        <span style="color:#4361ee;cursor:pointer;margin-left:0.5rem;" onclick="clearPresetMemory()">清除记忆</span>
                    </div>
                </div>
            </div>

            <button class="btn btn-primary" style="margin-top:0.75rem;font-size:1rem;" onclick="doUpload()" id="saBtnUpload">
                🚀 开始上传并应用预设筛选
            </button>
        </div>

        <div id="saUploadResult" style="display:none;margin-top:1rem;">
            <div class="card" style="background:#f8faff;">
                <div><strong>总记录数：</strong><span id="saTotalRows">0</span></div>
                <div><strong>预设筛选后：</strong><span id="saPresetFiltered">0</span></div>
                <div><strong>检测到的列：</strong><span id="saDetectedCols"></span></div>
                <div style="margin-top:0.5rem;" id="saMissingCols" class="badge badge-warning"></div>
            </div>

            <button class="btn btn-primary" style="margin-top:1rem;" onclick="goStep(2)">
                下一步：筛选 → &nbsp;▶
            </button>
        </div>
    </div>

    <!-- ══════════ Step 2: Advanced Filter ══════════ -->
    <div class="sa-step" id="saStep2">
        <h3 style="margin-bottom:0.5rem;">🔍 步骤二：筛选数据</h3>

        <div class="card" style="background:#f8faff;">
            <div style="font-size:0.85rem;">
                <strong>全部：</strong><span id="saFilterTotalBefore" style="font-weight:700;">0</span> 条
                <span style="margin-left:1.5rem;"><strong>筛选后：</strong></span>
                <span id="saFilteredCount" style="font-size:1.2rem;font-weight:700;color:#4361ee;">0</span> 条
            </div>
        </div>

        <!-- Simple card-based filter builder -->
        <div class="sa-filter-builder" id="saFilterBuilder">
            <div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.5rem;">
                <span style="font-weight:600;font-size:0.9rem;">筛选条件</span>
                <div class="sa-logic-toggle" id="saLogicToggle">
                    <button class="active" data-logic="AND" onclick="setFilterLogic('AND', this)">全部满足（且）</button>
                    <button data-logic="OR" onclick="setFilterLogic('OR', this)">任一满足（或）</button>
                </div>
            </div>

            <!-- Human-readable description -->
            <div class="sa-filter-desc" id="saFilterDesc" style="display:none;"></div>

            <div id="saFilterConditions"></div>
            <div class="sa-filter-actions">
                <button class="btn" onclick="addCond()">+ 条件</button>
                <button class="btn" onclick="addGroup()">+ 条件组</button>
                <button class="btn btn-primary" onclick="applyAdvancedFilter()" id="saBtnApplyFilter">▶ 应用筛选</button>
            </div>
        </div>

        <div id="saFilterError" style="display:none;margin-top:0.5rem;"></div>

        <h4 style="margin:0.75rem 0 0.35rem;">数据预览 <span style="font-size:0.75rem;color:#999;font-weight:400;">（前20行）</span></h4>
        <div class="sa-preview-wrap" id="saPreviewWrap">
            <table>
                <thead><tr id="saPreviewHeader"></tr></thead>
                <tbody id="saPreviewBody"></tbody>
            </table>
        </div>
    </div>

    <!-- ══════════ Step 3: Cluster ══════════ -->
    <div class="sa-step" id="saStep3">
        <h3 style="margin-bottom:0.75rem;">🧩 步骤三：IP 网络社区发现</h3>

        <div class="card" style="background:#f8faff;">
            <p style="color:#666;font-size:0.85rem;margin-bottom:0.75rem;">
                基于 IP 通信网络构建图（节点=唯一IP，边=通信关系），按 /24 子网前缀分组后使用 HDBSCAN 地理聚类发现社区。
            </p>

            <div id="saClusterParams">
                <div class="param-row" style="margin:0.5rem 0;">
                    <label style="font-size:0.85rem;">最小社区规模 (min_cluster_size)：</label>
                    <input type="range" id="saMinClusterSize" min="1" max="50" value="1"
                           oninput="document.getElementById('saMinClusterVal').textContent=this.value">
                    <span id="saMinClusterVal" style="font-weight:700;min-width:30px;">5</span>
                    <span style="font-size:0.75rem;color:#999;margin-left:0.5rem;">小于此规模的子网直接合并为一个社区</span>
                </div>
                <div class="param-row" style="margin:0.5rem 0;">
                    <label style="font-size:0.85rem;">聚合半径 (epsilon_radians)：</label>
                    <input type="range" id="saEpsilon" min="100" max="2000" value="500" step="50"
                           oninput="document.getElementById('saEpsilonVal').textContent=this.value + 'm'">
                    <span id="saEpsilonVal" style="font-weight:700;min-width:50px;">500m</span>
                    <span style="font-size:0.75rem;color:#999;margin-left:0.5rem;">同一子网内多近算同一社区</span>
                </div>
            </div>

            <button class="btn btn-primary" onclick="runCluster()" style="margin-top:0.5rem;">
                🚀 开始分析
            </button>
        </div>

        <div id="saClusterResult" style="display:none;">
            <div class="card" style="background:#f0faf0;margin-top:0.75rem;">
                <div class="grid-3">
                    <div><strong>社区数</strong><div style="font-size:1.5rem;font-weight:700;" id="saNClusters">0</div></div>
                    <div><strong>节点数</strong><div style="font-size:1.5rem;font-weight:700;" id="saNNoise">0</div></div>
                    <div><strong>边数</strong><div style="font-size:1.5rem;font-weight:700;" id="saNValid">0</div></div>
                </div>
            </div>

            <button class="btn btn-primary" style="margin-bottom:0.5rem;" onclick="goStep(4)">
                下一步：地图可视化 → &nbsp;▶
            </button>
            <button class="btn" style="margin-bottom:0.5rem;margin-left:0.5rem;" onclick="goStep(2)">← 返回</button>

            <div class="sa-cluster-grid" id="saClusterCards"></div>
        </div>
    </div>

    <!-- ══════════ Step 4: Map ═════════════ -->
    <div class="sa-step" id="saStep4">
        <h3 style="margin-bottom:0.75rem;">🗺️ 步骤四：可视化交互地图</h3>

        <div class="sa-map-row">
            <div class="sa-map-container" id="saMap"></div>
            <div class="sa-legend" id="saLegend">
                <div style="font-weight:600;margin-bottom:0.5rem;">簇图例</div>
                <div id="saLegendItems"></div>
            </div>
        </div>

        <!-- Edge layer controls -->
        <div class="sa-edge-controls" id="saEdgeControls" style="display:none;">
            <span style="font-weight:600;">🔗 边关系</span>
            <label style="font-size:0.8rem;">显示IP通联：</label>
            <input type="checkbox" id="saShowEdges" onchange="toggleEdges()">
            <label style="font-size:0.8rem;">最低频次：</label>
            <input type="range" id="saEdgeFreq" min="1" max="50" value="1"
                   oninput="document.getElementById('saEdgeFreqVal').textContent=this.value;renderEdgesOnMap();">
            <span id="saEdgeFreqVal" style="font-size:0.8rem;font-weight:700;">1</span>
            <span id="saEdgeCount" style="color:#999;font-size:0.78rem;"></span>
        </div>

        <div style="margin:0.75rem 0;text-align:center;">
            <button class="btn" onclick="exportClusterCSV()">📥 导出点表 (CSV)</button>
            <button class="btn" style="margin-left:0.5rem;" onclick="exportEdgesCSV()">📤 导出边表 (CSV)</button>
            <button class="btn btn-primary" style="margin-left:0.5rem;" onclick="toggleTrafficPanel()">🌊 动态流通</button>
            <button class="btn" style="margin-left:0.5rem;" onclick="resetAll()">🔄 重新开始</button>
        </div>

        <div class="sa-chart-row" id="saCharts">
            <div class="sa-chart-box">
                <h4>簇记录数柱状图</h4>
                <canvas id="saBarChart" height="200"></canvas>
            </div>
            <div class="sa-chart-box">
                <h4>簇内流量占比</h4>
                <canvas id="saPieChart" height="200"></canvas>
            </div>
        </div>
    </div>

    <!-- Debug info panel (always visible) -->
    <div class="sa-debug-panel" id="saDebugPanel">
        <div class="sa-debug-header" onclick="this.nextElementSibling.classList.toggle('open')">
            <span>🐛 调试信息</span>
            <span style="color:#999;font-size:0.75rem;">点击展开 ▼</span>
        </div>
        <div class="sa-debug-body" id="saDebugBody">
            <div class="row"><span class="key">状态</span><span class="val" id="saDebugStatus">等待上传</span></div>
            <div class="row"><span class="key">总记录数</span><span class="val" id="saDebugTotal">0</span></div>
            <div class="row"><span class="key">预设筛选后</span><span class="val" id="saDebugPreset">—</span></div>
            <div class="row"><span class="key">高级筛选后</span><span class="val" id="saDebugFiltered">—</span></div>
            <div class="row"><span class="key">GeoIP 解析</span><span class="val" id="saDebugGeo">—</span></div>
            <div class="row"><span class="key">聚类模式</span><span class="val" id="saDebugMode">—</span></div>
            <div class="row"><span class="key">簇数量</span><span class="val" id="saDebugClusters">—</span></div>
            <div class="row"><span class="key">单节点簇</span><span class="val" id="saDebugNoise">—</span></div>
            <div class="row"><span class="key">耗时</span><span class="val" id="saDebugTime">—</span></div>
            <div class="row"><span class="key">备注</span><span class="val" id="saDebugNote">—</span></div>
        </div>
    </div>
</div>

<!-- ── Leaflet + Chart.js + TopoJSON (local vendor assets) ── -->
<link rel="stylesheet" href="{% static 'simple_analysis/vendor/leaflet.css' %}" />
<script src="{% static 'simple_analysis/vendor/leaflet.js' %}"></script>
<script src="{% static 'simple_analysis/vendor/chart.umd.min.js' %}"></script>
<script src="{% static 'simple_analysis/maps/topojson-client.min.js' %}"></script>

<script>
/* ── Static URL paths for offline assets ─────────────── */
const STATIC_MAPS = '{% static "simple_analysis/maps" %}';
const STATIC_WORLD_MAP = '{% static "simple_analysis/world_map.svg" %}';
/* ── State ────────────────────────────────────────────── */
const SA = {
    sessionId: null,
    scanId: null,          // quick-scan id for lazy column-value queries
    colNames: [],
    detectedCols: {},
    columnsStd: [],  // standardized column names
    nodes: [],
    clusterLabels: [],
    clusterMap: {},
    dataPoints: {},
    map: null,
    pointLayer: null,
    centerLayer: null,
    edgeLayer: null,
    edgesData: null,
    barChart: null,
    pieChart: null,
    hiddenClusters: new Set(),
    filterConditions: [],  // [{col, op, val, logic}, ...]
    _loading: false,       // anti-duplicate
};

/* ── Navigation ──────────────────────────────────────── */
function goStep(n) {
    if (n === 2 && SA.sessionId) {
        initFilterBuilder();
    }
    if (n === 4 && (!SA.clusterMap || !Object.keys(SA.clusterMap).length)) {
        showError('请先完成聚类分析（步骤三）');
        return;
    }
    document.querySelectorAll('.sa-step').forEach(el => el.classList.remove('active'));
    const stepEl = document.getElementById('saStep' + n);
    if (stepEl) stepEl.classList.add('active');
    document.querySelectorAll('.sp-item').forEach(el => {
        const step = parseInt(el.dataset.step);
        el.classList.remove('active', 'done');
        if (step === n) el.classList.add('active');
        else if (step < n) el.classList.add('done');
    });

    const names = {1:'上传CSV',2:'筛选数据',3:'聚类分析',4:'地图可视化'};
    setText('saStepIndicator', `步骤 ${n}：${names[n]||''}`);
    const prevBtn = document.getElementById('saBtnPrev');
    const nextBtn = document.getElementById('saBtnNext');
    if (prevBtn) { prevBtn.style.display = n > 1 ? 'inline-block' : 'none'; prevBtn.disabled = false; }
    if (nextBtn) { nextBtn.style.display = n < 4 ? 'inline-block' : 'none'; nextBtn.disabled = false; }

    if (n === 4) {
        initMap();
        renderCharts();
        const edgeControls = document.getElementById('saEdgeControls');
        if (edgeControls) edgeControls.style.display = 'flex';
    }
    window.scrollTo(0, 0);
}

/* ── Debounce / Anti-duplicate ─────────────────────────── */
function setBusy(busy, msg) {
    SA._loading = busy;
    const loadingEl = document.getElementById('saLoading');
    const msgEl = document.getElementById('saLoadingMsg');
    if (busy) {
        if (msgEl) msgEl.textContent = msg || '处理中...';
        if (loadingEl) loadingEl.classList.add('show');
        try {
            document.querySelectorAll('.sa-action-bar button, #saBtnApplyFilter, .sa-filter-actions button').forEach(b => { if (b) b.disabled = true; });
        } catch(e) {}
    } else {
        if (loadingEl) loadingEl.classList.remove('show');
        try {
            document.querySelectorAll('.sa-action-bar button, #saBtnApplyFilter, .sa-filter-actions button').forEach(b => { if (b) b.disabled = false; });
        } catch(e) {}
    }
}
function showLoading(msg) { setBusy(true, msg); }
function hideLoading() { setBusy(false); }

/* ── Safe textContent setter ────────────────────────────── */
function setText(id, val) {
    const el = document.getElementById(id);
    if (el) el.textContent = val != null ? String(val) : '—';
}

/* ── Debug panel update ────────────────────────────────── */
function updateDebug(info) {
    if (!info) return;
    setText('saDebugStatus', info.status || '—');
    if (info.total !== undefined) setText('saDebugTotal', info.total.toLocaleString());
    if (info.preset !== undefined) setText('saDebugPreset', info.preset.toLocaleString());
    if (info.filtered !== undefined) setText('saDebugFiltered', info.filtered.toLocaleString());
    if (info.geo !== undefined) setText('saDebugGeo', info.geo);
    if (info.mode !== undefined) setText('saDebugMode', info.mode);
    if (info.clusters !== undefined) setText('saDebugClusters', info.clusters);
    if (info.noise_ratio !== undefined) setText('saDebugNoise', info.noise_ratio);
    if (info.time !== undefined) setText('saDebugTime', info.time + 's');
    if (info.note !== undefined) setText('saDebugNote', info.note);
}

/* ── Preset filter restore (old format migration) ────────── */
function restorePreset() {
    // Migrate old-format preset to new tree format
    try {
        const saved = localStorage.getItem('sa_preset');
        if (saved) {
            const p = JSON.parse(saved);
            const items = [];
            if (p.cnrs) items.push({type: 'condition', col: 'cnrs', op: 'eq', val: p.cnrs, logic: ''});
            if (p.isrs) items.push({type: 'condition', col: 'isrs', op: 'eq', val: p.isrs, logic: ''});
            if (p.cnam) items.push({type: 'condition', col: 'cnam', op: 'contains', val: p.cnam, logic: ''});
            if (p.snam) items.push({type: 'condition', col: 'snam', op: 'contains', val: p.snam, logic: ''});
            if (items.length) {
                localStorage.setItem('sa_preset_tree', JSON.stringify({type: 'group', logic: 'AND', items: items}));
            }
            localStorage.removeItem('sa_preset');  // Migrated
        }
    } catch(e) {}
}

/* ── Simple filter builder (card-based, AND/OR toggle, NOT checkbox) ── */

const OPS = [
    ['eq', '等于'], ['neq', '不等于'], ['contains', '包含'], ['not_contains', '不含'],
    ['gt', '大于'], ['lt', '小于'], ['gte', '≥'], ['lte', '≤'],
    ['is_empty', '为空'], ['not_empty', '非空'],
];

function initFilterBuilder() {
    const c = document.getElementById('saFilterConditions');
    if (!c || c.children.length > 0) return;
    addCond(c);
}

/* ── Logic toggle ────────────────────────────────────── */
function setFilterLogic(logic, btn) {
    btn.parentElement.querySelectorAll('button').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    refreshDesc(document.getElementById('saFilterConditions'), 'saFilterDesc');
}

function getLogic(scope) {
    if (scope === 'preset') {
        const b = document.querySelector('#saPresetLogicToggle .active');
        return b ? b.dataset.logic : 'AND';
    }
    const b = document.querySelector('#saLogicToggle .active');
    return b ? b.dataset.logic : 'AND';
}

function setPresetLogic(logic, btn) {
    setFilterLogic(logic, btn);
}

/* ── Op change (disable value for is_empty / not_empty) ── */
function onFilterOpChange(opSel) {
    const row = opSel.closest('.sa-filter-row');
    const valSel = row.querySelector('.sa-filter-val');
    if (!valSel) return;
    if (opSel.value === 'is_empty' || opSel.value === 'not_empty') {
        valSel.disabled = true;
        valSel.innerHTML = '<option value="">— 无需值 —</option>';
    } else {
        valSel.disabled = false;
        const colSel = row.querySelector('.sa-filter-col-select');
        if (colSel && colSel.value) onFilterColChange(colSel);
    }
}

/* ── Col change (fetch top-50 values) ─────────────────── */
function onFilterColChange(colSel) {
    const row = colSel.closest('.sa-filter-row');
    const valSel = row.querySelector('.sa-filter-val');
    if (!colSel || !valSel || !colSel.value) return;
    valSel.disabled = false;
    valSel.innerHTML = '<option value="">加载中...</option>';

    let url = '/simple/column-values/?col=' + encodeURIComponent(colSel.value);
    if (SA.sessionId) url += '&session_id=' + SA.sessionId;
    else if (SA.scanId) url += '&scan_id=' + SA.scanId;
    else { valSel.innerHTML = '<option value="">— 请先选择文件 —</option>'; return; }

    fetch(url, {headers: {'X-CSRFToken': getCSRF()}})
    .then(r => r.json())
    .then(d => {
        valSel.innerHTML = '<option value="">— 选择值 —</option>';
        if (d.error || !d.values) return;
        d.values.forEach(v => {
            const o = document.createElement('option');
            o.value = v.value; o.textContent = (v.value || '(空)') + ' (' + v.count + ')';
            valSel.appendChild(o);
        });
    })
    .catch(() => { valSel.innerHTML = '<option value="">— 选择值 —</option>'; });
}

/* ── Add condition row ─────────────────────────────────── */
function addCond(container, scope) {
    if (!container) container = document.getElementById('saFilterConditions');
    const scopeStr = scope || (container.id === 'saPresetConditions' ? 'preset' : 'main');
    const cols = (SA.columnsStd.length ? SA.columnsStd : SA.colNames).slice().sort((a, b) => a.localeCompare(b));
    const div = document.createElement('div');
    div.className = 'sa-filter-row';
    div.innerHTML = `
        <select class="sa-filter-col-select" style="min-width:90px;" onchange="onFilterColChange(this);updateDesc(this)">
            <option value="">— 列 —</option>
            ${cols.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('')}
        </select>
        <select class="sa-filter-op" style="width:80px;" onchange="onFilterOpChange(this);updateDesc(this)">
            ${OPS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}
        </select>
        <select class="sa-filter-val" style="width:130px;"><option value="">— 值 —</option></select>
        <label class="not-cb" title="取反此条件"><input type="checkbox" class="sa-filter-not" onchange="updateDesc(this)"> 取反</label>
        <span class="remove-btn" onclick="this.parentElement.remove();refreshDesc(this.closest('.sa-filter-builder, .sa-preset-body')?.querySelector('[id$=Conditions]'), '${scopeStr === 'preset' ? 'saPresetDesc' : 'saFilterDesc'}')">✕</span>
    `;
    container.appendChild(div);
    if (scopeStr === 'preset') savePresetTree();
}

/* ── Add group card ────────────────────────────────────── */
function addGroup(container, scope) {
    if (!container) container = document.getElementById('saFilterConditions');
    const scopeStr = scope || (container.id === 'saPresetConditions' ? 'preset' : 'main');
    const gid = 'g_' + Date.now();
    const card = document.createElement('div');
    card.className = 'sa-filter-group-card';
    card.dataset.gid = gid;
    card.innerHTML = `
        <div class="sgc-header">
            📦 条件组 —
            <select class="sgc-logic" onchange="refreshDesc(this.closest('.sa-filter-builder, .sa-preset-body')?.querySelector('[id$=Conditions]'), '${scopeStr === 'preset' ? 'saPresetDesc' : 'saFilterDesc'}')">
                <option value="AND">全部满足（且）</option>
                <option value="OR">任一满足（或）</option>
            </select>
            <span style="font-size:0.75rem;color:#888;font-weight:400;">— 组内条件</span>
        </div>
        <div class="sgc-body"></div>
        <div class="sgc-footer">
            <button class="btn" onclick="addCond(this.closest('.sa-filter-group-card').querySelector('.sgc-body'), '${scopeStr}')">+ 条件</button>
            <button class="btn" onclick="addGroup(this.closest('.sa-filter-group-card').querySelector('.sgc-body'), '${scopeStr}')">+ 子组</button>
            <span class="remove-btn" onclick="this.closest('.sa-filter-group-card').remove();refreshDesc(document.getElementById('${scopeStr === 'preset' ? 'saPresetConditions' : 'saFilterConditions'}'), '${scopeStr === 'preset' ? 'saPresetDesc' : 'saFilterDesc'}')">✕ 删除组</span>
        </div>
    `;
    container.appendChild(card);
    // Start with one condition inside
    addCond(card.querySelector('.sgc-body'), scopeStr);
    if (scopeStr === 'preset') savePresetTree();
}

/* ── Serialize ─────────────────────────────────────────── */
function serializeFilter(container) {
    if (!container) return [];
    const items = [];
    container.querySelectorAll(':scope > .sa-filter-row, :scope > .sa-filter-group-card').forEach(el => {
        if (el.classList.contains('sa-filter-row')) {
            const col = el.querySelector('.sa-filter-col-select')?.value;
            if (!col) return;
            const not = el.querySelector('.sa-filter-not')?.checked || false;
            items.push({
                type: 'condition', col, op: el.querySelector('.sa-filter-op')?.value || 'eq',
                val: el.querySelector('.sa-filter-val')?.value || '',
                logic: not ? 'NOT' : '',
            });
        } else if (el.classList.contains('sa-filter-group-card')) {
            const children = serializeFilter(el.querySelector('.sgc-body'));
            if (children.length) {
                items.push({ type: 'group', logic: el.querySelector('.sgc-logic')?.value || 'AND', items: children });
            }
        }
    });
    return items;
}

/* ── Human-readable description ────────────────────────── */
function updateDesc(el) {
    const builder = el.closest('.sa-filter-builder');
    const presetBody = el.closest('.sa-preset-body');
    if (builder) refreshDesc(builder.querySelector('[id$=Conditions]'), 'saFilterDesc');
    if (presetBody) refreshDesc(presetBody.querySelector('#saPresetConditions'), 'saPresetDesc');
}
function refreshDesc(container, descId) {
    const descEl = document.getElementById(descId);
    if (!descEl || !container) return;
    const items = serializeFilter(container);
    if (!items.length) { descEl.style.display = 'none'; return; }
    const logic = descId === 'saPresetDesc' ? getLogic('preset') : getLogic('main');
    const joiner = logic === 'OR' ? ' 或 ' : ' 且 ';
    descEl.style.display = 'block';
    descEl.textContent = '当前筛选：' + describeItems(items, joiner);
}
function describeItems(items, joiner) {
    return items.map(item => {
        if (item.type === 'group') {
            const inner = describeItems(item.items, item.logic === 'OR' ? ' 或 ' : ' 且 ');
            return '(' + inner + ')';
        }
        const opLabel = (OPS.find(o => o[0] === item.op) || ['', item.op])[1];
        let s = `${item.col} ${opLabel} "${item.val || '(空)'}"`;
        if (item.logic === 'NOT') s = '非(' + s + ')';
        return s;
    }).join(joiner);
}

/* ── Apply filter ──────────────────────────────────────── */
function applyAdvancedFilter() {
    if (SA._loading || !SA.sessionId) return;
    const items = serializeFilter(document.getElementById('saFilterConditions'));
    if (!items.length) { showError('请至少添加一个筛选条件'); return; }

    const logic = getLogic('main');
    const payload = { session_id: SA.sessionId, root: { type: 'group', logic, items } };

    setBusy(true, '正在筛选...');
    fetch('/simple/filter/advanced/', {
        method: 'POST',
        headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
        body: JSON.stringify(payload),
    })
    .then(r => r.json())
    .then(data => {
        setBusy(false);
        if (data.error) { showError(data.error); return; }
        setText('saFilteredCount', (data.filtered_rows || 0).toLocaleString());
        setText('saFilterTotalBefore', (data.total_before_filter || 0).toLocaleString());
        renderPreview(data.preview_columns, data.preview_rows);
        updateDebug({status: '已筛选', filtered: data.filtered_rows, total: data.total_before_filter});
        // Scroll to preview
        const pw = document.getElementById('saPreviewWrap');
        if (pw) pw.scrollIntoView({behavior: 'smooth', block: 'start'});
        showSuccess(`筛选完成，${data.filtered_rows} 条记录`);
    })
    .catch(err => { setBusy(false); showError('筛选失败: ' + err.message); });
}

/* ── Step 1: Upload (two-phase: quick scan → preset filter → upload) ── */
let _pendingFiles = null;  // FileList saved after drop for later upload

function handleDrop(e) {
    e.preventDefault();
    document.getElementById('saDropzone').classList.remove('dragover');
    handleFiles(e.dataTransfer.files);
}

function handleFiles(files) {
    if (!files.length) return;
    _pendingFiles = files;
    const list = document.getElementById('saFileList');
    list.innerHTML = '';
    Array.from(files).forEach(f => {
        const div = document.createElement('div');
        div.className = 'sa-file-item';
        div.innerHTML = `📄 ${f.name} (${(f.size/1024).toFixed(1)} KB) <span class="remove" onclick="removeFile(this)">✕</span>`;
        list.appendChild(div);
    });
    // Phase 1: quick scan for columns only
    quickScanFiles(files);
}

function removeFile(el) {
    el.parentElement.remove();
    const remaining = document.getElementById('saFileList').children.length;
    if (!remaining) {
        _pendingFiles = null;
        document.getElementById('saPresetFilterArea').style.display = 'none';
        document.getElementById('saUploadResult').style.display = 'none';
    }
}

function quickScanFiles(files) {
    const formData = new FormData();
    Array.from(files).forEach(f => formData.append('files', f));

    setBusy(true, '正在扫描CSV表头...');
    fetch('/simple/quick-scan/', {
        method: 'POST',
        body: formData,
        headers: {'X-CSRFToken': getCSRF()},
    })
    .then(r => r.json())
    .then(data => {
        setBusy(false);
        if (data.error) { showError(data.error); return; }

        SA.scanId = data.scan_id;  // 保存 scan_id 供后续 column-values 查询
        SA.columnsStd = data.columns_std || data.columns || [];
        SA.colNames = data.columns || [];

        // Show preset filter area
        showPresetFilterBuilder(data.columns_std || data.columns || []);
        updateDebug({status: '表头已扫描', total: '—', note: (data.columns || []).length + ' 列检测到'});
    })
    .catch(err => { setBusy(false); showError('扫描失败: ' + err.message); });
}

/* ── Preset filter persistence (v2 simple card-based) ──── */
function showPresetFilterBuilder(columns) {
    const area = document.getElementById('saPresetFilterArea');
    const container = document.getElementById('saPresetConditions');
    if (!area || !container) return;
    area.style.display = 'block';
    container.innerHTML = '';
    const saved = loadPresetTree();
    if (saved && saved.items && saved.items.length) {
        restoreTree(container, saved, columns);
    } else {
        addCond(container, 'preset');
    }
}

function loadPresetTree() {
    try { const r = localStorage.getItem('sa_preset_tree_v2'); if (r) return JSON.parse(r); } catch(e) {}
    return null;
}

function savePresetTree() {
    const c = document.getElementById('saPresetConditions');
    if (!c) return;
    const items = serializeFilter(c);
    if (items.length) {
        localStorage.setItem('sa_preset_tree_v2', JSON.stringify({ type: 'group', logic: getLogic('preset'), items }));
    } else {
        localStorage.removeItem('sa_preset_tree_v2');
    }
}

function clearPresetMemory() {
    localStorage.removeItem('sa_preset_tree_v2');
    localStorage.removeItem('sa_preset');
    const c = document.getElementById('saPresetConditions');
    if (c) { c.innerHTML = ''; addCond(c, 'preset'); }
    refreshDesc(c, 'saPresetDesc');
    showSuccess('已清除预设筛选记忆');
}

function restoreTree(container, tree, columns) {
    const sortedCols = columns.slice().sort((a, b) => a.localeCompare(b));
    if (tree.logic) {
        const toggle = document.getElementById('saPresetLogicToggle');
        if (toggle) {
            toggle.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.logic === tree.logic));
        }
    }
    tree.items.forEach(item => {
        if (item.type === 'group') appendSavedGroup(container, item, sortedCols);
        else appendSavedCond(container, item, sortedCols);
    });
    refreshDesc(container, 'saPresetDesc');
}

function appendSavedCond(container, item, sortedCols) {
    const div = document.createElement('div');
    div.className = 'sa-filter-row';
    const notChecked = item.logic === 'NOT' ? 'checked' : '';
    div.innerHTML =
        `<select class="sa-filter-col-select" style="min-width:90px;" onchange="onFilterColChange(this);updateDesc(this)">` +
        `<option value="">— 列 —</option>` +
        sortedCols.map(c => `<option value="${escapeHtml(c)}" ${c === item.col ? 'selected' : ''}>${escapeHtml(c)}</option>`).join('') +
        `</select>` +
        `<select class="sa-filter-op" style="width:80px;" onchange="onFilterOpChange(this);updateDesc(this)">` +
        OPS.map(([v, l]) => `<option value="${v}" ${item.op === v ? 'selected' : ''}>${l}</option>`).join('') +
        `</select>` +
        `<select class="sa-filter-val" style="width:130px;"><option value="${escapeHtml(item.val || '')}" selected>${escapeHtml(item.val || '—')}</option></select>` +
        `<label class="not-cb"><input type="checkbox" class="sa-filter-not" ${notChecked} onchange="updateDesc(this)"> 取反</label>` +
        `<span class="remove-btn" onclick="this.parentElement.remove();savePresetTree();refreshDesc(document.getElementById('saPresetConditions'),'saPresetDesc')">✕</span>`;
    container.appendChild(div);
}

function appendSavedGroup(container, group, sortedCols) {
    const gid = 'g_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
    const card = document.createElement('div');
    card.className = 'sa-filter-group-card';
    card.dataset.gid = gid;
    card.innerHTML =
        `<div class="sgc-header">📦 条件组 —` +
        `<select class="sgc-logic" onchange="refreshDesc(document.getElementById('saPresetConditions'),'saPresetDesc')">` +
        `<option value="AND" ${group.logic === 'AND' ? 'selected' : ''}>全部满足（且）</option>` +
        `<option value="OR" ${group.logic === 'OR' ? 'selected' : ''}>任一满足（或）</option>` +
        `</select><span style="font-size:0.75rem;color:#888;font-weight:400;">— 组内条件</span></div>` +
        `<div class="sgc-body"></div>` +
        `<div class="sgc-footer">` +
        `<button class="btn" onclick="addCond(this.closest('.sa-filter-group-card').querySelector('.sgc-body'), 'preset')">+ 条件</button>` +
        `<button class="btn" onclick="addGroup(this.closest('.sa-filter-group-card').querySelector('.sgc-body'), 'preset')">+ 子组</button>` +
        `<span class="remove-btn" onclick="this.closest('.sa-filter-group-card').remove();savePresetTree();refreshDesc(document.getElementById('saPresetConditions'),'saPresetDesc')">✕ 删除组</span></div>`;
    const inner = card.querySelector('.sgc-body');
    (group.items || []).forEach(item => {
        if (item.type === 'group') appendSavedGroup(inner, item, sortedCols);
        else appendSavedCond(inner, item, sortedCols);
    });
    container.appendChild(card);
}

function doUpload() {
    if (SA._loading) return;
    if (!_pendingFiles && !SA.scanId) { showError('请先选择文件'); return; }

    const formData = new FormData();

    // 如果有 pendingFiles，始终附上（upload API 需要 files）
    if (_pendingFiles) {
        Array.from(_pendingFiles).forEach(f => formData.append('files', f));
    }
    // 传 scan_id 让后端复用已保存的文件，避免重复写盘
    if (SA.scanId) {
        formData.append('scan_id', SA.scanId);
    }

    // Serialize and save preset filter tree
    savePresetTree();
    const presetItems = serializeFilter(document.getElementById('saPresetConditions'));
    if (presetItems.length) {
        const tree = { type: 'group', logic: getLogic('preset'), items: presetItems };
        formData.append('preset_filters', JSON.stringify({root: tree}));
    }

    setBusy(true, '正在上传并解析CSV（含预设筛选）...');
    fetch('/simple/upload/', {
        method: 'POST',
        body: formData,
        headers: {'X-CSRFToken': getCSRF()},
    })
    .then(r => r.json())
    .then(data => {
        setBusy(false);
        if (data.error) { showError(data.error); return; }

        SA.sessionId = data.session_id;
        SA.colNames = data.columns || [];
        SA.columnsStd = data.columns_std || data.columns || [];
        SA.detectedCols = data.detected_columns || {};

        const geoStats = data.geo_stats || {};
        updateDebug({
            status: '已上传',
            total: data.total_rows,
            preset: data.preset_filtered,
            geo: `${geoStats.ips_resolved || 0} IPs 解析`,
            note: data.preset_filtered < data.total_rows ? `预设筛选过滤 ${data.total_rows - data.preset_filtered} 条` : '',
        });

        setText('saTotalRows', data.total_rows.toLocaleString());
        setText('saPresetFiltered', data.preset_filtered.toLocaleString());

        // Detected columns
        const dc = data.detected_columns || {};
        const found = Object.entries(dc).filter(([,v]) => v).map(([k,v]) => `${k}→${v}`);
        setText('saDetectedCols', found.join('、') || '（无自动匹配）');

        // Missing critical columns
        const missing = Object.entries(dc).filter(([,v]) => !v).map(([k]) => k);
        const mcEl = document.getElementById('saMissingCols');
        if (mcEl) {
            if (missing.length) {
                mcEl.style.display = 'block';
                mcEl.textContent = '未检测到列: ' + missing.join(', ');
            } else {
                mcEl.style.display = 'none';
                mcEl.textContent = '';
            }
        }

        const urEl = document.getElementById('saUploadResult');
        if (urEl) { urEl.style.display = 'block'; urEl.scrollIntoView({behavior: 'smooth', block: 'start'}); }
        showSuccess(`上传成功！共 ${data.total_rows.toLocaleString()} 条记录（预设筛选后 ${data.preset_filtered.toLocaleString()} 条）`);
    })
    .catch(err => { setBusy(false); showError('上传失败: ' + err.message); });
}

/* ── Step 2: Filter ───────────────────────────────────── */

function renderPreview(cols, rows) {
    const hdr = document.getElementById('saPreviewHeader');
    const body = document.getElementById('saPreviewBody');
    if (!hdr || !body) return;
    hdr.innerHTML = cols.map(c => `<th>${escapeHtml(c)}</th>`).join('');
    body.innerHTML = rows.map(row => {
        return '<tr>' + cols.map(c => {
            const v = row[c];
            return `<td title="${escapeHtml(v || '')}">${escapeHtml(v || '')}</td>`;
        }).join('') + '</tr>';
    }).join('');
}

/* ── Step 3: Cluster ──────────────────────────────────── */
function runCluster() {
    const minClusterSize = parseInt(document.getElementById('saMinClusterSize').value);
    const epsilonMeters = parseInt(document.getElementById('saEpsilon').value);
    const epsilonRad = epsilonMeters / 6371000;

    showLoading('正在构建IP通信网络并聚类...');
    fetch('/simple/cluster/', {
        method: 'POST',
        headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
        body: JSON.stringify({
            session_id: SA.sessionId,
            min_cluster_size: minClusterSize,
            cluster_selection_epsilon: epsilonRad,
        }),
    })
    .then(r => r.json())
    .then(data => {
        hideLoading();
        if (data.error) { showError(data.error); return; }

        SA.nodes = data.nodes || [];
        SA.edgesData = data.edges || [];
        SA.clusterMap = data.clusters || {};
        SA.clusterLabels = data.nodes ? data.nodes.map(n => n.cluster_id) : [];
        SA.dataPoints = {};

        setText('saNClusters', data.n_clusters);
        setText('saNNoise', data.n_nodes || 0);
        setText('saNValid', data.n_edges || 0);
        const crEl = document.getElementById('saClusterResult');
        if (crEl) { crEl.style.display = 'block'; crEl.scrollIntoView({behavior: 'smooth', block: 'start'}); }

        renderClusterCards(data.clusters);

        updateDebug({
            status: '聚类完成',
            mode: 'IP网络社区发现',
            clusters: String(data.n_clusters),
            noise_ratio: '—',
            time: data.elapsed_seconds || '—',
            note: data.note || '',
        });

        showSuccess(`${data.n_nodes} 个IP节点, ${data.n_edges} 条边, ${data.n_clusters} 个社区`);
    })
    .catch(err => { hideLoading(); showError('聚类失败: ' + err.message); });
}

function renderClusterCards(clusters) {
    const el = document.getElementById('saClusterCards');
    el.innerHTML = '';
    if (!clusters || !Object.keys(clusters).length) return;
    const sorted = Object.values(clusters).sort((a, b) => b.size - a.size);
    const colors = generateColors(sorted.length);

    sorted.forEach((c, idx) => {
        const div = document.createElement('div');
        div.className = 'sa-cluster-card';
        const color = c.size <= 1 ? '#b0b0b0' : (colors[idx] || '#4361ee');
        div.style.borderLeft = `4px solid ${color}`;
        const label = c.size <= 1 ? '独立节点' : '社区 ' + c.label;
        div.innerHTML = `
            <div class="label" style="color:${color};">${label}</div>
            <div class="size">${c.size} 个IP</div>
            <div class="size">子网: ${c.subnet || '—'}</div>
        `;
        div.onclick = () => loadClusterDetail(c.label);
        el.appendChild(div);
    });
}

/* ── Step 4: Map (static SVG world map + large clickable points) ── */

// World map image path
const WORLD_MAP_URL = STATIC_WORLD_MAP;

// Subnet color palette (high-contrast against map background)
const SUBNET_COLORS = [
    '#e63946','#2a9d8f','#f77f00','#7209b7','#4cc9f0','#f72585',
    '#e76f51','#264653','#2ec4b6','#ff9f1c','#8338ec','#00b4d8',
    '#d00000','#1b4332','#e36414','#3c096c','#0077b6','#bc4742',
    '#606c38','#bc6c25','#b5838d','#6d6875','#457b9d','#e07a5f',
];

function getSubnetColor(ip) {
    const parts = ip.split('.');
    if (parts.length >= 3) {
        const h = (parseInt(parts[0]) * 31 + parseInt(parts[1]) * 17 + parseInt(parts[2]) * 7);
        return SUBNET_COLORS[Math.abs(h) % SUBNET_COLORS.length];
    }
    return SUBNET_COLORS[0];
}

/* ── World map: satellite tiles + vector overlay ────── */
function loadOfflineWorldMap(map) {
    // Satellite tile providers (free, no API key)
    const tileProviders = [
        {
            name: 'ESRI卫星',
            url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
            attr: '&copy; Esri', maxZoom: 18,
        },
        {
            name: 'CartoDB浅色',
            url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
            attr: '&copy; OSM', subdomains: 'abcd', maxZoom: 19,
        },
    ];

    // Offline vector fallback files
    const landFiles = [
        { url: STATIC_MAPS + '/land-10m.json', obj: 'land', label: '10m' },
        { url: STATIC_MAPS + '/land-50m.json', obj: 'land', label: '50m' },
    ];
    const countryFiles = [
        { url: STATIC_MAPS + '/countries-10m.json', obj: 'countries', label: '10m' },
        { url: STATIC_MAPS + '/countries-50m.json', obj: 'countries', label: '50m' },
    ];

    function sanitizeName(name) {
        if (!name) return '';
        return name
            .replace(/Å/g,'A').replace(/å/g,'a').replace(/Á/g,'A').replace(/á/g,'a')
            .replace(/À/g,'A').replace(/à/g,'a').replace(/Â/g,'A').replace(/â/g,'a')
            .replace(/Ã/g,'A').replace(/ã/g,'a').replace(/Ä/g,'A').replace(/ä/g,'a')
            .replace(/Ç/g,'C').replace(/ç/g,'c').replace(/É/g,'E').replace(/é/g,'e')
            .replace(/È/g,'E').replace(/è/g,'e').replace(/Ê/g,'E').replace(/ê/g,'e')
            .replace(/Ë/g,'E').replace(/ë/g,'e').replace(/Í/g,'I').replace(/í/g,'i')
            .replace(/Î/g,'I').replace(/î/g,'i').replace(/Ï/g,'I').replace(/ï/g,'i')
            .replace(/Ñ/g,'N').replace(/ñ/g,'n').replace(/Ó/g,'O').replace(/ó/g,'o')
            .replace(/Ô/g,'O').replace(/ô/g,'o').replace(/Õ/g,'O').replace(/õ/g,'o')
            .replace(/Ö/g,'O').replace(/ö/g,'o').replace(/Ø/g,'O').replace(/ø/g,'o')
            .replace(/Ú/g,'U').replace(/ú/g,'u').replace(/Ü/g,'U').replace(/ü/g,'u')
            .replace(/Ý/g,'Y').replace(/ý/g,'y').replace(/Æ/g,'AE').replace(/æ/g,'ae')
            .replace(/Œ/g,'OE').replace(/œ/g,'oe').replace(/ß/g,'ss');
    }

    let tileLayer = null;
    let tileLoaded = false;
    let vectorLoaded = false;

    // ── Step 1: Try satellite tiles ──
    function tryTile(providerIdx) {
        if (providerIdx >= tileProviders.length) {
            console.log('在线瓦片不可用，加载离线矢量地图');
            loadVector(0);
            return;
        }
        const p = tileProviders[providerIdx];
        const opts = { attribution: p.attr, maxZoom: p.maxZoom };
        if (p.subdomains) opts.subdomains = p.subdomains;

        const testTile = p.url
            .replace('{s}', p.subdomains ? p.subdomains[0] : 'a')
            .replace('{z}', '2').replace('{y}', '1').replace('{x}', '1')
            .replace('{r}', '');

        fetch(testTile)
            .then(r => { if (!r.ok) throw new Error(); return r.blob(); })
            .then(() => {
                tileLayer = L.tileLayer(p.url, opts).addTo(map);
                tileLoaded = true;
                console.log('在线地图: ' + p.name);
                // Still load vector borders overlay
                loadVector(0);
            })
            .catch(() => tryTile(providerIdx + 1));
    }

    // ── Step 2: Vector overlay (land + borders) ──
    function loadVector(landIdx) {
        if (landIdx >= landFiles.length) { return; }
        fetch(landFiles[landIdx].url)
            .then(r => { if (!r.ok) throw new Error(); return r.json(); })
            .then(topo => {
                if (!tileLoaded) {
                    // No satellite — opaque land fill
                    L.geoJSON(topojson.feature(topo, topo.objects.land), {
                        style: { fillColor: '#2a2a2a', color: '#444', weight: 0.5, fillOpacity: 1 },
                        interactive: false,
                    }).addTo(map);
                    map.getContainer().style.background = '#1a1a2e';
                }
                console.log('矢量陆地: ' + landFiles[landIdx].label);
                loadBorders(0);
            })
            .catch(() => loadVector(landIdx + 1));
    }

    function loadBorders(idx) {
        if (idx >= countryFiles.length) { vectorLoaded = true; return; }
        fetch(countryFiles[idx].url)
            .then(r => { if (!r.ok) throw new Error(); return r.json(); })
            .then(topo => {
                const geojson = topojson.feature(topo, topo.objects.countries);
                const feats = geojson.features || [];
                const isSat = tileLoaded;

                L.geoJSON(geojson, {
                    style: {
                        fillColor: 'transparent', fillOpacity: 0,
                        color: isSat ? 'rgba(255,255,255,0.55)' : '#666',
                        weight: isSat ? 1.0 : 0.6,
                        opacity: isSat ? 0.7 : 0.5,
                        dashArray: isSat ? null : '3 2',
                    },
                    interactive: false,
                }).addTo(map);

                // Labels
                const labelGroup = L.layerGroup();
                feats.forEach(function(f) {
                    const name = sanitizeName((f.properties && f.properties.name) || '');
                    if (!name || name.length > 25) return;
                    let c = null;
                    const g = f.geometry;
                    if (g && g.type === 'Polygon') c = ringCenter(g.coordinates[0]);
                    else if (g && g.type === 'MultiPolygon') {
                        let m = 0;
                        for (const p of g.coordinates) {
                            if (p[0].length > m) { m = p[0].length; c = ringCenter(p[0]); }
                        }
                    }
                    if (c && Math.abs(c[0]) < 82) {
                        L.marker(c, { icon: L.divIcon({
                            className: 'sa-country-label' + (isSat ? ' sa-country-label-sat' : ''),
                            html: name, iconSize: null,
                            iconAnchor: [name.length * 3.5, 7],
                        }), interactive: false }).addTo(labelGroup);
                    }
                });
                map.on('zoomend', function() {
                    map.getZoom() >= 3 ? labelGroup.addTo(map) : map.removeLayer(labelGroup);
                });
                if (map.getZoom() >= 3) labelGroup.addTo(map);
                vectorLoaded = true;
                console.log('矢量国界: ' + countryFiles[idx].label + ', ' + feats.length + '国');
            })
            .catch(() => loadBorders(idx + 1));
    }

    function ringCenter(coords) {
        let la = 0, lo = 0, n = coords.length;
        for (let i = 0; i < n; i++) { la += coords[i][1]; lo += coords[i][0]; }
        return n > 0 ? [la / n, lo / n] : null;
    }

    tryTile(0);
}

function initMap() {
    const container = document.getElementById('saMap');
    if (!container) return;
    container.style.height = '550px';
    container.style.width = '100%';
    // Set ocean background color (world map rendered via L.imageOverlay)
    container.style.background = '#1a1a2e';  // dark ocean (overridden by satellite tiles)

    if (SA.map) { SA.map.invalidateSize(); renderMapData(); return; }

    // Wait until container has real dimensions, max 5 seconds
    let attempts = 0;
    function tryCreateMap() {
        if (container.offsetHeight > 10) {
            SA.map = L.map('saMap', {
                center: [20, 0], zoom: 2, minZoom: 2, maxZoom: 14,
                worldCopyJump: true, preferCanvas: true,
                attributionControl: false, zoomControl: true,
                zoomAnimation: false,
            });
            SA.map.setMaxBounds([[-85, -180], [85, 180]]);
            loadOfflineWorldMap(SA.map);
            SA.map.on('zoomend moveend', () => renderMapData());
            SA.map.invalidateSize();
            renderMapData();
            console.log('Map created. Height:', container.offsetHeight, 'Markers:', SA.pointLayer ? SA.pointLayer.getLayers().length : 0);
        } else if (attempts < 50) {
            attempts++;
            setTimeout(tryCreateMap, 100);
        } else {
            // Fallback: force-create even with 0 height
            SA.map = L.map('saMap', {
                center: [20, 0], zoom: 2, minZoom: 2, maxZoom: 14,
                worldCopyJump: true, preferCanvas: true,
                attributionControl: false, zoomControl: true,
                zoomAnimation: false,
            });
            SA.map.setMaxBounds([[-85, -180], [85, 180]]);
            loadOfflineWorldMap(SA.map);
            SA.map.on('zoomend moveend', () => renderMapData());
            setTimeout(() => { SA.map.invalidateSize(); renderMapData(); }, 500);
            console.log('Map created (fallback).');
        }
    }
    tryCreateMap();
}

// ── Render IP nodes (large, clickable, high-contrast colors) ──
function renderMapData() {
    if (!SA.map) return;
    if (SA.pointLayer) SA.map.removeLayer(SA.pointLayer);
    if (SA.centerLayer) SA.map.removeLayer(SA.centerLayer);

    const nodes = SA.nodes || [];
    const clusters = SA.clusterMap || {};
    if (!nodes.length) return;

    const bounds = SA.map.getBounds();
    const markers = [];
    const centerMarkers = [];

    nodes.forEach(n => {
        if (n.lat == null || n.lon == null) return;
        if (!bounds.contains([n.lat, n.lon])) return;
        if (SA.hiddenClusters.has(n.cluster_id)) return;

        const color = getSubnetColor(n.ip);
        const peerCount = n.peer_count || 0;
        // High-degree servers: larger, brighter
        // Singletons: smaller, dimmer
        const isHighDeg = peerCount > 20;
        const isSingleton = peerCount <= 1;
        const radius = isHighDeg ? 10 : (isSingleton ? 4 : 7);
        const fillOpacity = isHighDeg ? 0.95 : (isSingleton ? 0.5 : 0.85);
        const weight = isHighDeg ? 3 : (isSingleton ? 1 : 2);
        const borderColor = isHighDeg ? '#222' : (isSingleton ? '#ccc' : '#fff');

        const circle = L.circleMarker([n.lat, n.lon], {
            radius, fillColor: color, color: borderColor, weight,
            opacity: 0.95, fillOpacity,
            interactive: true, bubblingMouseEvents: false,
        });
        circle.bindTooltip(buildNodeTooltip(n), {sticky: true, direction: 'top', offset: [0, -8]});
        circle.on('click', function(e) {
            L.DomEvent.stopPropagation(e);
            loadNodeDetail(n.ip);
        });
        markers.push(circle);
    });

    // Community center markers
    const colors = generateColors(20);
    const clusterColorMap = {};
    Object.values(clusters).sort((a, b) => b.size - a.size).forEach((c, i) => {
        clusterColorMap[c.label] = colors[i % colors.length];
    });

    Object.entries(clusters).forEach(([cid, cinfo]) => {
        const cidInt = parseInt(cid);
        if (cinfo.size <= 1 || SA.hiddenClusters.has(cidInt)) return;
        if (cinfo.center_lat == null || cinfo.center_lon == null) return;
        if (!bounds.contains([cinfo.center_lat, cinfo.center_lon])) return;

        const color = clusterColorMap[cidInt] || '#4361ee';
        const circle = L.circleMarker([cinfo.center_lat, cinfo.center_lon], {
            radius: 10, fillColor: color, color: '#fff', weight: 3,
            opacity: 0.95, fillOpacity: 0.7,
            interactive: true, bubblingMouseEvents: false,
        });
        circle.bindTooltip(
            `<div class="sa-point-tooltip"><b>社区 ${cidInt}</b><br>${cinfo.size} IP · ${cinfo.subnet||''}<br>点击查看详情</div>`,
            {direction: 'top'}
        );
        circle.on('click', function(e) { L.DomEvent.stopPropagation(e); loadClusterDetail(cidInt); });
        centerMarkers.push(circle);
    });

    SA.pointLayer = L.layerGroup(markers).addTo(SA.map);
    SA.centerLayer = L.layerGroup(centerMarkers).addTo(SA.map);
    renderLegend();
    // Restore cluster focus if active
    if (SA._clusterFocus && _clusterFocusLayer && !SA.map.hasLayer(_clusterFocusLayer)) {
        _clusterFocusLayer.addTo(SA.map);
    }
}

function buildNodeTooltip(n) {
    const p = n.ip ? n.ip.split('.') : [];
    const sn = p.length >= 3 ? p[0]+'.'+p[1]+'.'+p[2]+'.0/24' : '';
    const pc = n.peer_count || 0;
    const label = pc > 20 ? ' 🔥服务器' : (n.is_server ? ' 🖥' : '');
    const degHint = pc > 0 ? `<span style="color:#e76f51;">${pc} 对端</span><br>` : '';
    return `<div class="sa-point-tooltip"><b>${escapeHtml(n.ip)}</b>${label}<br>` +
        (n.host ? `<span style="color:#2a9d8f;">${escapeHtml(n.host)}</span><br>` : '') +
        degHint +
        (sn ? `<span style="color:#888;">子网: ${sn}</span><br>` : '') +
        `${escapeHtml(n.city||'')}, ${escapeHtml(n.country||'')}<br>` +
        (n.isp ? `<span style="color:#666;">${escapeHtml(n.isp)}</span><br>` : '') +
        (n.org ? `<span style="color:#666;">${escapeHtml(n.org)}</span><br>` : '') +
        `<span style="color:#4361ee;">🖱 点击查看详情</span></div>`;
}

function renderLegend() {
    const el = document.getElementById('saLegendItems');
    const clusters = SA.clusterMap || {};
    const sorted = Object.values(clusters).sort((a, b) => b.size - a.size);
    if (!sorted.length) { el.innerHTML = ''; return; }

    const colors = generateColors(sorted.length);
    // Show top 20 communities + subnet info
    const top = sorted.slice(0, 20);
    const html = top.map((c, idx) => {
        const color = colors[idx % colors.length];
        const hidden = SA.hiddenClusters.has(c.label);
        const label = c.size <= 1 ? '独立节点' : '社区 ' + c.label;
        return `<div class="sa-legend-item${hidden ? ' hidden' : ''}"
                     onclick="toggleCluster(${c.label})">
                    <span class="color-dot" style="background:${color}"></span>
                    <span>${label}</span>
                    <span style="color:#999;font-size:0.75rem;">${c.size} IP · ${c.subnet || ''}</span>
                </div>`;
    });
    if (sorted.length > 20) {
        html.push(`<div style="font-size:0.75rem;color:#999;padding:0.3rem;">... 还有 ${sorted.length - 20} 个社区</div>`);
    }
    el.innerHTML = html.join('');
}

function toggleCluster(label) {
    if (SA.hiddenClusters.has(label)) SA.hiddenClusters.delete(label);
    else SA.hiddenClusters.add(label);
    renderMapData();
}

/* ── Charts ───────────────────────────────────────────── */
function renderCharts() {
    const clusters = SA.clusterMap || {};
    const sorted = Object.values(clusters).sort((a, b) => b.size - a.size);
    if (!sorted.length) return;
    const labels = sorted.map(c => '社区 ' + c.label);
    const sizes = sorted.map(c => c.size);
    const colors = generateColors(sorted.length);

    // Bar chart
    const barCtx = document.getElementById('saBarChart');
    if (!barCtx) return;
    const barCtx2d = barCtx.getContext('2d');
    if (SA.barChart) SA.barChart.destroy();
    SA.barChart = new Chart(barCtx2d, {
        type: 'bar',
        data: {
            labels: labels,
            datasets: [{
                label: 'IP数',
                data: sizes,
                backgroundColor: colors,
                borderRadius: 4,
            }]
        },
        options: {
            responsive: true,
            plugins: { legend: { display: false } },
            scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
        }
    });

    // Pie chart
    const pieCtx = document.getElementById('saPieChart');
    if (!pieCtx) return;
    const pieCtx2d = pieCtx.getContext('2d');
    if (SA.pieChart) SA.pieChart.destroy();
    SA.pieChart = new Chart(pieCtx2d, {
        type: 'pie',
        data: {
            labels: labels,
            datasets: [{
                data: sizes,
                backgroundColor: colors,
                borderWidth: 1,
                borderColor: '#fff',
            }]
        },
        options: {
            responsive: true,
            plugins: {
                legend: { position: 'right', labels: { font: { size: 11 } } }
            }
        }
    });
}

/* ── Node Detail ─────────────────────────────────────── */
function loadNodeDetail(ip) {
    showLoading('加载节点详情...');
    fetch(`/simple/node/?session_id=${SA.sessionId}&ip=${encodeURIComponent(ip)}`, {
        headers: {'X-CSRFToken': getCSRF()},
    })
    .then(r => r.json())
    .then(data => {
        hideLoading();
        if (data.error) { showError(data.error); return; }
        showNodeDetailPanel(data);
    })
    .catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}

function showNodeDetailPanel(data) {
    const panel = document.getElementById('saDetailPanel');
    const body = document.getElementById('saDetailBody');
    if (!panel || !body) return;
    const n = data.node || {};
    const edges = data.related_edges || [];

    // Build edges HTML with click handlers (show peer_host, peer_org)
    let edgeHtml = '';
    if (edges.length) {
        edgeHtml = `<div class="section-title">🔗 关联边 (${data.n_edges} 条) — 点击查看详情</div>
                    <div style="max-height:250px;overflow-y:auto;font-size:0.78rem;">`;
        edges.slice(0, 30).forEach((e, i) => {
            const peer = e.peer_ip || (e.source === n.ip ? e.target : e.source);
            const dir = (e.source === n.ip || e.peer_ip === e.target) ? '→' : '←';
            const peerHost = e.peer_host || e.dst_host || e.src_host || '';
            const peerOrg = e.peer_org || '';
            edgeHtml += `<div class="edge-row" data-edge-idx="${i}"
                style="margin:0.2rem 0;padding:0.3rem 0.5rem;background:#f8faff;border-radius:4px;cursor:pointer;border:1px solid #e8ecf0;"
                onmouseover="this.style.background='#eef2ff'" onmouseout="this.style.background='#f8faff'"
                onclick="highlightEdge(${i});loadEdgeDetail(SA._highlightedNodeEdges[${i}])">
                ${dir} <b>${escapeHtml(peer)}</b>
                ${peerHost ? ` <span style="color:#888;font-size:0.75rem;">${escapeHtml(peerHost)}</span>` : ''}
                &nbsp;· ${e.comm_count} 次
                ${e.total_bytes ? '&nbsp;· ' + (e.total_bytes/1024).toFixed(1) + ' KB' : ''}
            </div>`;
        });
        if (edges.length > 30) edgeHtml += `<div style="color:#999;font-size:0.75rem;">... 还有 ${edges.length - 30} 条边</div>`;
        edgeHtml += '</div>';
    }

    // Build texts section
    const texts = data.node_texts || n.texts || [];
    let textsHtml = '';
    if (texts.length) {
        textsHtml = `<div class="section-title">📝 关联文本</div>`;
        texts.forEach(t => {
            textsHtml += `<div style="font-size:0.75rem;padding:0.25rem 0.5rem;background:#fafbfc;border-radius:3px;margin:0.15rem 0;word-break:break-all;max-height:60px;overflow:hidden;">${escapeHtml(t)}</div>`;
        });
    }

    // Build peer IP list
    const peerIps = data.peer_ips || [];
    let peersHtml = '';
    if (peerIps.length) {
        peersHtml = `<div class="section-title">👥 对端 IP (${data.n_peers || peerIps.length} 个)</div>
            <div class="ip-list" style="max-height:150px;overflow-y:auto;">`;
        peerIps.slice(0, 30).forEach(pip => {
            peersHtml += `<span class="sa-tag" style="cursor:pointer;color:#4361ee;margin:0.1rem;" onclick="loadNodeDetail('${escapeHtml(pip)}')">${escapeHtml(pip)}</span>`;
        });
        if (peerIps.length > 30) peersHtml += `<div style="font-size:0.75rem;color:#999;">... 还有 ${peerIps.length - 30} 个</div>`;
        peersHtml += '</div>';
    }

    const srvBadge = n.is_server ? ' 🖥 服务器' : '';

    // Enriched node attributes
    const attrToHtml = (items, title, icon) => {
        if (!items || !items.length) return '';
        return `<div class="section-title">${icon} ${title}</div>
            <div>${items.slice(0,5).map(i => `<span class="sa-tag">${escapeHtml(i.value||i)} (${i.count||''})</span>`).join(' ')}</div>`;
    };

    // Traffic stats row
    const totalBytes = n.total_bytes || data.total_flow_bytes || 0;
    const totalPkts = n.total_packets;
    const avgDur = n.avg_duration;
    const connRate = n.conn_success_rate;
    let trafficHtml = '<div class="stat-row">';
    trafficHtml += `<div class="stat-card"><div class="num">${totalBytes ? (totalBytes/1024).toFixed(1)+' KB' : '—'}</div><div class="lbl">总流量</div></div>`;
    if (totalPkts != null) trafficHtml += `<div class="stat-card"><div class="num">${totalPkts.toLocaleString()}</div><div class="lbl">总包数</div></div>`;
    if (avgDur != null) trafficHtml += `<div class="stat-card"><div class="num">${avgDur}s</div><div class="lbl">平均持续时间</div></div>`;
    trafficHtml += '</div>';

    if (connRate != null || n.conn_count) {
        trafficHtml += '<div class="stat-row">';
        if (connRate != null) trafficHtml += `<div class="stat-card"><div class="num">${connRate}%</div><div class="lbl">连接成功率</div></div>`;
        if (n.conn_count) trafficHtml += `<div class="stat-card"><div class="num">${n.conn_count}</div><div class="lbl">总连接数</div></div>`;
        trafficHtml += '</div>';
    }

    // Time range
    if (n.first_seen || n.last_seen) {
        trafficHtml += '<div class="stat-row">';
        if (n.first_seen) trafficHtml += `<div class="stat-card"><div class="num" style="font-size:0.7rem;">${n.first_seen.slice(0,19)}</div><div class="lbl">首次出现</div></div>`;
        if (n.last_seen) trafficHtml += `<div class="stat-card"><div class="num" style="font-size:0.7rem;">${n.last_seen.slice(0,19)}</div><div class="lbl">末次出现</div></div>`;
        trafficHtml += '</div>';
    }

    body.innerHTML =
        `<div class="stat-row">` +
        `<div class="stat-card"><div class="num" style="font-size:1rem;word-break:break-all;">${escapeHtml(n.ip)}${srvBadge}</div><div class="lbl">IP 地址</div></div>` +
        (n.host ? `<div class="stat-card"><div class="num" style="font-size:0.8rem;">${escapeHtml(n.host)}</div><div class="lbl">Host</div></div>` : '') +
        `</div>` +
        `<div class="stat-row">` +
        (n.city ? `<div class="stat-card"><div class="num">${escapeHtml(n.city)}</div><div class="lbl">城市</div></div>` : '') +
        (n.country ? `<div class="stat-card"><div class="num">${escapeHtml(n.country)}</div><div class="lbl">国家</div></div>` : '') +
        (n.isp ? `<div class="stat-card"><div class="num" style="font-size:0.85rem;">${escapeHtml(n.isp)}</div><div class="lbl">运营商</div></div>` : '') +
        (n.org ? `<div class="stat-card"><div class="num" style="font-size:0.85rem;">${escapeHtml(n.org)}</div><div class="lbl">组织</div></div>` : '') +
        `</div>` +
        `<div class="stat-row">` +
        `<div class="stat-card"><div class="num">${data.n_peers || 0}</div><div class="lbl">对端IP数</div></div>` +
        `<div class="stat-card"><div class="num">${n.asn || '—'}</div><div class="lbl">ASN</div></div></div>` +
        trafficHtml +
        attrToHtml(n.top_snis, 'SNI / 域名', '🌐') +
        attrToHtml(n.top_tls_vers, 'TLS 版本', '🔒') +
        attrToHtml(n.top_ciphers, '加密套件', '🔑') +
        attrToHtml(n.top_ports, '目的端口', '🔌') +
        attrToHtml(n.top_categories, '分类标签', '🏷') +
        textsHtml +
        peersHtml +
        edgeHtml;

    setText('saDetailTitle', `节点: ${n.ip}`);
    // Add traffic view button
    const titleEl = document.getElementById('saDetailTitle');
    if (titleEl) {
        titleEl.innerHTML += ` <button class=\"btn\" style=\"font-size:0.7rem;padding:0.15rem 0.5rem;margin-left:0.5rem;\" onclick=\"showEntityTrafficView('${escapeHtml(n.ip)}')\">📡 动态流量</button>`;
    }
    panel.classList.add('open');

    // Store edges for map highlighting
    SA._highlightedNodeEdges = edges;

    // Highlight edges on map
    highlightNodeEdges();
}

// ── Highlight edges for selected node ────────────────────
function highlightNodeEdges() {
    // Save old edge layer before replacing
    if (_edgeLayer) {
        _savedEdgeLayer = _edgeLayer;
        SA.map.removeLayer(_edgeLayer);
    }
    const edges = SA._highlightedNodeEdges || [];
    if (!edges.length) return;

    const ipCoord = {};
    (SA.nodes || []).forEach(n => {
        if (n.lat != null && n.lon != null) ipCoord[n.ip] = [n.lat, n.lon];
    });

    const polys = [];
    edges.forEach((e, i) => {
        const src = ipCoord[e.source];
        const dst = ipCoord[e.target];
        if (!src || !dst) return;
        const w = Math.max(2, Math.min((e.comm_count || 1) / 3 + 1, 5));
        // 底层粗透明线用于点击捕获
        const hitPoly = L.polyline([src, dst], {
            color: '#ff3333', weight: 12, opacity: 0,
            interactive: true, bubblingMouseEvents: false,
        });
        // 上层细线用于展示
        const visPoly = L.polyline([src, dst], {
            color: '#ff3333', weight: w, opacity: 0.8,
            interactive: false, bubblingMouseEvents: true,
        });
        const pair = L.layerGroup([hitPoly, visPoly]);
        hitPoly.bindTooltip(
            `${escapeHtml(e.source)} → ${escapeHtml(e.target)}<br>` +
            `${e.comm_count} 次 · ${(e.total_bytes/1024).toFixed(1)} KB` +
            `<br><span style="color:#aaa;">🖱 点击查看详情</span>`,
            {sticky: true, direction: 'top'}
        );
        hitPoly.on('click', function(ev) {
            L.DomEvent.stopPropagation(ev);
            loadEdgeDetail(e);
        });
        hitPoly.on('mouseover', function() {
            visPoly.setStyle({ color: '#e60000', weight: Math.min(w + 2, 8), opacity: 1 });
        });
        hitPoly.on('mouseout', function() {
            visPoly.setStyle({ color: '#ff3333', weight: w, opacity: 0.8 });
        });
        polys.push(pair);
    });

    if (polys.length) {
        _edgeLayer = L.layerGroup(polys).addTo(SA.map);
        // Fit map to show these edges
        const allPts = [];
        polys.forEach(p => {
            const latlngs = p.getLayers()[0].getLatLngs();
            allPts.push(latlngs[0]); allPts.push(latlngs[1]);
        });
        if (allPts.length) {
            try { SA.map.fitBounds(L.latLngBounds(allPts), {padding: [50, 50], maxZoom: 8}); } catch(e) {}
        }
    }
}

function highlightEdge(idx) {
    // Visual feedback when edge is clicked in the panel
    if (!_edgeLayer) return;
    const layers = _edgeLayer.getLayers();
    layers.forEach((l, i) => {
        // Each entry is a layerGroup containing [hitPoly, visPoly]
        const children = l.getLayers ? l.getLayers() : [];
        const visPoly = children.length >= 2 ? children[1] : children[0];
        if (visPoly && visPoly.setStyle) {
            visPoly.setStyle({
                color: i === idx ? '#e60000' : '#ff6666',
                weight: i === idx ? 6 : 2,
                opacity: i === idx ? 1 : 0.3
            });
        }
    });
}

/* ── Edge rendering on map ────────────────────────────── */
let _edgeLayer = null;
let _savedEdgeLayer = null;  // Saved edge layer when node is highlighted

function renderEdgesOnMap() {
    if (!SA.map) return;
    // Don't overwrite highlighted node edges
    if (SA._highlightedNodeEdges && SA._highlightedNodeEdges.length) return;
    if (_edgeLayer) SA.map.removeLayer(_edgeLayer);
    if (!document.getElementById('saShowEdges')?.checked) {
        setText('saEdgeCount', '');
        return;
    }
    if (!SA.edgesData || !SA.edgesData.length) {
        setText('saEdgeCount', '0 条边');
        return;
    }

    const nodes = SA.nodes || [];
    const ipCoord = {};
    nodes.forEach(n => {
        if (n.lat != null && n.lon != null) ipCoord[n.ip] = [n.lat, n.lon];
    });

    const minFreq = parseInt(document.getElementById('saEdgeFreq')?.value || 1);
    const maxCount = Math.max(...SA.edgesData.map(e => e.comm_count || 1), 1);
    const edgePolys = [];
    let shown = 0;

    SA.edgesData.forEach(e => {
        if ((e.comm_count || 0) < minFreq) return;
        const srcCoord = ipCoord[e.source];
        const dstCoord = ipCoord[e.target];
        if (!srcCoord || !dstCoord) return;
        if (shown >= 300) return;
        shown++;

        // 线宽：最小2px，最大6px，按频次占比缩放
        const ratio = (e.comm_count || 1) / maxCount;
        const weight = Math.max(2, Math.min(ratio * 5 + 1.5, 6));
        const opacity = 0.45 + ratio * 0.35;  // 0.45 ~ 0.80

        // 底层的粗透明线 —— 用于点击捕获（不可见但可交互）
        const hitPoly = L.polyline([srcCoord, dstCoord], {
            color: '#ff6b6b', weight: 12, opacity: 0,
            interactive: true, bubblingMouseEvents: false,
        });
        // 上层的细线 —— 用于展示
        const visPoly = L.polyline([srcCoord, dstCoord], {
            color: '#ff6b6b', weight: weight, opacity: opacity,
            interactive: false, bubblingMouseEvents: true,
        });

        // 将两条线绑定为一个组
        const pair = L.layerGroup([hitPoly, visPoly]);

        hitPoly.bindTooltip(
            `<b>${escapeHtml(e.source)} → ${escapeHtml(e.target)}</b><br>` +
            `${e.comm_count} 次 · ${e.total_bytes ? (e.total_bytes/1024).toFixed(1)+'KB' : ''}` +
            `<br><span style="color:#aaa;">🖱 点击查看详情</span>`,
            {sticky: true, direction: 'top'}
        );
        hitPoly.on('click', function(ev) {
            L.DomEvent.stopPropagation(ev);
            loadEdgeDetail(e);
        });
        // Hover 高亮
        hitPoly.on('mouseover', function() {
            visPoly.setStyle({ color: '#e63946', weight: Math.min(weight + 2, 8), opacity: 0.95 });
        });
        hitPoly.on('mouseout', function() {
            visPoly.setStyle({ color: '#ff6b6b', weight: weight, opacity: opacity });
        });

        edgePolys.push(pair);
    });

    if (edgePolys.length) _edgeLayer = L.layerGroup(edgePolys).addTo(SA.map);
    setText('saEdgeCount', `${shown} 条边`);
}

function loadEdgeDetail(e) {
    const panel = document.getElementById('saDetailPanel');
    const body = document.getElementById('saDetailBody');
    if (!panel || !body) return;

    // Destroy previous chart if any
    if (SA._edgeTimeChart) { SA._edgeTimeChart.destroy(); SA._edgeTimeChart = null; }

    setText('saDetailTitle', `边详情`);
    const titleEl2 = document.getElementById('saDetailTitle');
    if (titleEl2) {
        titleEl2.innerHTML += ` <button class=\"btn\" style=\"font-size:0.7rem;padding:0.15rem 0.5rem;margin-left:0.5rem;\" onclick=\"showEntityTrafficView('${escapeHtml(e.source)}')\">📡 源IP流量</button>`;
    }
    const toBeijing = (ts) => {
        if (!ts) return '—';
        try {
            // Handle various timestamp formats
            let clean = ts.replace(/\.\d+$/, '').replace(' ', 'T');
            if (!clean.endsWith('Z') && !clean.includes('+')) clean += 'Z';
            const d = new Date(clean);
            if (isNaN(d.getTime())) return ts.slice(0, 19);
            const bj = new Date(d.getTime() + 8 * 3600000);
            return bj.toISOString().replace('T', ' ').slice(0, 19) + ' (北京)';
        } catch(e) { return ts.slice(0, 19); }
    };
    const avgBytes = e.comm_count > 0 ? (e.total_bytes / e.comm_count).toFixed(1) : '—';
    const timeCount = e.time_count || 0;
    const peakHours = e.peak_hours || '';
    const srcHost = e.src_host || '';
    const dstHost = e.dst_host || '';
    const srcOrg = e.src_org || '';
    const dstOrg = e.dst_org || '';
    const srcCity = e.src_city || '';
    const dstCity = e.dst_city || '';
    const srcCountry = e.src_country || '';
    const dstCountry = e.dst_country || '';

    // Enriched edge attributes helper
    const edgeAttrToHtml = (items, title, icon) => {
        if (!items || !items.length) return '';
        return `<div class="section-title">${icon} ${title}</div>
            <div>${items.slice(0,4).map(i => `<span class="sa-tag" style="font-size:0.75rem;">${escapeHtml(i.value||i)} (${i.count||''})</span>`).join(' ')}</div>`;
    };

    // Extra traffic stats row
    const totalPkts = e.total_packets;
    const avgDur = e.avg_duration;
    let extraStats = '';
    if (totalPkts != null || avgDur != null) {
        extraStats = '<div class="stat-row">';
        if (totalPkts != null) extraStats += `<div class="stat-card"><div class="num">${Number(totalPkts).toLocaleString()}</div><div class="lbl">总包数</div></div>`;
        if (avgDur != null) extraStats += `<div class="stat-card"><div class="num">${avgDur}s</div><div class="lbl">平均持续时间</div></div>`;
        extraStats += '</div>';
    }

    body.innerHTML =
        `<div class="stat-row">` +
        `<div class="stat-card"><div class="num">${e.comm_count}</div><div class="lbl">通信次数</div></div>` +
        `<div class="stat-card"><div class="num">${(e.total_bytes/1024).toFixed(1)} KB</div><div class="lbl">总流量</div></div>` +
        `<div class="stat-card"><div class="num">${avgBytes} B</div><div class="lbl">平均单次流量</div></div></div>` +
        extraStats +
        `<div class="stat-row">` +
        `<div class="stat-card"><div class="num" style="font-size:0.8rem;">${toBeijing(e.first_seen)}</div><div class="lbl">首次通信</div></div>` +
        `<div class="stat-card"><div class="num" style="font-size:0.8rem;">${toBeijing(e.last_seen)}</div><div class="lbl">末次通信</div></div></div>` +
        (timeCount ? `<div class="stat-row"><div class="stat-card"><div class="num">${timeCount}</div><div class="lbl">时间样本数</div></div>` : '') +
        (peakHours ? `<div class="stat-card"><div class="num">${peakHours}:00</div><div class="lbl">高峰时段 (UTC)</div></div>` : '') +
        (timeCount || peakHours ? `</div>` : '') +
        `<div class="section-title">源 IP</div>
        <div><span class="sa-tag" style="cursor:pointer;color:#4361ee;" onclick="loadNodeDetail('${escapeHtml(e.source)}')">${escapeHtml(e.source)}</span>
        ${srcHost ? ` <span style="font-size:0.75rem;color:#666;">${escapeHtml(srcHost)}</span>` : ''}
        ${srcOrg ? ` <span style="font-size:0.75rem;color:#888;">· ${escapeHtml(srcOrg)}</span>` : ''}
        ${srcCity ? ` <span style="font-size:0.75rem;color:#999;">· ${escapeHtml(srcCity)}${srcCountry ? ', ' + escapeHtml(srcCountry) : ''}</span>` : ''}
        </div>` +
        `<div class="section-title">目的 IP</div>
        <div><span class="sa-tag" style="cursor:pointer;color:#4361ee;" onclick="loadNodeDetail('${escapeHtml(e.target)}')">${escapeHtml(e.target)}</span>
        ${dstHost ? ` <span style="font-size:0.75rem;color:#666;">${escapeHtml(dstHost)}</span>` : ''}
        ${dstOrg ? ` <span style="font-size:0.75rem;color:#888;">· ${escapeHtml(dstOrg)}</span>` : ''}
        ${dstCity ? ` <span style="font-size:0.75rem;color:#999;">· ${escapeHtml(dstCity)}${dstCountry ? ', ' + escapeHtml(dstCountry) : ''}</span>` : ''}
        </div>` +
        edgeAttrToHtml(e.top_snis, 'SNI / 域名', '🌐') +
        edgeAttrToHtml(e.top_tls_vers, 'TLS 版本', '🔒') +
        edgeAttrToHtml(e.top_ciphers, '加密套件', '🔑') +
        edgeAttrToHtml(e.top_ports, '目的端口', '🔌') +
        edgeAttrToHtml(e.top_categories, '分类标签', '🏷') +
        `<div class="section-title">📈 流量时序图 (北京时间)</div>
        <div id="saEdgeTsChart" style="position:relative; width:100%; height:220px;">
            <canvas id="saEdgeTimeCanvas" style="width:100%;height:100%;"></canvas>
        </div>` +
        `<div style="font-size:0.7rem;color:#999;text-align:center;margin-top:0.25rem;" id="saEdgeTsNote"></div>`;
    panel.classList.add('open');

    // ── Fetch time-series data ──
    const tsContainer = document.getElementById('saEdgeTsChart');
    const tsNote = document.getElementById('saEdgeTsNote');
    if (!tsContainer || !tsNote) return;
    tsNote.textContent = '加载中...';

    fetch('/simple/edge-time-series/', {
        method: 'POST',
        headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
        body: JSON.stringify({session_id: SA.sessionId, source: e.source, target: e.target}),
    })
    .then(r => r.json())
    .then(data => {
        if (data.error) {
            tsNote.textContent = '⚠ ' + data.error;
            tsContainer.style.display = 'none';
            return;
        }
        const series = data.time_series || [];
        if (!series.length) {
            tsNote.textContent = '无时间数据';
            tsContainer.style.display = 'none';
            return;
        }
        // Build config for dual-axis chart
        const labels = series.map(d => d.t);
        const countData = series.map(d => d.count);

        const datasets = [{
            label: '通信次数',
            data: countData,
            borderColor: '#4361ee',
            backgroundColor: 'rgba(67,97,238,0.08)',
            fill: true,
            tension: 0.3,
            pointRadius: series.length > 60 ? 0 : 2,
            borderWidth: 2,
            yAxisID: 'y',
        }];

        if (data.has_bytes) {
            const bytesData = series.map(d => d.bytes / 1024);
            datasets.push({
                label: '流量 (KB)',
                data: bytesData,
                borderColor: '#e63946',
                backgroundColor: 'rgba(230,57,70,0.06)',
                fill: true,
                tension: 0.3,
                pointRadius: series.length > 60 ? 0 : 2,
                borderWidth: 2,
                yAxisID: 'y1',
            });
        }

        const ctx = document.getElementById('saEdgeTimeCanvas');
        if (!ctx) return;
        SA._edgeTimeChart = new Chart(ctx.getContext('2d'), {
            type: 'line',
            data: { labels, datasets },
            options: {
                responsive: true,
                maintainAspectRatio: false,
                interaction: { mode: 'index', intersect: false },
                plugins: {
                    legend: {
                        display: true,
                        position: 'top',
                        labels: { boxWidth: 12, font: { size: 11 }, padding: 16 }
                    },
                    tooltip: {
                        callbacks: {
                            title: function(items) { return '🕐 ' + items[0].label + ' (北京)'; }
                        }
                    }
                },
                scales: {
                    x: {
                        display: true,
                        ticks: {
                            font: { size: 10 },
                            maxTicksLimit: series.length > 30 ? 12 : series.length,
                            maxRotation: 45,
                        },
                        grid: { display: false }
                    },
                    y: {
                        type: 'linear',
                        display: true,
                        position: 'left',
                        title: { display: true, text: '次数', font: { size: 10 } },
                        beginAtZero: true,
                        ticks: { font: { size: 10 } },
                        grid: { color: '#f0f0f0' }
                    },
                    y1: {
                        type: 'linear',
                        display: data.has_bytes,
                        position: 'right',
                        title: { display: true, text: 'KB', font: { size: 10 } },
                        beginAtZero: true,
                        ticks: { font: { size: 10 } },
                        grid: { display: false }
                    }
                }
            }
        });

        const spanH = data.span_hours || 0;
        const bucketM = data.bucket_minutes || 0;
        tsNote.textContent = `${series.length} 个时间桶 · 跨度 ${spanH.toFixed(1)}h · 桶粒度 ${bucketM}min · ${data.n_raw} 条原始记录`;
    })
    .catch(err => {
        tsNote.textContent = '⚠ 加载失败: ' + err.message;
        tsContainer.style.display = 'none';
    });
}

function toggleEdges() {
    if (document.getElementById('saShowEdges')?.checked) {
        renderEdgesOnMap();
    } else {
        if (_edgeLayer && SA.map) { SA.map.removeLayer(_edgeLayer); _edgeLayer = null; }
        setText('saEdgeCount', '');
    }
}

/* ── Export ────────────────────────────────────────────── */
function exportClusterCSV() {
    // Export nodes CSV with all attributes
    const nodes = SA.nodes || [];
    if (!nodes.length) { showError('无数据可导出'); return; }
    const headers = ['ip', 'cluster_id', 'lat', 'lon', 'city', 'country', 'isp', 'is_server',
                     'host', 'org', 'asn', 'peer_count', 'connected_ips'];
    const rows = [headers.join(',')];
    nodes.forEach(n => {
        const peerIps = (n.peer_ips || []).join('; ');
        rows.push([n.ip, n.cluster_id, n.lat || '', n.lon || '',
                   (n.city || '').replace(/,/g, ' '),
                   n.country || '', (n.isp || '').replace(/,/g, ' '),
                   n.is_server ? '1' : '0',
                   (n.host || '').replace(/,/g, ' '),
                   (n.org || '').replace(/,/g, ' '),
                   n.asn || '',
                   n.peer_count || 0,
                   peerIps].join(','));
    });
    downloadCSV(rows.join('\n'), `nodes_${SA.sessionId}.csv`);
}

function exportEdgesCSV() {
    const edges = SA.edgesData || [];
    if (!edges.length) { showError('无边数据可导出'); return; }
    const headers = ['src_ip', 'dst_ip', 'comm_count', 'total_bytes', 'first_seen', 'last_seen',
                     'peak_hours', 'time_count',
                     'src_host', 'dst_host', 'src_org', 'dst_org',
                     'src_city', 'dst_city', 'src_country', 'dst_country',
                     'src_cluster', 'dst_cluster'];
    const rows = [headers.join(',')];
    edges.forEach(e => {
        rows.push([e.source, e.target, e.comm_count, e.total_bytes || 0,
                   e.first_seen || '', e.last_seen || '',
                   e.peak_hours || '', e.time_count || 0,
                   (e.src_host || '').replace(/,/g, ' '), (e.dst_host || '').replace(/,/g, ' '),
                   (e.src_org || '').replace(/,/g, ' '), (e.dst_org || '').replace(/,/g, ' '),
                   e.src_city || '', e.dst_city || '',
                   e.src_country || '', e.dst_country || '',
                   e.src_cluster != null ? e.src_cluster : '',
                   e.dst_cluster != null ? e.dst_cluster : ''].join(','));
    });
    downloadCSV(rows.join('\n'), `edges_${SA.sessionId}.csv`);
}

function downloadCSV(csv, filename) {
    const blob = new Blob(['﻿' + csv], {type: 'text/csv;charset=utf-8;'});
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    a.click();
    URL.revokeObjectURL(a.href);
    showSuccess('导出成功: ' + filename);
}

/* ── Page init ─────────────────────────────────────────── */
(function() {
    restorePreset();
    // Check GeoIP precision
    fetch('/simple/geoip-status/', {headers: {'X-CSRFToken': getCSRF()}})
        .then(r => r.json())
        .then(d => {
            updateDebug({geo: d.source || '—'});
            if (!d.using_mmdb) {
                const el = document.getElementById('saDebugNote');
                if (el) el.textContent = '提示: 放入 GeoLite2-City.mmdb 到 data/ 可获精确坐标';
            }
        })
        .catch(() => {});
})();

/* ── Cluster detail + focus ────────────────────────────── */
let _clusterFocusLayer = null;  // LayerGroup for cluster-focused edges
let _clusterNodeLayer = null;  // LayerGroup for focused node markers

function loadClusterDetail(label) {
    showLoading('加载集群详情...');
    fetch('/simple/cluster-nodes-edges/', {
        method: 'POST',
        headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
        body: JSON.stringify({session_id: SA.sessionId, cluster_label: label}),
    })
    .then(r => r.json())
    .then(data => {
        hideLoading();
        if (data.error) { showError(data.error); return; }
        // Ensure map exists — go to step 4 if needed
        if (!SA.map) {
            goStep(4);
            // Wait for map to finish initializing, then focus
            let attempts = 0;
            function waitThenFocus() {
                if (SA.map) {
                    focusClusterOnMap(data);
                    showClusterDetailPanelV2(data);
                } else if (attempts < 30) {
                    attempts++;
                    setTimeout(waitThenFocus, 200);
                } else {
                    // Fallback: just show panel without map focus
                    showClusterDetailPanelV2(data);
                }
            }
            setTimeout(waitThenFocus, 500);
        } else {
            focusClusterOnMap(data);
            showClusterDetailPanelV2(data);
        }
    })
    .catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}

function focusClusterOnMap(data) {
    if (!SA.map) return;
    clearClusterFocus();

    // 1. Zoom to bounds
    const bounds = data.geo_bounds;
    if (bounds) {
        try {
            SA.map.fitBounds(
                [[bounds.lat_min, bounds.lon_min], [bounds.lat_max, bounds.lon_max]],
                {padding: [60, 60], maxZoom: 10}
            );
        } catch(e) {}
    }

    // 2. Highlight cluster nodes with larger markers
    const clusterNodes = data.nodes || [];
    const clusterIps = new Set(clusterNodes.map(n => n.ip));
    const clusterColors = {};
    clusterNodes.forEach(n => {
        clusterColors[n.ip] = getSubnetColor(n.ip);
    });

    // Redraw all markers in focus mode
    const allNodes = SA.nodes || [];
    const markers = [];
    const centerMarkers = [];

    allNodes.forEach(n => {
        if (n.lat == null || n.lon == null) return;
        const inCluster = clusterIps.has(n.ip);
        const color = inCluster ? (clusterColors[n.ip] || '#4361ee') : '#ccc';
        const radius = inCluster ? 10 : 4;
        const opacity = inCluster ? 1 : 0.35;

        const circle = L.circleMarker([n.lat, n.lon], {
            radius, fillColor: color, color: inCluster ? '#333' : '#ddd',
            weight: inCluster ? 2 : 1, opacity, fillOpacity: opacity,
            interactive: true, bubblingMouseEvents: false,
        });
        circle.bindTooltip(buildNodeTooltip(n), {sticky: true, direction: 'top', offset: [0, -8]});
        circle.on('click', function(e) { L.DomEvent.stopPropagation(e); loadNodeDetail(n.ip); });
        markers.push(circle);
    });

    // Community center markers for other clusters
    const clusters = SA.clusterMap || {};
    const colors = generateColors(20);
    const clusterColorMap = {};
    Object.values(clusters).sort((a, b) => b.size - a.size).forEach((c, i) => {
        clusterColorMap[c.label] = colors[i % colors.length];
    });

    Object.entries(clusters).forEach(([cid, cinfo]) => {
        const cidInt = parseInt(cid);
        if (cidInt === data.cluster_label) return; // Skip focused cluster
        if (cinfo.size <= 1 || SA.hiddenClusters.has(cidInt)) return;
        if (cinfo.center_lat == null || cinfo.center_lon == null) return;
        const color = clusterColorMap[cidInt] || '#4361ee';
        const circle = L.circleMarker([cinfo.center_lat, cinfo.center_lon], {
            radius: 8, fillColor: color, color: '#fff', weight: 2,
            opacity: 0.7, fillOpacity: 0.5, interactive: true, bubblingMouseEvents: false,
        });
        circle.bindTooltip(`<b>社区 ${cidInt}</b><br>${cinfo.size} IP`, {direction: 'top'});
        circle.on('click', function(e) { L.DomEvent.stopPropagation(e); loadClusterDetail(cidInt); });
        centerMarkers.push(circle);
    });

    // Replace point layers
    if (SA.pointLayer) SA.map.removeLayer(SA.pointLayer);
    if (SA.centerLayer) SA.map.removeLayer(SA.centerLayer);
    SA.pointLayer = L.layerGroup(markers).addTo(SA.map);
    SA.centerLayer = L.layerGroup(centerMarkers).addTo(SA.map);

    // 3. Render cluster edges (internal + external) as clickable
    const allEdges = [...(data.internal_edges || []), ...(data.external_edges || [])];
    if (allEdges.length && SA.map) {
        const ipCoord = {};
        (SA.nodes || []).forEach(n => {
            if (n.lat != null && n.lon != null) ipCoord[n.ip] = [n.lat, n.lon];
        });

        const edgePolys = [];
        allEdges.forEach((e, i) => {
            const src = ipCoord[e.source];
            const dst = ipCoord[e.target];
            if (!src || !dst) return;
            const isInternal = clusterIps.has(e.source) && clusterIps.has(e.target);
            const w = Math.max(1.5, Math.min((e.comm_count || 1) / 5 + 1, 4));
            const edgeColor = isInternal ? '#e63946' : '#f77f00';

            const hitPoly = L.polyline([src, dst], {
                color: edgeColor, weight: 10, opacity: 0,
                interactive: true, bubblingMouseEvents: false,
            });
            const visPoly = L.polyline([src, dst], {
                color: edgeColor, weight: w, opacity: 0.75,
                interactive: false, bubblingMouseEvents: true,
            });
            const pair = L.layerGroup([hitPoly, visPoly]);
            hitPoly.bindTooltip(
                `${escapeHtml(e.source)} → ${escapeHtml(e.target)}<br>` +
                `${e.comm_count} 次 · ${(e.total_bytes/1024).toFixed(1)} KB` +
                `<br><span style="color:#aaa;">🖱 点击查看详情</span>`,
                {sticky: true, direction: 'top'}
            );
            hitPoly.on('click', function(ev) {
                L.DomEvent.stopPropagation(ev);
                loadEdgeDetail(e);
            });
            hitPoly.on('mouseover', function() {
                visPoly.setStyle({ color: '#e60000', weight: Math.min(w + 3, 8), opacity: 1 });
            });
            hitPoly.on('mouseout', function() {
                visPoly.setStyle({ color: edgeColor, weight: w, opacity: 0.75 });
            });
            edgePolys.push(pair);
        });

        _clusterFocusLayer = L.layerGroup(edgePolys).addTo(SA.map);
    }

    // 4. Store focus state
    SA._clusterFocus = data;
    // Hide general edge layer if visible
    if (_edgeLayer && SA.map) { SA.map.removeLayer(_edgeLayer); }
}

function clearClusterFocus() {
    if (_clusterFocusLayer && SA.map) { SA.map.removeLayer(_clusterFocusLayer); _clusterFocusLayer = null; }
    SA._clusterFocus = null;
    SA._highlightedNodeEdges = null;

    // Restore normal markers
    if (SA.map && SA.nodes && SA.nodes.length) {
        renderMapData();
    }
    // Restore general edge layer
    if (_edgeLayer) {
        _edgeLayer.addTo(SA.map);
    } else if (document.getElementById('saShowEdges')?.checked) {
        renderEdgesOnMap();
    }
}

function showClusterDetailPanelV2(data) {
    const panel = document.getElementById('saDetailPanel');
    const body = document.getElementById('saDetailBody');
    if (!panel || !body) return;
    setText('saDetailTitle', `社区 ${data.cluster_label} 详情`);

    const ci = data.cluster_info || {};
    const bounds = data.geo_bounds;
    let geoHtml = '';
    if (bounds) {
        geoHtml = `<div class="section-title">📍 地理范围</div>
            <div style="font-size:0.78rem;">lat: ${bounds.lat_min} ~ ${bounds.lat_max}<br>lon: ${bounds.lon_min} ~ ${bounds.lon_max}</div>`;
    }

    const distToHtml = (items, title) => {
        if (!items || !items.length) return '';
        const maxC = Math.max(...items.map(i => i.count));
        return `<div class="section-title">${title}</div>
            <div>${items.slice(0,5).map(i =>
                `<span class="sa-tag" style="font-size:${0.75 + 0.25*i.count/maxC}rem;">${escapeHtml(i.value)} (${i.count})</span>`
            ).join(' ')}</div>`;
    };

    // Internal edges list (clickable)
    const intEdges = data.internal_edges || [];
    let intEdgeHtml = '';
    if (intEdges.length) {
        intEdgeHtml = `<div class="section-title">🔴 内部边 (${intEdges.length})</div>
            <div style="max-height:150px;overflow-y:auto;font-size:0.75rem;">`;
        intEdges.slice(0, 20).forEach((e, i) => {
            intEdgeHtml += `<div style="padding:0.15rem 0.3rem;cursor:pointer;border-radius:3px;"
                onmouseover="this.style.background='#fef0f0'" onmouseout="this.style.background=''"
                onclick="loadEdgeDetail(SA._clusterFocus.internal_edges[${i}])">
                ${escapeHtml(e.source)} → ${escapeHtml(e.target)} · ${e.comm_count}次 · ${(e.total_bytes/1024).toFixed(1)}KB</div>`;
        });
        if (intEdges.length > 20) intEdgeHtml += `<div style="color:#999;">... 还有 ${intEdges.length-20} 条</div>`;
        intEdgeHtml += '</div>';
    }

    // External edges list (clickable)
    const extEdges = data.external_edges || [];
    let extEdgeHtml = '';
    if (extEdges.length) {
        extEdgeHtml = `<div class="section-title">🟠 外部关联边 (${extEdges.length})</div>
            <div style="max-height:150px;overflow-y:auto;font-size:0.75rem;">`;
        extEdges.slice(0, 20).forEach((e, i) => {
            extEdgeHtml += `<div style="padding:0.15rem 0.3rem;cursor:pointer;border-radius:3px;"
                onmouseover="this.style.background='#fff8f0'" onmouseout="this.style.background=''"
                onclick="loadEdgeDetail(SA._clusterFocus.external_edges[${i}])">
                ${escapeHtml(e.source)} → ${escapeHtml(e.target)} · ${e.comm_count}次 · ${(e.total_bytes/1024).toFixed(1)}KB</div>`;
        });
        if (extEdges.length > 20) extEdgeHtml += `<div style="color:#999;">... 还有 ${extEdges.length-20} 条</div>`;
        extEdgeHtml += '</div>';
    }

    // Node IPs (clickable)
    const clusterNodes = data.nodes || [];
    let ipHtml = `<div class="section-title">🔗 节点 (${clusterNodes.length})</div><div class="ip-list">`;
    clusterNodes.slice(0, 50).forEach(n => {
        ipHtml += `<span class="sa-tag" style="cursor:pointer;color:#4361ee;margin:0.1rem;"
            onclick="loadNodeDetail('${escapeHtml(n.ip)}')">${escapeHtml(n.ip)}</span>`;
    });
    if (clusterNodes.length > 50) ipHtml += `<div style="font-size:0.75rem;color:#999;">... 还有 ${clusterNodes.length-50} 个</div>`;
    ipHtml += '</div>';

    // Adjacent nodes
    const adjNodes = data.adjacent_nodes || [];
    let adjHtml = '';
    if (adjNodes.length) {
        adjHtml = `<div class="section-title">🌐 邻接节点 (${adjNodes.length})</div><div class="ip-list">`;
        adjNodes.slice(0, 30).forEach(n => {
            adjHtml += `<span class="sa-tag" style="cursor:pointer;color:#e76f51;margin:0.1rem;"
                onclick="loadNodeDetail('${escapeHtml(n.ip)}')">${escapeHtml(n.ip)}</span>`;
        });
        if (adjNodes.length > 30) adjHtml += `<div style="font-size:0.75rem;color:#999;">... 还有 ${adjNodes.length-30} 个</div>`;
        adjHtml += '</div>';
    }

    body.innerHTML =
        `<div style="margin-bottom:0.5rem;">
            <button class="btn" onclick="clearClusterFocus()" style="font-size:0.75rem;padding:0.2rem 0.5rem;">🗺 恢复全局视图</button>
        </div>
        <div class="stat-row">
            <div class="stat-card"><div class="num">${data.n_nodes || 0}</div><div class="lbl">节点数</div></div>
            <div class="stat-card"><div class="num">${data.n_internal_edges || 0}</div><div class="lbl">内部边</div></div>
            <div class="stat-card"><div class="num">${data.n_external_edges || 0}</div><div class="lbl">外部边</div></div>
        </div>
        <div class="stat-row">
            <div class="stat-card"><div class="num">${(data.total_internal_bytes/1024).toFixed(1)} KB</div><div class="lbl">内部流量</div></div>
            <div class="stat-card"><div class="num">${(data.total_external_bytes/1024).toFixed(1)} KB</div><div class="lbl">关联流量</div></div>
        </div>
        ${geoHtml}
        ${distToHtml(data.top_isps, '🏢 ISP 分布')}
        ${distToHtml(data.top_cities, '🏙 城市分布')}
        ${distToHtml(data.top_countries, '🌍 国家分布')}
        ${intEdgeHtml}
        ${extEdgeHtml}
        ${ipHtml}
        ${adjHtml}`;

    panel.classList.add('open');
}

/* ── Close detail ───────────────────────────────────────── */
function closeDetail() {
    document.getElementById('saDetailPanel').classList.remove('open');
    // Destroy edge time-series chart
    if (SA._edgeTimeChart) { SA._edgeTimeChart.destroy(); SA._edgeTimeChart = null; }
    // Clear cluster focus if active
    if (SA._clusterFocus) { clearClusterFocus(); }
    // Remove highlighted node edges, restore saved general edge layer
    if (_edgeLayer && SA._highlightedNodeEdges && SA._highlightedNodeEdges.length) {
        SA.map.removeLayer(_edgeLayer);
        _edgeLayer = null;
    }
    SA._highlightedNodeEdges = null;
    // Restore saved edge layer or re-render if toggle on
    if (_savedEdgeLayer) {
        _edgeLayer = _savedEdgeLayer;
        _savedEdgeLayer = null;
        _edgeLayer.addTo(SA.map);
    } else if (document.getElementById('saShowEdges')?.checked) {
        renderEdgesOnMap();
    }
}

/* ── Reset ─────────────────────────────────────────────── */
function resetAll() {
    SA.sessionId = null;
    SA.scanId = null;
    SA.nodes = [];
    SA.edgesData = null;
    SA.clusterLabels = [];
    SA.clusterMap = {};
    SA.dataPoints = {};
    SA.hiddenClusters = new Set();
    _pendingFiles = null;
    if (_edgeLayer && SA.map) { SA.map.removeLayer(_edgeLayer); _edgeLayer = null; }
    document.getElementById('saFileList').innerHTML = '';
    document.getElementById('saUploadResult').style.display = 'none';
    document.getElementById('saPresetFilterArea').style.display = 'none';
    document.getElementById('saClusterResult').style.display = 'none';
    document.getElementById('saPreviewWrap').querySelector('table').innerHTML = '';
    if (SA.map) { SA.map.remove(); SA.map = null; }
    if (SA.barChart) { SA.barChart.destroy(); SA.barChart = null; }
    if (SA.pieChart) { SA.pieChart.destroy(); SA.pieChart = null; }
    if (SA._edgeTimeChart) { SA._edgeTimeChart.destroy(); SA._edgeTimeChart = null; }
    clearClusterFocus();
    updateDebug({status: '等待上传'});
    goStep(1);
}

/* ── Helpers ──────────────────────────────────────────── */
function getCSRF() {
    const name = 'csrftoken';
    const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
    return match ? match[2] : '';
}

function generateColors(n) {
    const palette = [
        '#4361ee', '#43aa8b', '#f72585', '#f4a261', '#e76f51',
        '#264653', '#2a9d8f', '#e9c46a', '#8ecae6', '#219ebc',
        '#023047', '#ffb703', '#fb8500', '#6a4c93', '#ff6b6b',
        '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9',
    ];
    return Array.from({length: n}, (_, i) => palette[i % palette.length]);
}

/* ── Traffic Flow Animation (crossfade window transitions) ── */
let _trafficData = null;
let _trafficAnimId = null;
let _trafficPlaying = false;
let _trafficSpeed = 1;
let _trafficCurrentTime = 0;
let _trafficPrevFrameTs = 0;
let _trafficSeekTarget = null;
let _trafficEdges = [];      // flat array: {source,target,count,bytes,first_ts,last_ts}
let _trafficBuckets = {};    // time-bucket → [edge indices]

// 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:fixed;pointer-events:none;z-index:99999;';
        document.body.appendChild(c);
    }
    const s = SA.map.getSize();
    const mr = SA.map.getContainer().getBoundingClientRect();
    c.width = s.x; c.height = s.y;
    c.style.width = s.x + 'px'; c.style.height = s.y + 'px';
    c.style.left = mr.left + 'px'; c.style.top = mr.top + '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: 500}),
    })
    .then(r => r.json())
    .then(d => {
        hideLoading();
        if (d.error) { showError(d.error); return; }
        let minTs = d.min_ts || 0;
        let maxTs = d.max_ts || (minTs + 60);
        if (maxTs - minTs < 10) maxTs = minTs + 60;

        _trafficEdges = d.edges || [];
        _trafficData = d;
        d._minTs = minTs; d._maxTs = maxTs;

        // Time-bucket index. Stretch short lifetimes so edges are visible.
        _trafficBuckets = {};
        const bucketSz = 60;
        let minFs = Infinity, maxLs = -Infinity;
        _trafficEdges.forEach((e, idx) => {
            let fs = e.first_ts, ls = e.last_ts;
            if (ls - fs < 5) { fs -= 30; ls += 30; }
            e._fs = fs; e._ls = ls;
            if (fs < minFs) minFs = fs;
            if (ls > maxLs) maxLs = ls;
            for (let b = Math.floor(fs / bucketSz); b <= Math.floor(ls / bucketSz); b++) {
                if (!_trafficBuckets[b]) _trafficBuckets[b] = [];
                _trafficBuckets[b].push(idx);
            }
        });
        // Use stretched range for animation span
        if (minFs < Infinity) d._minTs = minFs;
        if (maxLs > -Infinity) d._maxTs = maxLs;
        if (d._maxTs - d._minTs < 10) d._maxTs = d._minTs + 60;
        _trafficCurrentTime = d._minTs;

        document.getElementById('saTrafficSlider').min = 0;
        document.getElementById('saTrafficSlider').max = 1000;
        document.getElementById('saTrafficSlider').value = 0;
        document.getElementById('saTrafficInfo').textContent =
            _trafficEdges.length + ' 条边 · ' + Object.keys(d.nodes_coords||{}).length + ' 节点 · 连续生命周期';
        document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
        trafficStart();
    })
    .catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}

function reloadTrafficData() { trafficStop(); loadTrafficData(); }

// No more windows — edge lifecycle based

function trafficSeek(val) {
    if (!_trafficData) return;
    const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
    _trafficSeekTarget = minTs + (val / 1000) * (maxTs - minTs);
}

function trafficStepForward() {
    if (!_trafficData) return;
    _trafficSeekTarget = Math.min(_trafficData._maxTs, _trafficCurrentTime + 10);
}

function trafficStepBack() {
    if (!_trafficData) return;
    _trafficSeekTarget = Math.max(_trafficData._minTs, _trafficCurrentTime - 10);
}

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; _trafficEdges = []; _trafficBuckets = {};
    _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 edges = _trafficEdges || [];
    if (!edges.length) return;
    const coords = _trafficData.nodes_coords || {};
    const now = _trafficCurrentTime;

    // Use time-bucket index to only check nearby edges
    const bucketSz = 60;
    const curBucket = Math.floor(now / bucketSz);
    let candidateIdx = new Set();
    for (let b = curBucket - 1; b <= curBucket + 1; b++) {
        const bucket = _trafficBuckets[b];
        if (bucket) bucket.forEach(idx => candidateIdx.add(idx));
    }
    // Fallback: if no buckets (old data), scan all
    if (candidateIdx.size === 0) {
        edges.forEach((_, i) => candidateIdx.add(i));
    }

    // Filter visible edges + compute alpha
    const visible = [];
    const activeNodes = new Set();
    let maxCount = 1;
    for (const idx of candidateIdx) {
        const e = edges[idx];
        const fs = e._fs || e.first_ts, ls = e._ls || e.last_ts;
        if (!e || now < fs || now > ls) continue;
        const duration = Math.max(1, ls - fs);
        const softEdge = Math.max(0.5, duration * 0.05);
        let alpha = 1;
        if (now - fs < softEdge) alpha = Math.max(0, (now - fs) / Math.max(0.01, softEdge));
        else if (ls - now < softEdge) alpha = Math.max(0, (ls - now) / Math.max(0.01, softEdge));
        if (alpha < 0.03) continue;
        visible.push({...e, alpha});
        activeNodes.add(e.source);
        activeNodes.add(e.target);
        if (e.count > maxCount) maxCount = e.count;
    }

    // Limit max rendered edges for performance
    if (visible.length > 500) {
        visible.sort((a, b) => b.count - a.count);
        visible.length = 500;
    }

    document.getElementById('saTrafficInfo').textContent =
        visible.length + ' 边 · ' + activeNodes.size + ' 节点 · ' + tsToBJ(now);

    visible.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 wa = e.alpha * (0.6 + 0.4 * ratio);

        const grad = ctx.createLinearGradient(p1.x, p1.y, p2.x, p2.y);
        grad.addColorStop(0, 'rgba(46,196,182,' + wa.toFixed(2) + ')');
        grad.addColorStop(1, 'rgba(230,57,70,' + wa.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();

        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.5+0.5*wa) + ')';
            ctx.shadowColor = 'rgba(' + cr + ',' + cg + ',' + cb + ',0.4)';
            ctx.shadowBlur = 5;
            ctx.fill();
            ctx.shadowBlur = 0;
        }
    });

    if (SA.pointLayer && !SA._clusterFocus) {
        SA.pointLayer.getLayers().forEach(l => {
            if (!l.setStyle) return;
            try {
                const ct = l.getTooltip()?.getContent?.() || '';
                const m = ct.match(/\b(\d+\.\d+\.\d+\.\d+)\b/);
                if (m) l.setStyle(activeNodes.has(m[1])
                    ? {fillOpacity:0.95,opacity:1,weight:3,color:'#fff'}
                    : {fillOpacity:0.2,opacity:0.3,weight:1,color:'#999'});
            } catch(e) {}
        });
    }
}

/* ── Entity traffic view in detail panel ────────────── */
function showEntityTrafficView(ip) {
    const panel = document.getElementById('saDetailPanel');
    const body = document.getElementById('saDetailBody');
    if (!panel || !body) return;
    setText('saDetailTitle', `📡 动态流量: ${ip}`);
    showLoading('加载实体流量数据...');

    fetch('/simple/traffic-timeline/entity/', {
        method: 'POST',
        headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
        body: JSON.stringify({session_id: SA.sessionId, entity_ip: ip, window_seconds: 60}),
    })
    .then(r => r.json())
    .then(d => {
        hideLoading();
        if (d.error) { showError(d.error); return; }
        renderEntityTrafficPanel(ip, d);
    })
    .catch(err => { hideLoading(); showError(err.message); });
}

function renderEntityTrafficPanel(ip, data) {
    const body = document.getElementById('saDetailBody');
    if (!body) return;
    if (SA._edgeTimeChart) { SA._edgeTimeChart.destroy(); SA._edgeTimeChart = null; }

    const distHtml = (data.hourly_distribution || []).map(h =>
        `<div style="display:inline-block;width:${Math.max(1,h.count/Math.max(...data.hourly_distribution.map(x=>x.count))*100)}%;height:12px;background:#4361ee;margin-right:1px;" title="${h.hour}时: ${h.count}次"></div>`
    ).join('');

    body.innerHTML =
        `<div class="stat-row">
            <div class="stat-card"><div class="num">${data.n_peers||0}</div><div class="lbl">关联对端</div></div>
            <div class="stat-card"><div class="num">${data.total_count||0}</div><div class="lbl">总通信次数</div></div>
            <div class="stat-card"><div class="num">${(data.total_bytes/1024).toFixed(1)} KB</div><div class="lbl">总流量</div></div>
        </div>
        <div class="section-title">📊 24小时活跃分布 (北京时间)</div>
        <div style="display:flex;align-items:flex-end;height:30px;gap:1px;">${distHtml}</div>
        <div style="display:flex;justify-content:space-between;font-size:0.6rem;color:#999;margin-top:0.2rem;">
            <span>0时</span><span>6时</span><span>12时</span><span>18时</span><span>23时</span></div>
        <div class="section-title">🕐 逐窗口流入/流出</div>
        <div style="max-height:180px;overflow-y:auto;font-size:0.75rem;">
            <canvas id="saEntityTrafficChart" height="160"></canvas>
        </div>
        <div class="section-title">🌐 对端IP (${data.n_peers||0})</div>
        <div>${(data.peers||[]).slice(0,30).map(p => `<span class="sa-tag" style="cursor:pointer;color:#4361ee;margin:0.1rem;" onclick="loadNodeDetail('${escapeHtml(p)}')">${escapeHtml(p)}</span>`).join(' ')}</div>`;

    document.getElementById('saDetailPanel').classList.add('open');

    // Render chart
    const ctx = document.getElementById('saEntityTrafficChart');
    if (!ctx) return;
    const wins = data.windows || [];
    const labels = wins.map(w => w.t_label);
    const outData = wins.map(w => w.out_bytes / 1024);
    const inData = wins.map(w => w.in_bytes / 1024);
    SA._edgeTimeChart = new Chart(ctx.getContext('2d'), {
        type: 'bar',
        data: {
            labels,
            datasets: [
                {label: '流出 KB', data: outData, backgroundColor: '#e63946', barPercentage: 0.8},
                {label: '流入 KB', data: inData, backgroundColor: '#4361ee', barPercentage: 0.8},
            ]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: { legend: { labels: { font: {size: 10}, boxWidth: 10 } } },
            scales: {
                x: { ticks: { font: {size: 8}, maxTicksLimit: 15, maxRotation: 45 } },
                y: { beginAtZero: true, ticks: { font: {size: 9} } }
            }
        }
    });
}

function escapeHtml(str) {
    if (str == null) return '';
    return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

</script>

<!-- Traffic Flow Panel (outside script block) -->
<div class="sa-traffic-panel" id="saTrafficPanel">
    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
        <b style="font-size:0.9rem;">🌊 动态流通 (北京时间)</b>
        <div style="display:flex;gap:0.5rem;align-items:center;">
            <span style="font-size:0.75rem;">窗口:</span>
            <select id="saTrafficWindow" onchange="reloadTrafficData()" style="background:#2a2d3a;color:#ccc;border:1px solid #444;border-radius:3px;padding:0.15rem 0.3rem;font-size:0.75rem;">
                <option value="10">10s</option><option value="30">30s</option><option value="60" selected>1min</option><option value="300">5min</option><option value="600">10min</option>
            </select>
            <button onclick="toggleTrafficPanel()" style="background:transparent;color:#888;border:none;font-size:1.2rem;cursor:pointer;">✕</button>
        </div>
    </div>
    <div class="sa-traffic-controls">
        <button onclick="trafficStepBack()" title="后退">⏮</button>
        <button id="saTrafficPlayBtn" onclick="trafficTogglePlay()">▶ 播放</button>
        <button onclick="trafficStepForward()" title="前进">⏭</button>
        <span style="font-size:0.75rem;">速度:</span>
        <button data-speed="0.5" onclick="trafficSetSpeed(0.5,this)">0.5x</button>
        <button data-speed="1" onclick="trafficSetSpeed(1,this)" class="active">1x</button>
        <button data-speed="2" onclick="trafficSetSpeed(2,this)">2x</button>
        <button data-speed="4" onclick="trafficSetSpeed(4,this)">4x</button>
        <button data-speed="8" onclick="trafficSetSpeed(8,this)">8x</button>
        <div class="sa-traffic-slider">
            <input type="range" id="saTrafficSlider" min="0" max="0" value="0" oninput="trafficSeek(this.value)">
        </div>
        <span class="sa-traffic-time" id="saTrafficTime">--:--:--</span>
    </div>
    <div class="sa-traffic-info" id="saTrafficInfo"></div>
</div>
{% endblock %}
