{% 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; }

    /* ── 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: 1rem 0;
    }
    .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; }
    .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;
    }

    /* ── Advanced filter builder ───────────────── */
    .sa-filter-builder { margin: 1rem 0; }
    .sa-filter-row {
        display: flex; gap: 0.4rem; align-items: center; margin: 0.35rem 0;
        padding: 0.35rem; background: #fafafa; border-radius: 4px; 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 .logic-tag {
        display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px;
        font-size: 0.75rem; font-weight: 700; background: #d4e6ff; color: #4361ee;
    }
    .sa-filter-row .logic-tag.or { background: #fff3cd; color: #856404; }
    .sa-filter-row .logic-tag.not { background: #f8d7da; color: #721c24; }
    .sa-filter-row .remove-btn { color: #dc3545; cursor: pointer; font-weight: 700; font-size: 1.1rem; }
    .sa-filter-group { margin: 0.4rem 0 0.4rem 1.2rem; padding: 0.5rem 0.5rem 0.5rem 1rem; border-left: 3px dashed #90a4ae; border-radius: 4px; background: #f8faff; }
    .sa-filter-group .sa-filter-group { background: #f5f7fa; }
    .sa-group-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.8rem; color: #546e7a; font-weight: 600; }
    .sa-group-header .paren { font-size: 1rem; font-weight: 700; color: #4361ee; }
    .sa-group-footer { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.3rem; font-size: 0.8rem; color: #546e7a; }
    .sa-group-footer .paren { font-size: 1rem; font-weight: 700; color: #4361ee; }
    .sa-group-inner { margin-left: 0.5rem; }
    .sa-group-actions { display: flex; gap: 0.3rem; margin: 0.3rem 0; }
    .sa-group-actions button { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
    .sa-filter-actions { display: flex; gap: 0.5rem; margin: 0.5rem 0; flex-wrap: wrap; }
    .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;
    }

    /* ── 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; }

    /* ── 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" id="saFileList"></div>

        <!-- Preset filter builder (appears after quick scan) -->
        <div id="saPresetFilterArea" style="display:none;margin-top:1rem;">
            <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-filter-builder">
                        <div id="saPresetConditions"></div>
                        <div class="sa-filter-actions">
                            <button class="btn" style="font-size:0.78rem;padding:0.3rem 0.6rem;" onclick="addFilterCondition(document.getElementById('saPresetConditions'))">+ 条件</button>
                            <button class="btn" style="font-size:0.78rem;padding:0.3rem 0.6rem;" onclick="addFilterGroup(document.getElementById('saPresetConditions'))">( ) 组</button>
                            <select id="saPresetGlobalLogic" style="padding:0.25rem;border:1px solid #ccc;border-radius:4px;font-size:0.8rem;" onchange="updatePresetLogicTags()">
                                <option value="AND">根逻辑：AND</option>
                                <option value="OR">根逻辑：OR</option>
                            </select>
                        </div>
                    </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.75rem;">🔍 步骤二：高级筛选</h3>

        <div class="card" style="background:#f8faff;">
            <div style="margin-bottom:0.5rem;font-size:0.85rem;">
                <strong>当前数据：</strong>
                <span id="saFilterTotalBefore" style="font-weight:700;">0</span> 条
                <span style="margin-left:1rem;"><strong>筛选后：</strong></span>
                <span id="saFilteredCount" style="font-size:1.2rem;font-weight:700;color:#4361ee;">0</span> 条
            </div>
        </div>

        <!-- Advanced filter builder -->
        <div class="sa-filter-builder" id="saFilterBuilder">
            <div style="font-weight:600;font-size:0.9rem;margin-bottom:0.5rem;">条件构建器</div>
            <div id="saFilterConditions"></div>
            <div class="sa-filter-actions">
                <button class="btn" style="font-size:0.78rem;padding:0.3rem 0.6rem;" onclick="addFilterCondition()">+ 条件</button>
                <button class="btn" style="font-size:0.78rem;padding:0.3rem 0.6rem;" onclick="addFilterGroup()">( ) 组</button>
                <select id="saFilterGlobalLogic" style="padding:0.25rem;border:1px solid #ccc;border-radius:4px;font-size:0.8rem;">
                    <option value="AND">根逻辑：AND</option>
                    <option value="OR">根逻辑：OR</option>
                </select>
                <button class="btn btn-primary" style="font-size:0.8rem;" 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="3" max="50" value="5"
                           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;color:#999;" id="saNNoise">0</div></div>
                    <div><strong>边数</strong><div style="font-size:1.5rem;font-weight:700;" id="saNValid">0</div></div>
                </div>
            </div>

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

            <button class="btn btn-primary" style="margin-top:0.75rem;" onclick="goStep(4)">
                下一步：地图可视化 → &nbsp;▶
            </button>
            <button class="btn" style="margin-top:0.75rem;margin-left:0.5rem;" onclick="goStep(2)">← 返回</button>
        </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="10"
                   oninput="document.getElementById('saEdgeFreqVal').textContent=this.value">
            <span id="saEdgeFreqVal" style="font-size:0.8rem;font-weight:700;">10</span>
            <button class="btn" style="font-size:0.75rem;padding:0.2rem 0.5rem;" onclick="loadEdges()">加载</button>
            <span id="saEdgeCount" style="color:#999;font-size:0.78rem;"></span>
        </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 style="margin-top:1rem;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" style="margin-left:0.5rem;" onclick="resetAll()">🔄 重新开始</button>
        </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 (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>
/* ── State ────────────────────────────────────────────── */
const SA = {
    sessionId: null,
    colNames: [],
    detectedCols: {},
    columnsStd: [],  // standardized column names
    clusterLabels: [],
    clusterMap: {},
    dataPoints: {},
    clusterMode: 'geo',
    map: null,
    pointLayer: null,
    centerLayer: null,
    edgeLayer: null,
    edgesData: null,
    noisePoints: [],
    barChart: null,
    pieChart: null,
    hiddenClusters: new Set(),
    showNoise: true,
    filterConditions: [],  // [{col, op, val, logic}, ...]
    _loading: false,       // anti-duplicate
};

/* ── Navigation ──────────────────────────────────────── */
function goStep(n) {
    if (n === 2 && SA.sessionId && !SA.colNames.length) {
        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();
        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) {}
}

/* ── Filter builder ────────────────────────────────────── */
function initFilterBuilder() {
    const container = document.getElementById('saFilterConditions');
    if (!container || container.children.length > 0) return;
    // Add first condition row (addFilterCondition now uses sorted columns inline)
    addFilterCondition();
}

function onFilterOpChange(opSel) {
    const row = opSel.closest('.sa-filter-row');
    const valSelect = row.querySelector('.sa-filter-val');
    const colSel = row.querySelector('.sa-filter-col-select');
    if (!valSelect || !colSel) return;
    const op = opSel.value;
    if (op === 'is_empty' || op === 'not_empty') {
        valSelect.disabled = true;
        valSelect.innerHTML = '<option value="">— 无值 —</option>';
    } else {
        valSelect.disabled = false;
        if (colSel.value) {
            onFilterColChange(colSel);
        } else {
            valSelect.innerHTML = '<option value="">— 选择值 —</option>';
        }
    }
}

function onFilterColChange(colSel) {
    // Populate value select with top distinct values from uploaded data
    const row = colSel.closest('.sa-filter-row');
    const valSelect = row.querySelector('.sa-filter-val');
    if (!colSel || !valSelect || !colSel.value) return;
    valSelect.disabled = false;
    valSelect.innerHTML = '<option value="">加载中...</option>';
    if (!SA.sessionId) { valSelect.innerHTML = '<option value="">— 选择值 —</option>'; return; }
    fetch('/simple/column-values/?session_id=' + SA.sessionId + '&col=' + encodeURIComponent(colSel.value), {
        headers: {'X-CSRFToken': getCSRF()},
    })
    .then(r => r.json())
    .then(data => {
        valSelect.innerHTML = '<option value="">— 选择值 —</option>';
        if (data.error || !data.values) return;
        data.values.forEach(v => {
            const opt = document.createElement('option');
            opt.value = v.value;
            const display = v.value || '(空)';
            opt.textContent = display + ' (' + v.count + ')';
            valSelect.appendChild(opt);
        });
    })
    .catch(() => { valSelect.innerHTML = '<option value="">— 选择值 —</option>'; });
}

function addFilterCondition(containerEl) {
    const container = containerEl || document.getElementById('saFilterConditions');
    const idx = Date.now() + '_' + Math.random().toString(36).slice(2, 6);
    const div = document.createElement('div');
    div.className = 'sa-filter-row';
    div.dataset.fidx = idx;
    const allCols = (SA.columnsStd.length ? SA.columnsStd : SA.colNames).slice().sort((a, b) => a.localeCompare(b));
    div.innerHTML = `
        <span class="logic-tag">WHERE</span>
        <select class="sa-filter-col-select" style="min-width:100px;" onchange="onFilterColChange(this)">
            <option value="">— 选择列 —</option>
            ${allCols.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('')}
        </select>
        <select class="sa-filter-op" style="width:90px;" onchange="onFilterOpChange(this)">
            <option value="eq">等于</option>
            <option value="neq">不等于</option>
            <option value="contains">包含</option>
            <option value="not_contains">不包含</option>
            <option value="gt">大于</option>
            <option value="lt">小于</option>
            <option value="is_empty">为空</option>
            <option value="not_empty">非空</option>
        </select>
        <select class="sa-filter-val" style="width:130px;"><option value="">— 选择值 —</option></select>
        <select class="sa-filter-logic" style="width:60px;font-size:0.75rem;">
            <option value="">AND</option>
            <option value="NOT">NOT</option>
        </select>
        <span class="group-btn" title="包裹到组" style="cursor:pointer;color:#4361ee;font-weight:700;font-size:1rem;" onclick="wrapRowInGroup(this.parentElement)">()</span>
        <span class="remove-btn" onclick="this.parentElement.remove()">✕</span>
    `;
    container.appendChild(div);
    updateFilterLogicTags();
}

function updateFilterLogicTags() {
    // Label top-level rows within #saFilterConditions
    const container = document.getElementById('saFilterConditions');
    const topItems = container.querySelectorAll(':scope > .sa-filter-row, :scope > .sa-filter-group');
    const globalLogic = document.getElementById('saFilterGlobalLogic').value;
    topItems.forEach((el, i) => {
        if (el.classList.contains('sa-filter-row')) {
            const tag = el.querySelector('.logic-tag');
            if (tag) {
                if (i === 0) { tag.textContent = 'WHERE'; tag.className = 'logic-tag'; }
                else { tag.textContent = globalLogic; tag.className = 'logic-tag ' + (globalLogic === 'OR' ? 'or' : ''); }
            }
        } else if (el.classList.contains('sa-filter-group')) {
            // Relabel logic-tags inside groups relative to their siblings
            const innerRows = el.querySelectorAll('.sa-group-inner > .sa-filter-row, .sa-group-inner > .sa-filter-group');
            const groupLogic = el.querySelector('.sa-group-logic')?.value || 'AND';
            innerRows.forEach((innerEl, j) => {
                if (innerEl.classList.contains('sa-filter-row')) {
                    const tag = innerEl.querySelector('.logic-tag');
                    if (tag) {
                        if (j === 0) { tag.textContent = 'WHERE'; tag.className = 'logic-tag'; }
                        else { tag.textContent = groupLogic; tag.className = 'logic-tag ' + (groupLogic === 'OR' ? 'or' : ''); }
                    }
                }
            });
        }
    });
}
document.getElementById('saFilterGlobalLogic')?.addEventListener('change', updateFilterLogicTags);

function wrapRowInGroup(rowEl) {
    if (!rowEl || !rowEl.parentElement) return;
    const parent = rowEl.parentElement;
    const groupEl = document.createElement('div');
    groupEl.className = 'sa-filter-group';
    groupEl.innerHTML = `
        <div class="sa-group-header"><span class="paren">(</span><select class="sa-group-logic" style="padding:0.2rem;border:1px solid #ccc;border-radius:3px;font-size:0.78rem;"><option value="AND">AND</option><option value="OR">OR</option></select></div>
        <div class="sa-group-inner"></div>
        <div class="sa-group-actions">
            <button class="btn" onclick="addFilterCondition(this.closest('.sa-filter-group').querySelector('.sa-group-inner'))">+ 条件</button>
            <button class="btn" onclick="addFilterGroup(this.closest('.sa-filter-group').querySelector('.sa-group-inner'))">+ 子组</button>
        </div>
        <div class="sa-group-footer"><span class="paren">)</span><span style="color:#999;font-size:0.75rem;">组</span><span class="remove-btn" style="font-size:0.9rem;" onclick="this.closest('.sa-filter-group').remove()">✕ 删除组</span></div>
    `;
    parent.insertBefore(groupEl, rowEl);
    rowEl.remove();
    groupEl.querySelector('.sa-group-inner').appendChild(rowEl);
    updateFilterLogicTags();
}

function addFilterGroup(containerEl) {
    const container = containerEl || document.getElementById('saFilterConditions');
    const groupEl = document.createElement('div');
    groupEl.className = 'sa-filter-group';
    groupEl.innerHTML = `
        <div class="sa-group-header"><span class="paren">(</span><select class="sa-group-logic" style="padding:0.2rem;border:1px solid #ccc;border-radius:3px;font-size:0.78rem;"><option value="AND">AND</option><option value="OR">OR</option></select></div>
        <div class="sa-group-inner"></div>
        <div class="sa-group-actions">
            <button class="btn" onclick="addFilterCondition(this.closest('.sa-filter-group').querySelector('.sa-group-inner'))">+ 条件</button>
            <button class="btn" onclick="addFilterGroup(this.closest('.sa-filter-group').querySelector('.sa-group-inner'))">+ 子组</button>
        </div>
        <div class="sa-group-footer"><span class="paren">)</span><span style="color:#999;font-size:0.75rem;">组</span><span class="remove-btn" style="font-size:0.9rem;" onclick="this.closest('.sa-filter-group').remove()">✕ 删除组</span></div>
    `;
    // Start with one condition inside
    container.appendChild(groupEl);
    addFilterCondition(groupEl.querySelector('.sa-group-inner'));
    updateFilterLogicTags();
}

function serializeFilterTree(containerEl) {
    const items = [];
    containerEl.querySelectorAll(':scope > .sa-filter-row, :scope > .sa-filter-group').forEach(el => {
        if (el.classList.contains('sa-filter-row')) {
            const col = el.querySelector('.sa-filter-col-select')?.value;
            if (!col) return;
            items.push({
                type: 'condition', col: col,
                op: el.querySelector('.sa-filter-op')?.value || 'eq',
                val: el.querySelector('.sa-filter-val')?.value || '',
                logic: el.querySelector('.sa-filter-logic')?.value || '',
            });
        } else if (el.classList.contains('sa-filter-group')) {
            const children = serializeFilterTree(el.querySelector('.sa-group-inner'));
            if (children.length) {
                items.push({
                    type: 'group',
                    logic: el.querySelector('.sa-group-logic')?.value || 'AND',
                    items: children,
                });
            }
        }
    });
    return items;
}

function applyAdvancedFilter() {
    if (SA._loading || !SA.sessionId) return;
    const rootItems = serializeFilterTree(document.getElementById('saFilterConditions'));
    if (!rootItems.length) { showError('请至少添加一个筛选条件'); return; }
    const payload = {
        session_id: SA.sessionId,
        root: {type: 'group', logic: document.getElementById('saFilterGlobalLogic').value, items: rootItems},
    };

    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);
        SA.filterConditions = conditions;
        updateDebug({status: '已筛选', filtered: data.filtered_rows, total: data.total_before_filter});
        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.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); });
}

function showPresetFilterBuilder(columns) {
    const area = document.getElementById('saPresetFilterArea');
    const container = document.getElementById('saPresetConditions');
    if (!area || !container) return;
    area.style.display = 'block';
    container.innerHTML = '';

    // Restore saved preset filter tree from localStorage
    const saved = loadPresetTree();
    if (saved && saved.items && saved.items.length) {
        restorePresetTree(container, saved, columns);
    } else {
        addFilterCondition(container);
    }
}

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

function savePresetTree() {
    const container = document.getElementById('saPresetConditions');
    if (!container) return;
    const items = serializeFilterTree(container);
    const tree = {type: 'group', logic: document.getElementById('saPresetGlobalLogic')?.value || 'AND', items: items};
    if (items.length) {
        localStorage.setItem('sa_preset_tree', JSON.stringify(tree));
    } else {
        localStorage.removeItem('sa_preset_tree');
    }
}

function clearPresetMemory() {
    localStorage.removeItem('sa_preset_tree');
    const container = document.getElementById('saPresetConditions');
    if (container) {
        container.innerHTML = '';
        addFilterCondition(container);
    }
    showSuccess('已清除预设筛选记忆');
}

function restorePresetTree(container, tree, columns) {
    const sortedCols = columns.slice().sort((a, b) => a.localeCompare(b));
    if (tree.logic && document.getElementById('saPresetGlobalLogic')) {
        document.getElementById('saPresetGlobalLogic').value = tree.logic;
    }
    tree.items.forEach(item => {
        if (item.type === 'group') {
            appendPresetGroup(container, item, sortedCols);
        } else {
            appendPresetCondition(container, item, sortedCols);
        }
    });
    updatePresetLogicTags();
}

function appendPresetCondition(container, item, sortedCols) {
    const idx = Date.now() + '_' + Math.random().toString(36).slice(2, 6);
    const div = document.createElement('div');
    div.className = 'sa-filter-row';
    div.dataset.fidx = idx;
    div.innerHTML = `
        <span class="logic-tag">WHERE</span>
        <select class="sa-filter-col-select" style="min-width:100px;" onchange="onFilterColChange(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:90px;" onchange="onFilterOpChange(this)">
            <option value="eq" ${item.op === 'eq' ? 'selected' : ''}>等于</option>
            <option value="neq" ${item.op === 'neq' ? 'selected' : ''}>不等于</option>
            <option value="contains" ${item.op === 'contains' ? 'selected' : ''}>包含</option>
            <option value="not_contains" ${item.op === 'not_contains' ? 'selected' : ''}>不包含</option>
            <option value="gt" ${item.op === 'gt' ? 'selected' : ''}>大于</option>
            <option value="lt" ${item.op === 'lt' ? 'selected' : ''}>小于</option>
            <option value="is_empty" ${item.op === 'is_empty' ? 'selected' : ''}>为空</option>
            <option value="not_empty" ${item.op === 'not_empty' ? 'selected' : ''}>非空</option>
        </select>
        <select class="sa-filter-val" style="width:130px;"><option value="${escapeHtml(item.val || '')}" selected>${escapeHtml(item.val || '—')}</option></select>
        <select class="sa-filter-logic" style="width:60px;font-size:0.75rem;">
            <option value="" ${!item.logic ? 'selected' : ''}>AND</option>
            <option value="NOT" ${item.logic === 'NOT' ? 'selected' : ''}>NOT</option>
        </select>
        <span class="group-btn" title="包裹到组" style="cursor:pointer;color:#4361ee;font-weight:700;font-size:1rem;" onclick="wrapRowInGroup(this.parentElement)">()</span>
        <span class="remove-btn" onclick="this.parentElement.remove();savePresetTree()">✕</span>
    `;
    container.appendChild(div);
}

function appendPresetGroup(container, group, sortedCols) {
    const groupEl = document.createElement('div');
    groupEl.className = 'sa-filter-group';
    groupEl.innerHTML = `
        <div class="sa-group-header"><span class="paren">(</span><select class="sa-group-logic" style="padding:0.2rem;border:1px solid #ccc;border-radius:3px;font-size:0.78rem;"><option value="AND" ${group.logic === 'AND' ? 'selected' : ''}>AND</option><option value="OR" ${group.logic === 'OR' ? 'selected' : ''}>OR</option></select></div>
        <div class="sa-group-inner"></div>
        <div class="sa-group-actions">
            <button class="btn" onclick="addFilterCondition(this.closest('.sa-filter-group').querySelector('.sa-group-inner'))">+ 条件</button>
            <button class="btn" onclick="addFilterGroup(this.closest('.sa-filter-group').querySelector('.sa-group-inner'))">+ 子组</button>
        </div>
        <div class="sa-group-footer"><span class="paren">)</span><span style="color:#999;font-size:0.75rem;">组</span><span class="remove-btn" style="font-size:0.9rem;" onclick="this.closest('.sa-filter-group').remove();savePresetTree()">✕ 删除组</span></div>
    `;
    const inner = groupEl.querySelector('.sa-group-inner');
    (group.items || []).forEach(item => {
        if (item.type === 'group') {
            appendPresetGroup(inner, item, sortedCols);
        } else {
            appendPresetCondition(inner, item, sortedCols);
        }
    });
    container.appendChild(groupEl);
}

function updatePresetLogicTags() {
    const container = document.getElementById('saPresetConditions');
    if (!container) return;
    const topItems = container.querySelectorAll(':scope > .sa-filter-row, :scope > .sa-filter-group');
    const globalLogic = document.getElementById('saPresetGlobalLogic')?.value || 'AND';
    topItems.forEach((el, i) => {
        if (el.classList.contains('sa-filter-row')) {
            const tag = el.querySelector('.logic-tag');
            if (tag) {
                if (i === 0) { tag.textContent = 'WHERE'; tag.className = 'logic-tag'; }
                else { tag.textContent = globalLogic; tag.className = 'logic-tag ' + (globalLogic === 'OR' ? 'or' : ''); }
            }
        }
    });
    savePresetTree();
}

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

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

    // Serialize and save preset filter tree
    savePresetTree();
    const presetItems = serializeFilterTree(document.getElementById('saPresetConditions'));
    if (presetItems.length) {
        const tree = {
            type: 'group',
            logic: document.getElementById('saPresetGlobalLogic')?.value || 'AND',
            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 = '';
            }
        }

        document.getElementById('saUploadResult').style.display = 'block';
        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 = {};  // will be rebuilt from nodes

        setText('saNClusters', data.n_clusters);
        setText('saNNoise', data.n_noise);
        setText('saNValid', data.n_edges || 0);
        const crEl = document.getElementById('saClusterResult');
        if (crEl) crEl.style.display = 'block';

        renderClusterCards(data.clusters);

        const noiseRatio = data.n_nodes > 0 ? ((data.n_noise / data.n_nodes) * 100).toFixed(1) + '%' : '—';
        updateDebug({
            status: data.all_noise ? '全噪声' : '聚类完成',
            mode: 'IP网络社区发现',
            clusters: String(data.n_clusters),
            noise_ratio: noiseRatio,
            time: data.elapsed_seconds || '—',
            note: data.note || '',
        });

        if (data.all_noise) {
            showError('⚠ ' + (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(Object.keys(clusters).length);

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

/* ── Step 4: Map & Visualization ──────────────────────── */

// Tile providers in priority order
const TILE_PROVIDERS = [
    {
        name: 'ArcGIS',
        url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',
        attr: 'Tiles &copy; Esri &mdash; Source: Esri, DeLorme, NAVTEQ'
    },
    {
        name: 'ArcGIS Gray',
        url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
        attr: 'Tiles &copy; Esri &mdash; Esri, DeLorme, NAVTEQ'
    },
    {
        name: 'OSM',
        url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
        attr: '&copy; OpenStreetMap contributors'
    },
];

// Offline fallback tile (plain light gray canvas data URI)
const OFFLINE_TILE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPj/HwADBwIAMCbHYQAAAABJRU5ErkJggg==';

// ── Offline tile cache using Cache API ──────────────────
const TILE_CACHE_NAME = 'tianxuan-tiles-v1';

async function cacheTile(url) {
    if (!('caches' in window)) return;
    try {
        const cache = await caches.open(TILE_CACHE_NAME);
        const cached = await cache.match(url);
        if (!cached) {
            const resp = await fetch(url, {mode: 'no-cors'});
            if (resp.ok || resp.type === 'opaque') {
                await cache.put(url, resp.clone());
            }
        }
    } catch(e) { /* cache quietly */ }
}

async function getCachedTile(url) {
    if (!('caches' in window)) return null;
    try {
        const cache = await caches.open(TILE_CACHE_NAME);
        const cached = await cache.match(url);
        if (cached) {
            const blob = await cached.blob();
            if (blob.size > 100) {
                return URL.createObjectURL(blob);
            }
        }
    } catch(e) { /* ignore */ }
    return null;
}

// Custom tile layer with offline caching
const CachedTileLayer = L.TileLayer.extend({
    createTile: function(coords, done) {
        const tile = L.DomUtil.create('img', 'leaflet-tile');
        const url = this.getTileUrl(coords);

        // Try cache first, then network
        getCachedTile(url).then(cachedUrl => {
            if (cachedUrl) {
                tile.src = cachedUrl;
                tile._cached = true;
                if (done) done(null, tile);
            }
        });

        // Also try loading from network (triggers cache in background)
        const img = new Image();
        img.onload = () => {
            if (!tile._cached) {
                tile.src = url;
                if (done) done(null, tile);
            }
            // Cache for offline use
            cacheTile(url);
        };
        img.onerror = () => {
            if (!tile._cached) {
                tile.src = OFFLINE_TILE;
                if (done) done(null, tile);
            }
        };
        img.src = url;

        return tile;
    }
});

function initMap() {
    const container = document.getElementById('saMap');
    if (!container) return;
    if (SA.map) {
        container.style.height = '550px';
        setTimeout(() => {
            SA.map.invalidateSize();
            renderMapData();
        }, 200);
        return;
    }
    setTimeout(() => {
        container.style.height = '550px';
        SA.map = L.map('saMap', {
            center: [0, 0],
            zoom: 2,
            worldCopyJump: true,
            preferCanvas: true,
        });
        tryTileProvider(0);
        setTimeout(() => SA.map.invalidateSize(), 300);
        renderMapData();
    }, 250);
}

function tryTileProvider(idx) {
    if (idx >= TILE_PROVIDERS.length || !SA.map) return;
    const provider = TILE_PROVIDERS[idx];

    // Use cached tile layer for first provider (ArcGIS)
    let tileLayer;
    if (idx === 0) {
        tileLayer = new CachedTileLayer(provider.url, {
            attribution: provider.attr,
            maxZoom: 18,
            errorTileUrl: OFFLINE_TILE,
        });
    } else {
        tileLayer = L.tileLayer(provider.url, {
            attribution: provider.attr,
            maxZoom: 18,
            errorTileUrl: OFFLINE_TILE,
        });
    }
    tileLayer.addTo(SA.map);

    let tileError = false;
    tileLayer.on('tileerror', () => {
        if (!tileError) {
            tileError = true;
            console.warn('Tile provider failed:', provider.name);
            SA.map.removeLayer(tileLayer);
            if (idx + 1 >= TILE_PROVIDERS.length) {
                showWarning('离线模式：地图瓦片不可用，数据点仍可显示');
                L.tileLayer(OFFLINE_TILE, {maxZoom: 18}).addTo(SA.map);
            } else {
                tryTileProvider(idx + 1);
            }
        }
    });
    SA._tileLayer = tileLayer;
}

function renderMapData() {
    if (!SA.map) return;

    // Remove old layers
    if (SA.pointLayer) SA.map.removeLayer(SA.pointLayer);
    if (SA.centerLayer) SA.map.removeLayer(SA.centerLayer);

    const nodes = SA.nodes || [];
    const clusters = SA.clusterMap || {};
    const colors = generateColors(20);

    const allPoints = [];
    const centerMarkers = [];

    // Build color map for clusters
    const clusterColors = {};
    const sortedClusters = Object.values(clusters)
        .filter(c => !c.is_noise)
        .sort((a, b) => b.size - a.size);
    sortedClusters.forEach((c, i) => {
        clusterColors[c.label] = colors[i % colors.length];
    });

    // Group nodes by cluster
    const nodesByCluster = {};
    nodes.forEach(n => {
        const cid = n.cluster_id;
        if (!nodesByCluster[cid]) nodesByCluster[cid] = [];
        nodesByCluster[cid].push(n);
    });

    // Render each node as a circle marker
    Object.entries(nodesByCluster).forEach(([cid, clusterNodes]) => {
        const cidInt = parseInt(cid);
        const color = cidInt === -1 ? '#aaa' : (clusterColors[cidInt] || '#4361ee');
        const hidden = SA.hiddenClusters.has(cidInt);

        clusterNodes.forEach(n => {
            if (n.lat == null || n.lon == null) return;
            if (hidden) return;
            if (cidInt === -1 && !SA.showNoise) return;

            const radius = cidInt === -1 ? 3 : 5;
            const opacity = cidInt === -1 ? 0.4 : 0.7;
            const circle = L.circleMarker([n.lat, n.lon], {
                radius: radius,
                fillColor: color,
                color: '#fff',
                weight: 0.5,
                opacity: 0.8,
                fillOpacity: opacity,
            });
            // Rich tooltip
            const serverLabel = n.is_server ? ' 🖥' : '';
            circle.bindTooltip(
                `<div class="sa-point-tooltip">
                  <b>${escapeHtml(n.ip)}</b>${serverLabel}<br>
                  ${n.city ? `<span class="tt-row"><span class="tt-key">城市:</span>${escapeHtml(n.city)}</span><br>` : ''}
                  ${n.isp ? `<span class="tt-row"><span class="tt-key">ISP:</span>${escapeHtml(n.isp)}</span><br>` : ''}
                  <span class="tt-row"><span class="tt-key">社区:</span>${cidInt === -1 ? '噪声' : cidInt}</span><br>
                  <span style="color:#4361ee;">点击查看详情</span>
                 </div>`
            );
            circle.on('click', () => loadNodeDetail(n.ip));
            allPoints.push(circle);
        });

        // Cluster center marker
        if (cidInt !== -1) {
            const cinfo = clusters[cidInt] || clusters[String(cidInt)] || {};
            if (cinfo.center_lat != null && cinfo.center_lon != null) {
                const icon = L.divIcon({
                    html: `<div style="background:${color};width:22px;height:22px;border-radius:50%;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;color:#fff;font-size:10px;font-weight:700;">${cidInt}</div>`,
                    className: '',
                    iconSize: [22, 22],
                    iconAnchor: [11, 11],
                });
                const marker = L.marker([cinfo.center_lat, cinfo.center_lon], {icon});
                marker.bindTooltip(
                    `<div class="sa-point-tooltip">
                      <b>社区 ${cidInt} 中心</b><br>
                      ${cinfo.size} 个IP · ${cinfo.subnet || ''}<br>
                      点击查看详情
                     </div>`,
                    {direction: 'top'}
                );
                marker.on('click', () => loadClusterDetail(cidInt));
                centerMarkers.push(marker);
            }
        }
    });

    SA.pointLayer = L.layerGroup(allPoints).addTo(SA.map);
    SA.centerLayer = L.layerGroup(centerMarkers).addTo(SA.map);

    // Fit bounds
    const boundsPoints = [];
    allPoints.forEach(p => { const ll = p.getLatLng(); if (ll) boundsPoints.push(ll); });
    centerMarkers.forEach(m => boundsPoints.push(m.getLatLng()));
    if (boundsPoints.length) {
        try {
            const bounds = L.latLngBounds(boundsPoints);
            SA.map.fitBounds(bounds, {padding: [50, 50], maxZoom: 12});
        } catch(e) {
            SA.map.setView([0, 0], 2);
        }
    }

    renderLegend(colors);
    renderCharts();
    renderEdgesOnMap();
}

function renderLegend(colors) {
    const el = document.getElementById('saLegendItems');
    const clusters = SA.clusterMap || {};
    const sorted = Object.values(clusters)
        .filter(c => !c.is_noise)
        .sort((a, b) => b.size - a.size);

    const html = [];
    // Noise toggle
    const noiseCount = (SA.nodes || []).filter(n => n.cluster_id === -1).length;
    html.push(`<div class="sa-legend-item${!SA.showNoise ? ' hidden' : ''}"
                 onclick="toggleNoise()">
                <span class="color-dot" style="background:#aaa"></span>
                <span>噪声节点 (${noiseCount})</span>
            </div>`);

    sorted.forEach((c, idx) => {
        const color = colors[idx % colors.length];
        const hidden = SA.hiddenClusters.has(c.label);
        html.push(`<div class="sa-legend-item${hidden ? ' hidden' : ''}"
                     onclick="toggleCluster(${c.label})">
                    <span class="color-dot" style="background:${color}"></span>
                    <span>社区 ${c.label}</span>
                    <span style="color:#999;font-size:0.8rem;">${c.size} IP</span>
                </div>`);
    });
    el.innerHTML = html.join('');
}

function toggleNoise() {
    SA.showNoise = !SA.showNoise;
    renderMapData();
}

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).filter(c => !c.is_noise).sort((a, b) => b.size - a.size);
    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');
    const n = data.node || {};
    const edges = data.related_edges || [];

    let edgeHtml = '';
    if (edges.length) {
        edgeHtml = `<div class="section-title">关联边 (${data.n_edges} 条)</div>
                    <div style="max-height:200px;overflow-y:auto;font-size:0.78rem;">`;
        edges.slice(0, 20).forEach(e => {
            const peer = e.source === n.ip ? e.target : e.source;
            const dir = e.source === n.ip ? '→' : '←';
            edgeHtml += `<div style="margin:0.2rem 0;padding:0.2rem 0.4rem;background:#f8faff;border-radius:3px;">
                ${dir} <span class="sa-tag">${escapeHtml(peer)}</span>
                通信 ${e.comm_count} 次
                ${e.total_bytes ? ' · ' + (e.total_bytes/1024).toFixed(1) + ' KB' : ''}
            </div>`;
        });
        if (edges.length > 20) edgeHtml += `<div style="color:#999;">... 还有 ${edges.length - 20} 条</div>`;
        edgeHtml += '</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)}</div><div class="lbl">IP 地址</div></div>
            <div class="stat-card"><div class="num">${n.cluster_id === -1 ? '噪声' : '社区 ' + n.cluster_id}</div><div class="lbl">所属社区</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">ISP</div></div>` : ''}
        </div>
        <div class="stat-row">
            <div class="stat-card"><div class="num">${n.is_server ? '是' : '否'}</div><div class="lbl">服务器</div></div>
            <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">${data.total_flow_bytes ? (data.total_flow_bytes/1024).toFixed(1) + ' KB' : '—'}</div><div class="lbl">总流量</div></div>
        </div>
        ${edgeHtml}
        <div style="font-size:0.78rem;color:#999;margin-top:0.5rem;">
            坐标: ${n.lat != null ? n.lat + ', ' + n.lon : '未知'}
        </div>
    `;

    setText('saDetailTitle', `节点: ${n.ip}`);
    panel.classList.add('open');
}

/* ── Edge rendering on map ────────────────────────────── */
let _edgeLayer = null;

function renderEdgesOnMap() {
    if (!SA.map) return;
    if (_edgeLayer) SA.map.removeLayer(_edgeLayer);
    if (!document.getElementById('saShowEdges')?.checked) return;
    if (!SA.edgesData || !SA.edgesData.length) return;

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

    const maxCount = Math.max(...SA.edgesData.map(e => e.comm_count || 1), 1);
    const edgePolys = [];

    SA.edgesData.slice(0, 200).forEach(e => {
        const srcCoord = ipCoord[e.source];
        const dstCoord = ipCoord[e.target];
        if (!srcCoord || !dstCoord) return;

        const weight = Math.min((e.comm_count || 1) / maxCount * 3 + 0.5, 4);
        const poly = L.polyline([srcCoord, dstCoord], {
            color: '#ff6b6b',
            weight: weight,
            opacity: 0.35,
            dashArray: null,
        });
        const firstSeen = e.first_seen || '';
        const lastSeen = e.last_seen || '';
        const peakHours = e.peak_hours || '';
        poly.bindTooltip(
            `<div class="sa-point-tooltip">
              <b>${escapeHtml(e.source)} → ${escapeHtml(e.target)}</b><br>
              通信: ${e.comm_count} 次<br>
              ${e.total_bytes ? '流量: ' + (e.total_bytes/1024).toFixed(1) + ' KB<br>' : ''}
              ${firstSeen ? '首次: ' + firstSeen + '<br>' : ''}
              ${lastSeen ? '末次: ' + lastSeen + '<br>' : ''}
              ${peakHours ? '活跃时段: ' + peakHours + 'h' : ''}
             </div>`
        );
        poly.on('click', () => loadEdgeDetail(e));
        edgePolys.push(poly);
    });

    if (edgePolys.length) {
        _edgeLayer = L.layerGroup(edgePolys).addTo(SA.map);
    }
}

function loadEdgeDetail(e) {
    const panel = document.getElementById('saDetailPanel');
    const body = document.getElementById('saDetailBody');
    setText('saDetailTitle', `边: ${e.source} → ${e.target}`);

    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 ? (e.total_bytes/1024).toFixed(1) + ' KB' : '—'}</div><div class="lbl">总流量</div></div>
        </div>
        <div class="stat-row">
            <div class="stat-card"><div class="num" style="font-size:0.85rem;">${e.first_seen || '—'}</div><div class="lbl">首次通信 (UTC)</div></div>
            <div class="stat-card"><div class="num" style="font-size:0.85rem;">${e.last_seen || '—'}</div><div class="lbl">末次通信 (UTC)</div></div>
        </div>
        ${e.peak_hours ? `<div class="stat-row"><div class="stat-card"><div class="num">${e.peak_hours} 时</div><div class="lbl">活跃时段 (UTC)</div></div></div>` : ''}
        <div class="section-title">源 IP</div>
        <div><span class="sa-tag" style="cursor:pointer;" onclick="loadNodeDetail('${escapeHtml(e.source)}')">${escapeHtml(e.source)}</span></div>
        <div class="section-title">目的 IP</div>
        <div><span class="sa-tag" style="cursor:pointer;" onclick="loadNodeDetail('${escapeHtml(e.target)}')">${escapeHtml(e.target)}</span></div>
    `;
    panel.classList.add('open');
}

function loadEdges() {
    if (SA._loading || !SA.sessionId) return;
    // Edges are already loaded from cluster step; just toggle display
    if (SA.edgesData && SA.edgesData.length) {
        document.getElementById('saEdgeCount').textContent = `${SA.edgesData.length} 条边`;
        renderEdgesOnMap();
        return;
    }
    const minFreq = parseInt(document.getElementById('saEdgeFreq').value) || 1;
    setBusy(true, '加载边关系...');
    fetch('/simple/edges/', {
        method: 'POST',
        headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
        body: JSON.stringify({session_id: SA.sessionId, min_freq: minFreq}),
    })
    .then(r => r.json())
    .then(data => {
        setBusy(false);
        if (data.error) { showError(data.error); return; }
        SA.edgesData = data.edges || [];
        document.getElementById('saEdgeCount').textContent = `${SA.edgesData.length} 条边`;
        renderEdgesOnMap();
        showSuccess(`加载 ${SA.edgesData.length} 条通联边`);
    })
    .catch(err => { setBusy(false); showError('边加载失败: ' + err.message); });
}

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

/* ── Export ────────────────────────────────────────────── */
function exportClusterCSV() {
    // Export nodes CSV
    const nodes = SA.nodes || [];
    if (!nodes.length) { showError('无数据可导出'); return; }
    const headers = ['ip', 'cluster_id', 'lat', 'lon', 'city', 'country', 'isp', 'is_server'];
    const rows = [headers.join(',')];
    nodes.forEach(n => {
        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'].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'];
    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 || ''].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();
})();

/* ── Close detail ───────────────────────────────────────── */
function closeDetail() {
    document.getElementById('saDetailPanel').classList.remove('open');
}

/* ── Reset ─────────────────────────────────────────────── */
function resetAll() {
    SA.sessionId = null;
    SA.nodes = [];
    SA.edgesData = null;
    SA.clusterLabels = [];
    SA.clusterMap = {};
    SA.dataPoints = {};
    SA.hiddenClusters = new Set();
    SA.showNoise = true;
    _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; }
    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]);
}

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

</script>
{% endblock %}
