refactor: per-edge continuous lifecycle rendering

后端:
- traffic_timeline_view: 返回edges[{first_ts,last_ts}]替代windows
- datetime.strptime + .timestamp()保留微秒精度

前端:
- 完全移除窗口概念, edge._fs/_ls定义生命周期
- 时间桶索引(O(1)查找可见边)
- 生命周期<5s自动扩展±30s
- 5%软边缘淡入淡出
- 每帧仅检查当前时间桶±1的边(性能优化)
- 最大渲染500条(按count排序)

验证: 500条边, ~127K像素, 206活跃节点, 边持久可见

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TianXuan Developer
2026-07-25 16:07:18 +08:00
parent 490f37d8fc
commit c49475d207
2 changed files with 132 additions and 103 deletions
@@ -2568,13 +2568,15 @@ function generateColors(n) {
}
/* ── Traffic Flow Animation (crossfade window transitions) ── */
let _trafficData = null; // {windows:[{ts,edges,active_nodes}], nodes_coords, window_seconds, _minTs, _maxTs}
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';
@@ -2623,53 +2625,46 @@ function loadTrafficData() {
fetch('/simple/traffic-timeline/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify({session_id: SA.sessionId, window_seconds: 10, max_edges: 300}),
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; }
// Edge positions computed fresh each frame — no cache needed
let ws = d.window_seconds || 10;
let minTs = d.min_ts || (d.windows[0]?.ts || 0);
let maxTs = d.max_ts || (d.windows[d.n_windows-1]?.ts_end || minTs + ws);
let wins = d.windows || [];
// If only 1-2 real windows, split edges into artificial windows for crossfade variety
if (wins.length <= 2 && wins.length > 0) {
const allEdges = [];
const seen = new Set();
wins.forEach(w => (w.edges||[]).forEach(e => {
const k = e.source + '|' + e.target;
if (!seen.has(k)) { seen.add(k); allEdges.push({...e}); }
}));
allEdges.sort((a,b) => (b.count||0) - (a.count||0));
// More windows with fewer edges each = more visible change
const nWindows = Math.min(20, Math.ceil(allEdges.length / 20));
const groupSize = Math.max(15, Math.ceil(allEdges.length / nWindows));
const newWins = [];
let ti = minTs;
for (let g = 0; g < allEdges.length; g += groupSize) {
const groupEdges = allEdges.slice(g, g + groupSize);
const activeNodes = new Set();
groupEdges.forEach(e => { activeNodes.add(e.source); activeNodes.add(e.target); });
newWins.push({ts: ti, t_label: tsToBJ(ti).slice(11,19), edges: groupEdges, active_nodes: [...activeNodes]});
ti += ws;
}
wins = newWins;
d.windows = wins;
maxTs = ti;
}
if (maxTs - minTs < 10) maxTs = minTs + Math.max(60, ws * 6);
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; d._ws = ws;
_trafficCurrentTime = minTs;
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 =
(d.windows||[]).length + ' 窗口 · ' + Object.keys(d.nodes_coords||{}).length + ' 节点 · 跨度' + ((maxTs-minTs)/60).toFixed(0) + 'min';
_trafficEdges.length + ' 条边 · ' + Object.keys(d.nodes_coords||{}).length + ' 节点 · 连续生命周期';
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
trafficStart();
})
@@ -2678,28 +2673,7 @@ function loadTrafficData() {
function reloadTrafficData() { trafficStop(); loadTrafficData(); }
// ── getWindowMix: find window index + crossfade factor ──
// crossfade 0→1 over last 20% of window[i], edges from both windows blend
function getWindowMix() {
if (!_trafficData) return {i:0, crossfade:0};
const wins = _trafficData.windows || [];
if (!wins.length) return {i:0, crossfade:0};
const ws = _trafficData._ws;
const minTs = _trafficData._minTs;
// Which window does currentTime fall into?
let idx = Math.floor((_trafficCurrentTime - minTs) / ws);
idx = Math.max(0, Math.min(wins.length - 1, idx));
// How far into this window are we? (0..1)
const wTs = wins[idx].ts;
let progress = 0;
if (ws > 0) progress = Math.max(0, Math.min(1, (_trafficCurrentTime - wTs) / ws));
// Crossfade: last 20% → blend from window[idx] to window[idx+1]
let crossfade = 0;
if (progress > 0.8) {
crossfade = Math.min(1, (progress - 0.8) / 0.2);
}
return {i: idx, crossfade};
}
// No more windows — edge lifecycle based
function trafficSeek(val) {
if (!_trafficData) return;
@@ -2709,14 +2683,12 @@ function trafficSeek(val) {
function trafficStepForward() {
if (!_trafficData) return;
const ws = _trafficData._ws;
_trafficSeekTarget = Math.min(_trafficData._maxTs, _trafficCurrentTime + ws);
_trafficSeekTarget = Math.min(_trafficData._maxTs, _trafficCurrentTime + 10);
}
function trafficStepBack() {
if (!_trafficData) return;
const ws = _trafficData._ws;
_trafficSeekTarget = Math.max(_trafficData._minTs, _trafficCurrentTime - ws);
_trafficSeekTarget = Math.max(_trafficData._minTs, _trafficCurrentTime - 10);
}
function trafficTogglePlay() { if (_trafficPlaying) trafficPause(); else trafficStart(); }
@@ -2732,7 +2704,7 @@ function trafficPause() {
cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null;
}
function trafficStop() {
trafficPause(); _trafficData = null;
trafficPause(); _trafficData = null; _trafficEdges = []; _trafficBuckets = {};
_removeFlowCanvas();
document.getElementById('saTrafficTime').textContent = '--:--:--';
document.getElementById('saTrafficInfo').textContent = '';
@@ -2778,42 +2750,54 @@ function renderTrafficCrossfade() {
if (!ctx) return;
ctx.clearRect(0, 0, c.width, c.height);
const wins = _trafficData.windows || [];
if (!wins.length) return;
const coords = _trafficData.nodes_coords || {};
const mix = getWindowMix();
const cf = mix.crossfade; // 0=only w0, 1=only w1
const w0 = wins[mix.i] || null;
const w1 = mix.i + 1 < wins.length ? wins[mix.i + 1] : null;
// Build edge map from both windows with crossfade weights
const edgeMap = {};
function addEdges(edges, windowAlpha) {
if (!edges || windowAlpha <= 0.02) return;
edges.forEach(e => {
const k = e.source + '|' + e.target;
if (!edgeMap[k]) edgeMap[k] = {source:e.source, target:e.target, alpha:0, count:0};
// For edges in both windows: keep max alpha (no flicker)
if (windowAlpha > edgeMap[k].alpha) edgeMap[k].alpha = windowAlpha;
if ((e.count||0) > edgeMap[k].count) edgeMap[k].count = e.count||0;
});
}
addEdges(w0 ? w0.edges : null, 1 - cf); // fading out
addEdges(w1 ? w1.edges : null, cf); // fading in
const edges = Object.values(edgeMap);
const edges = _trafficEdges || [];
if (!edges.length) return;
const maxCount = Math.max(1, ...edges.map(e => e.count));
const coords = _trafficData.nodes_coords || {};
const now = _trafficCurrentTime;
// Active nodes: union of both windows
// 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();
if (w0 && 1 - cf > 0.1) (w0.active_nodes||[]).forEach(n => activeNodes.add(n));
if (w1 && cf > 0.1) (w1.active_nodes||[]).forEach(n => activeNodes.add(n));
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 =
edges.length + ' 边 · ' + activeNodes.size + ' 节点 · ' + tsToBJ(_trafficCurrentTime);
visible.length + ' 边 · ' + activeNodes.size + ' 节点 · ' + tsToBJ(now);
edges.forEach(e => {
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]]);
@@ -2822,10 +2806,8 @@ function renderTrafficCrossfade() {
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 baseAlpha = 0.6 + 0.4 * ratio;
const wa = baseAlpha * e.alpha; // window blend
const wa = e.alpha * (0.6 + 0.4 * ratio);
// Edge trace
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) + ')');
@@ -2836,7 +2818,6 @@ function renderTrafficCrossfade() {
ctx.lineWidth = 1.5 + 4 * ratio;
ctx.stroke();
// Particles with same window blend
const speedMul = 0.5 + 1.5 * ratio;
const nP = Math.ceil(3 + 6 * ratio);
for (let pi = 0; pi < nP; pi++) {
@@ -2847,7 +2828,7 @@ function renderTrafficCrossfade() {
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+0.4*wa) + ')';
ctx.shadowColor = 'rgba(' + cr + ',' + cg + ',' + cb + ',0.4)';
ctx.shadowBlur = 5;
ctx.fill();
ctx.shadowBlur = 0;
+52 -4
View File
@@ -2171,8 +2171,8 @@ def traffic_timeline_view(request):
if not bj_timestamps:
return JsonResponse({'error': '无法解析时间戳'}, status=400)
min_ts = min(bj_timestamps)
max_ts = max(bj_timestamps)
min_ts = float(min(bj_timestamps))
max_ts = float(max(bj_timestamps))
# Build time windows
windows = []
@@ -2246,12 +2246,60 @@ def traffic_timeline_view(request):
if n.get('lat') is not None and n.get('lon') is not None:
nodes_coords[n['ip']] = [n['lat'], n['lon']]
# ── Per-edge lifecycle: first_ts, last_ts (continuous rendering) ──
# Parse timestamps with microsecond precision using datetime
from datetime import datetime, timezone, timedelta
BJ_TZ = timezone(timedelta(hours=8))
bj_floats = []
for t_str in time_list:
try:
t_clean = str(t_str).strip() if t_str else ''
if not t_clean or t_clean in ('None', '', 'nan'):
bj_floats.append(None); continue
# Try common formats
dt = None
for fmt in ['%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d %H:%M:%S.%f',
'%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S']:
try:
dt = datetime.strptime(t_clean[:26], fmt)
break
except ValueError: continue
if dt is None: bj_floats.append(None); continue
# Convert to Beijing time Unix timestamp
bj_floats.append(dt.replace(tzinfo=BJ_TZ).timestamp())
except Exception:
bj_floats.append(None)
edge_lifecycle = {} # (src,dst) → {count,bytes,first_ts,last_ts}
for idx in range(len(src_list)):
ts = bj_floats[idx] if idx < len(bj_floats) else None
if ts is None: continue
s = str(src_list[idx]) if idx < len(src_list) and src_list[idx] not in (None, '', 'None') else '?'
d = str(dst_list[idx]) if idx < len(dst_list) and dst_list[idx] not in (None, '', 'None') else '?'
key = (s, d)
if key not in edge_lifecycle:
edge_lifecycle[key] = {'count': 0, 'bytes': 0.0, 'first_ts': ts, 'last_ts': ts}
e = edge_lifecycle[key]
e['count'] += 1
if bytes_raw and idx < len(bytes_raw):
try: e['bytes'] += float(bytes_raw[idx] or 0)
except Exception: pass
if ts < e['first_ts']: e['first_ts'] = ts
if ts > e['last_ts']: e['last_ts'] = ts
edges_flat = []
for (src, dst), e in sorted(edge_lifecycle.items(), key=lambda x: -x[1]['count'])[:max_edges]:
edges_flat.append({
'source': src, 'target': dst,
'count': e['count'], 'bytes': round(e['bytes'], 2),
'first_ts': e['first_ts'], 'last_ts': e['last_ts'],
})
return JsonResponse({
'windows': window_data,
'edges': edges_flat,
'window_seconds': window_sec,
'min_ts': min_ts,
'max_ts': max_ts,
'n_windows': len(window_data),
'nodes_coords': nodes_coords,
})
except Exception as e: