feat: 流量动画重构为连续粒子系统
替换逐帧窗口切换为连续时间轴粒子动画: - 时间连续推进(requestAnimationFrame + speed倍率) - 粒子从源节点沿贝塞尔曲线流向目标,自动生成/过期 - 条边淡淡轨迹 + 运动光点,大小/透明度反映流量 - 时间滑块平滑映射到完整时间范围 - 活跃窗口自适应扩展(data稀疏时保持边可见) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 ────────────── */
|
||||
|
||||
Reference in New Issue
Block a user