feat: 流量动画重构为连续粒子系统

替换逐帧窗口切换为连续时间轴粒子动画:
- 时间连续推进(requestAnimationFrame + speed倍率)
- 粒子从源节点沿贝塞尔曲线流向目标,自动生成/过期
- 条边淡淡轨迹 + 运动光点,大小/透明度反映流量
- 时间滑块平滑映射到完整时间范围
- 活跃窗口自适应扩展(data稀疏时保持边可见)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TianXuan Developer
2026-07-24 23:28:28 +08:00
parent f17ba2255a
commit 74455a03b9
@@ -2566,14 +2566,18 @@ function generateColors(n) {
return Array.from({length: n}, (_, i) => palette[i % palette.length]);
}
/* ── Traffic Flow Animation ─────────────────────────── */
/* ── Traffic Flow Animation (continuous particle system) ── */
let _trafficData = null;
let _trafficAnimId = null;
let _trafficPlaying = false;
let _trafficSpeed = 1;
let _trafficWindowIdx = 0;
let _trafficParticles = [];
let _trafficCurrentTime = 0; // current Beijing Unix time (seconds)
let _trafficCanvasCtx = null;
let _trafficParticles = []; // [{src, dst, p1, p2, midX, midY, progress, speed, count}]
let _trafficPrevFrameTs = 0;
let _trafficAllEdges = []; // flat array of all edges across all windows
let _trafficEdgeMap = {}; // key "src|dst" → {count, bytes}
let _trafficSpawnTimers = {}; // key → next spawn time
function toggleTrafficPanel() {
const panel = document.getElementById('saTrafficPanel');
@@ -2581,10 +2585,6 @@ function toggleTrafficPanel() {
if (panel.classList.contains('open')) {
trafficStop();
panel.classList.remove('open');
// Remove canvas layer
const c = document.getElementById('saTrafficCanvas');
if (c) c.style.display = 'none';
if (_trafficCanvasCtx) { _trafficCanvasCtx.clearRect(0,0,c.width,c.height); }
_removeTrafficCanvas();
} else {
panel.classList.add('open');
@@ -2594,63 +2594,95 @@ function toggleTrafficPanel() {
function loadTrafficData() {
if (!SA.sessionId) { showError('请先完成聚类分析'); return; }
const ws = parseInt(document.getElementById('saTrafficWindow')?.value || 60);
showLoading('加载流通数据...');
fetch('/simple/traffic-timeline/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify({session_id: SA.sessionId, window_seconds: ws, max_edges: 300}),
body: JSON.stringify({session_id: SA.sessionId, window_seconds: 10, max_edges: 300}),
})
.then(r => r.json())
.then(d => {
hideLoading();
if (d.error) { showError(d.error); return; }
_trafficData = d;
_trafficWindowIdx = 0;
_trafficParticles = [];
_trafficSpawnTimers = {};
// Flatten all edges across windows with their timestamps
_trafficAllEdges = [];
_trafficEdgeMap = {};
(d.windows || []).forEach(wi => {
(wi.edges || []).forEach(e => {
const key = e.source + '|' + e.target;
if (!_trafficEdgeMap[key]) {
_trafficEdgeMap[key] = {source: e.source, target: e.target, count: 0, bytes: 0, windows: []};
}
_trafficEdgeMap[key].count += e.count;
_trafficEdgeMap[key].bytes += e.bytes;
_trafficEdgeMap[key].windows.push(wi.ts);
});
});
// Set continuous time range
_trafficCurrentTime = d.min_ts || (d.windows[0]?.ts || 0);
const maxTs = d.max_ts || (d.windows[d.n_windows-1]?.ts_end || _trafficCurrentTime + 60);
const slider = document.getElementById('saTrafficSlider');
slider.max = Math.max(0, d.n_windows - 1);
slider.min = 0;
slider.max = 1000;
slider.value = 0;
document.getElementById('saTrafficInfo').textContent =
`${d.n_windows} 个窗口 · ${ws}s/窗口 · ${Object.keys(d.nodes_coords||{}).length} 节点有坐标`;
trafficSeek(0);
`${Object.keys(_trafficEdgeMap).length} 条边 · ${Object.keys(d.nodes_coords||{}).length} 节点 · 跨度 ${((maxTs-_trafficCurrentTime)/3600).toFixed(1)}h`;
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
trafficStart();
})
.catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}
function reloadTrafficData() {
trafficStop();
loadTrafficData();
function tsToBJ(ts) {
const d = new Date(ts * 1000);
return d.toISOString().replace('T',' ').slice(0,19);
}
function trafficSeek(idx) {
idx = parseInt(idx);
function reloadTrafficData() { trafficStop(); loadTrafficData(); }
let _trafficSeekTarget = null;
function trafficSeek(val) {
if (!_trafficData) return;
_trafficWindowIdx = Math.max(0, Math.min(idx, _trafficData.n_windows - 1));
document.getElementById('saTrafficSlider').value = _trafficWindowIdx;
const w = _trafficData.windows[_trafficWindowIdx];
if (w) {
document.getElementById('saTrafficTime').textContent = w.t_label;
renderTrafficFrame(w);
}
const maxTs = _trafficData.max_ts || (_trafficData.windows[_trafficData.n_windows-1]?.ts_end || _trafficCurrentTime + 60);
const minTs = _trafficData.min_ts || (_trafficData.windows[0]?.ts || 0);
_trafficSeekTarget = minTs + (val / 1000) * (maxTs - minTs);
_trafficParticles = [];
_trafficSpawnTimers = {};
}
function trafficStepForward() {
if (_trafficData) trafficSeek(_trafficWindowIdx + 1);
if (_trafficData) {
const maxTs = _trafficData.max_ts || _trafficCurrentTime + 60;
const minTs = _trafficData.min_ts || _trafficCurrentTime;
_trafficCurrentTime = Math.min(maxTs, _trafficCurrentTime + 60);
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / Math.max(1, maxTs - minTs)) * 1000;
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
}
}
function trafficStepBack() {
if (_trafficData) trafficSeek(_trafficWindowIdx - 1);
if (_trafficData) {
const minTs = _trafficData.min_ts || _trafficCurrentTime;
_trafficCurrentTime = Math.max(minTs, _trafficCurrentTime - 60);
const maxTs = _trafficData.max_ts || _trafficCurrentTime + 60;
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / Math.max(1, maxTs - minTs)) * 1000;
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
}
}
function trafficTogglePlay() {
if (_trafficPlaying) trafficPause(); else trafficStart();
}
function trafficTogglePlay() { if (_trafficPlaying) trafficPause(); else trafficStart(); }
function trafficStart() {
if (!_trafficData || _trafficPlaying) return;
_trafficPlaying = true;
_trafficLastFrameTime = 0;
_trafficPrevFrameTs = 0;
document.getElementById('saTrafficPlayBtn').textContent = '⏸ 暂停';
_trafficAnimId = requestAnimationFrame(trafficAnimate);
}
@@ -2663,18 +2695,13 @@ function trafficPause() {
function trafficStop() {
trafficPause();
_trafficData = null;
_trafficWindowIdx = 0;
_trafficData = null; _trafficParticles = []; _trafficAllEdges = []; _trafficEdgeMap = {};
_removeTrafficCanvas();
document.getElementById('saTrafficTime').textContent = '--:--:--';
document.getElementById('saTrafficInfo').textContent = '';
// Restore node styles
if (SA.pointLayer) {
SA.pointLayer.getLayers().forEach(l => {
if (l.setStyle) {
try { l.setStyle({fillOpacity: 0.85, opacity: 0.95, weight: 2, color: '#fff'}); } catch(e) {}
}
if (l.setStyle) { try { l.setStyle({fillOpacity: 0.85, opacity: 0.95, weight: 2, color: '#fff'}); } catch(e) {} }
});
}
}
@@ -2685,26 +2712,35 @@ function trafficSetSpeed(speed, btn) {
if (btn) btn.classList.add('active');
}
let _trafficLastFrameTime = 0;
let _trafficFrameDuration = 1000;
function trafficAnimate(timestamp) {
if (!_trafficPlaying || !_trafficData) return;
if (!_trafficPrevFrameTs) _trafficPrevFrameTs = timestamp;
if (!_trafficLastFrameTime) _trafficLastFrameTime = timestamp;
const elapsed = timestamp - _trafficLastFrameTime;
const rawDt = (timestamp - _trafficPrevFrameTs) / 1000;
_trafficPrevFrameTs = timestamp;
const dt = Math.min(rawDt, 0.5) * _trafficSpeed;
const frameLen = Math.max(50, _trafficData.window_seconds * 1000 / _trafficSpeed);
const maxTs = _trafficData.max_ts || (_trafficData.windows[_trafficData.n_windows-1]?.ts_end || _trafficCurrentTime + 60);
const minTs = _trafficData.min_ts || (_trafficData.windows[0]?.ts || 0);
// Guarantee at least 30s of animation span so particles have time to travel
const rawSpan = maxTs - minTs;
const totalSpan = Math.max(30, rawSpan, ((_trafficData.windows||[]).length * (_trafficData.window_seconds || 10)));
if (elapsed >= frameLen) {
_trafficLastFrameTime = timestamp;
_trafficWindowIdx = (_trafficWindowIdx + 1) % _trafficData.n_windows;
document.getElementById('saTrafficSlider').value = _trafficWindowIdx;
// If seek is pending, jump to target
if (_trafficSeekTarget != null) {
_trafficCurrentTime = _trafficSeekTarget;
_trafficSeekTarget = null;
} else {
_trafficCurrentTime += dt;
if (_trafficCurrentTime > maxTs) _trafficCurrentTime = minTs + (_trafficCurrentTime - maxTs) % totalSpan;
}
// Always re-render for smooth particle animation
const w = _trafficData.windows[_trafficWindowIdx];
if (w) renderTrafficFrame(w);
// Update slider
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / totalSpan) * 1000;
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
// Render
renderTrafficContinuous(totalSpan, minTs, maxTs);
_trafficAnimId = requestAnimationFrame(trafficAnimate);
}
@@ -2721,10 +2757,8 @@ function _ensureTrafficCanvas() {
}
const rect = container.getBoundingClientRect();
if (c.width !== rect.width || c.height !== rect.height) {
c.width = rect.width;
c.height = rect.height;
c.style.width = rect.width + 'px';
c.style.height = rect.height + 'px';
c.width = rect.width; c.height = rect.height;
c.style.width = rect.width + 'px'; c.style.height = rect.height + 'px';
}
return c;
}
@@ -2734,71 +2768,131 @@ function _removeTrafficCanvas() {
if (c) { c.remove(); _trafficCanvasCtx = null; }
}
function renderTrafficFrame(w) {
if (!w || !SA.map) return;
document.getElementById('saTrafficTime').textContent = w.t_label;
document.getElementById('saTrafficInfo').textContent =
`${w.edges.length} 条活跃边 · ${w.active_nodes.length} 活跃节点`;
function renderTrafficContinuous(totalSpan, minTs, maxTs) {
const c = _ensureTrafficCanvas();
if (!c) return;
if (!c || !SA.map) return;
c.style.display = 'block';
_trafficCanvasCtx = c.getContext('2d');
_trafficCanvasCtx.clearRect(0, 0, c.width, c.height);
const coords = _trafficData.nodes_coords || {};
const maxCount = Math.max(1, ...w.edges.map(e => e.count));
const edges = Object.values(_trafficEdgeMap);
const maxCount = Math.max(1, ...edges.map(e => e.count));
const windowSec = _trafficData.window_seconds || 10;
totalSpan = totalSpan || 30;
w.edges.forEach(e => {
const srcC = coords[e.source];
const dstC = coords[e.target];
if (!srcC || !dstC) return;
const p1 = SA.map.latLngToContainerPoint([srcC[0], srcC[1]]);
const p2 = SA.map.latLngToContainerPoint([dstC[0], dstC[1]]);
if (!p1 || !p2) return;
const alpha = 0.25 + 0.55 * (e.count / maxCount);
const width = 1.5 + 3 * (e.count / maxCount);
// Active window width: wide enough to keep edges alive
const activeWindow = Math.max(windowSec * 5, totalSpan * 0.3);
const activeEdges = [];
const activeNodes = new Set();
edges.forEach(e => {
const near = (e.windows || []).some(wt =>
Math.abs(_trafficCurrentTime - wt) < activeWindow
);
if (near) {
activeEdges.push(e);
activeNodes.add(e.source);
activeNodes.add(e.target);
}
});
document.getElementById('saTrafficInfo').textContent =
`${activeEdges.length} 活跃边 · ${activeNodes.size} 活跃节点 · ${tsToBJ(_trafficCurrentTime)}`;
// Build pixel positions cache
const pixelCache = {};
const getPixel = (ip, lat, lon) => {
if (pixelCache[ip]) return pixelCache[ip];
const pt = SA.map.latLngToContainerPoint([lat, lon]);
if (pt) pixelCache[ip] = pt;
return pt;
};
// Spawn new particles
const now = _trafficCurrentTime;
const particleLifetime = Math.max(3, Math.min(8, totalSpan * 0.1));
const spawnInterval = Math.max(0.02, particleLifetime / 8); // spawn ~8 particles per edge per lifetime
activeEdges.forEach(e => {
const key = e.source + '|' + e.target;
if (!_trafficSpawnTimers[key] || now - _trafficSpawnTimers[key] > spawnInterval / Math.max(1, e.count)) {
_trafficSpawnTimers[key] = now;
_trafficParticles.push({
source: e.source, target: e.target,
progress: 0,
speed: 1 / particleLifetime,
count: e.count,
});
}
});
// Update & render particles
const toRemove = [];
_trafficParticles.forEach((p, i) => {
p.progress += p.speed * Math.min(0.1, (1 / 60) * _trafficSpeed * 2);
if (p.progress >= 1) { toRemove.push(i); return; }
const srcC = coords[p.source]; const dstC = coords[p.target];
if (!srcC || !dstC) { toRemove.push(i); return; }
const p1 = getPixel(p.source, srcC[0], srcC[1]);
const p2 = getPixel(p.target, dstC[0], dstC[1]);
if (!p1 || !p2) { toRemove.push(i); return; }
// Curved edge
const midX = (p1.x + p2.x) / 2;
const midY = (p1.y + p2.y) / 2 - Math.abs(p2.x - p1.x) * 0.12;
const midY = (p1.y + p2.y) / 2 - Math.abs(p2.x - p1.x) * 0.1;
// Fade trail
const t = p.progress;
const px = (1-t)*(1-t)*p1.x + 2*(1-t)*t*midX + t*t*p2.x;
const py = (1-t)*(1-t)*p1.y + 2*(1-t)*t*midY + t*t*p2.y;
const size = 2 + 2.5 * (p.count / maxCount);
const alpha = 0.5 + 0.5 * (1 - Math.abs(t - 0.5) * 2); // brightest at midpoint
_trafficCanvasCtx.beginPath();
_trafficCanvasCtx.arc(px, py, size, 0, Math.PI * 2);
_trafficCanvasCtx.fillStyle = `rgba(230,57,70,${alpha.toFixed(2)})`;
_trafficCanvasCtx.fill();
});
for (let i = toRemove.length - 1; i >= 0; i--) _trafficParticles.splice(toRemove[i], 1);
// Draw active edge traces (faint background)
activeEdges.forEach(e => {
const srcC = coords[e.source]; const dstC = coords[e.target];
if (!srcC || !dstC) return;
const p1 = getPixel(e.source, srcC[0], srcC[1]);
const p2 = getPixel(e.target, dstC[0], dstC[1]);
if (!p1 || !p2) return;
const midX = (p1.x + p2.x) / 2;
const midY = (p1.y + p2.y) / 2 - Math.abs(p2.x - p1.x) * 0.1;
const alpha = 0.15 + 0.3 * (e.count / maxCount);
const width = 0.8 + 2 * (e.count / maxCount);
_trafficCanvasCtx.beginPath();
_trafficCanvasCtx.moveTo(p1.x, p1.y);
_trafficCanvasCtx.quadraticCurveTo(midX, midY, p2.x, p2.y);
_trafficCanvasCtx.strokeStyle = `rgba(67,97,238,${alpha.toFixed(2)})`;
_trafficCanvasCtx.lineWidth = width;
_trafficCanvasCtx.stroke();
// Flow particle
const phase = (Date.now() / 2000 + e.count) % 1;
const t = 0.1 + phase * 0.8;
const px = (1-t)*(1-t)*p1.x + 2*(1-t)*t*midX + t*t*p2.x;
const py = (1-t)*(1-t)*p1.y + 2*(1-t)*t*midY + t*t*p2.y;
_trafficCanvasCtx.beginPath();
_trafficCanvasCtx.arc(px, py, 2.5 + 2.5*(e.count/maxCount), 0, Math.PI*2);
_trafficCanvasCtx.fillStyle = '#e63946';
_trafficCanvasCtx.fill();
});
// Dim inactive nodes
const activeSet = new Set(w.active_nodes || []);
if (SA.pointLayer && !SA._clusterFocus) {
SA.pointLayer.getLayers().forEach(l => {
if (l.setStyle && l._tooltip) {
try {
const content = l.getTooltip()?.getContent?.() || '';
const match = content.match(/\b(\d+\.\d+\.\d+\.\d+)\b/);
if (match) {
if (activeSet.has(match[1])) {
l.setStyle({fillOpacity: 0.95, opacity: 1, weight: 3, color: '#fff'});
} else {
l.setStyle({fillOpacity: 0.2, opacity: 0.3, weight: 1, color: '#999'});
}
if (!l.setStyle) return;
try {
const content = l.getTooltip()?.getContent?.() || '';
const match = content.match(/\b(\d+\.\d+\.\d+\.\d+)\b/);
if (match) {
if (activeNodes.has(match[1])) {
l.setStyle({fillOpacity: 0.95, opacity: 1, weight: 3, color: '#fff'});
} else {
l.setStyle({fillOpacity: 0.2, opacity: 0.3, weight: 1, color: '#999'});
}
} catch(e) {}
}
}
} catch(e) {}
});
}
// Trim old particles to prevent memory leak
if (_trafficParticles.length > 500) _trafficParticles.splice(0, _trafficParticles.length - 400);
}
/* ── Entity traffic view in detail panel ────────────── */