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:
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user