refactor: smooth crossfade transition for traffic timeline, fix animation freeze, reduce memory

完全重写动态流通动画:
- Crossfade: 相邻窗口边通过透明度渐变(1-t↔t)平滑过渡
- getWindowMix: 计算当前窗口索引+混合因子t
- 边去重: 同一边在两窗口间连续存在不闪烁
- 时间确保持续推进,循环播放
- 边坐标缓存(地图移动时失效),减少latLngToContainerPoint调用
- 粒子基于Date.now()连续运动,不受窗口切换影响
- 节点活跃状态按两窗口并集+权重
- 移除旧的phase band/edgeMap/particle pool/_spawnTimers

验证: 上传→聚类→地图→流量面板→时间推进→实体流量图表,0 JS错误

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TianXuan Developer
2026-07-25 09:53:17 +08:00
parent 55b1d0a7a2
commit 66b35eb207
@@ -2567,17 +2567,21 @@ function generateColors(n) {
return Array.from({length: n}, (_, i) => palette[i % palette.length]);
}
/* ── Traffic Flow Animation (continuous particle system) ── */
let _trafficData = null;
/* ── Traffic Flow Animation (crossfade window transitions) ── */
let _trafficData = null; // {windows:[{ts,edges,active_nodes}], nodes_coords, window_seconds, _minTs, _maxTs}
let _trafficAnimId = null;
let _trafficPlaying = false;
let _trafficSpeed = 1;
let _trafficCurrentTime = 0; // current Beijing Unix time (seconds)
let _trafficParticles = []; // [{src, dst, p1, p2, midX, midY, progress, speed, count}]
let _trafficCurrentTime = 0;
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
let _trafficSeekTarget = null;
// Canvas overlay
let _trafficCanvas = null;
let _trafficCtx = null;
// Cached pixel positions for edges (rebuilt on map move)
let _trafficEdgeCache = {}; // key → {p1, p2, midX, midY}
function toggleTrafficPanel() {
const panel = document.getElementById('saTrafficPanel');
@@ -2585,13 +2589,37 @@ function toggleTrafficPanel() {
if (panel.classList.contains('open')) {
trafficStop();
panel.classList.remove('open');
_removeFlowLayer();
_removeFlowCanvas();
} else {
panel.classList.add('open');
loadTrafficData();
}
}
function _ensureFlowCanvas() {
if (!SA.map) return null;
if (_trafficCanvas && _trafficCanvas.parentNode) return _trafficCanvas;
const mapEl = SA.map.getContainer();
const c = document.createElement('canvas');
c.id = 'saTrafficCanvasOverlay';
c.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:10000;';
mapEl.appendChild(c);
const s = SA.map.getSize();
c.width = s.x; c.height = s.y;
c.style.width = s.x + 'px'; c.style.height = s.y + 'px';
SA.map.on('resize', () => { const sz = SA.map.getSize(); c.width = sz.x; c.height = sz.y; c.style.width = sz.x + 'px'; c.style.height = sz.y + 'px'; });
_trafficCtx = c.getContext('2d');
_trafficCanvas = c;
return c;
}
function _removeFlowCanvas() {
cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null;
if (_trafficCanvas) { _trafficCanvas.remove(); _trafficCanvas = null; _trafficCtx = null; }
}
function tsToBJ(ts) { const d = new Date(ts * 1000); return d.toISOString().replace('T',' ').slice(0,19); }
function loadTrafficData() {
if (!SA.sessionId) { showError('请先完成聚类分析'); return; }
showLoading('加载流通数据...');
@@ -2605,116 +2633,83 @@ function loadTrafficData() {
hideLoading();
if (d.error) { showError(d.error); return; }
_trafficData = d;
_trafficParticles = [];
_trafficSpawnTimers = {};
// Flatten all edges and build edge map
const allEdges = [];
(d.windows || []).forEach(wi => {
(wi.edges || []).forEach(e => {
const key = e.source + '|' + e.target;
if (!_trafficEdgeMap[key]) {
const obj = {source: e.source, target: e.target, count: 0, bytes: 0, phase: 0};
_trafficEdgeMap[key] = obj;
allEdges.push(obj);
}
_trafficEdgeMap[key].count += e.count;
_trafficEdgeMap[key].bytes += e.bytes;
});
});
// Distribute edges across time phases (0.0→1.0) so they don't all fire at once
allEdges.sort((a,b) => b.bytes - a.bytes);
allEdges.forEach((e, i) => { e.phase = i / Math.max(1, allEdges.length - 1); });
// Force a meaningful animation span (at least 60 fake-seconds)
_trafficEdgeCache = {};
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 + 1);
const realSpan = maxTs - minTs;
if (realSpan < 10) {
maxTs = minTs + 60; // artificial 60s span for sparse data
d._artificialSpan = true;
}
d._minTs = minTs;
d._maxTs = maxTs;
let maxTs = d.max_ts || (d.windows[d.n_windows-1]?.ts_end || minTs + ws);
if (maxTs - minTs < 10) maxTs = minTs + Math.max(60, ws * 6);
d._minTs = minTs; d._maxTs = maxTs; d._ws = ws;
_trafficCurrentTime = minTs;
const slider = document.getElementById('saTrafficSlider');
slider.min = 0; slider.max = 1000; slider.value = 0;
document.getElementById('saTrafficSlider').min = 0;
document.getElementById('saTrafficSlider').max = 1000;
document.getElementById('saTrafficSlider').value = 0;
document.getElementById('saTrafficInfo').textContent =
`${Object.keys(_trafficEdgeMap).length} 条边 · ${Object.keys(d.nodes_coords||{}).length} 节点`;
(d.windows||[]).length + ' 窗口 · ' + Object.keys(d.nodes_coords||{}).length + ' 节点';
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
trafficStart();
})
.catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}
function tsToBJ(ts) {
const d = new Date(ts * 1000);
return d.toISOString().replace('T',' ').slice(0,19);
}
function reloadTrafficData() { trafficStop(); loadTrafficData(); }
let _trafficSeekTarget = null;
// ── getWindowMix: find window index + crossfade factor ──
function getWindowMix() {
if (!_trafficData) return {i:0, t:0};
const wins = _trafficData.windows || [];
if (!wins.length) return {i:0, t: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));
// Crossfade: how far between this window's ts and next window's ts?
const wTs = wins[idx].ts;
const nextTs = idx + 1 < wins.length ? wins[idx+1].ts : wTs + ws;
let t = 0;
if (nextTs > wTs) t = Math.max(0, Math.min(1, (_trafficCurrentTime - wTs) / (nextTs - wTs)));
return {i: idx, t};
}
function trafficSeek(val) {
if (!_trafficData) return;
const minTs = _trafficData._minTs;
const maxTs = _trafficData._maxTs;
const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
_trafficSeekTarget = minTs + (val / 1000) * (maxTs - minTs);
_trafficParticles = [];
_trafficSpawnTimers = {};
}
function trafficStepForward() {
if (!_trafficData) return;
const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
const span = Math.max(1, maxTs - minTs);
_trafficCurrentTime = Math.min(maxTs, _trafficCurrentTime + span * 0.02);
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / span) * 1000;
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
const ws = _trafficData._ws;
_trafficSeekTarget = Math.min(_trafficData._maxTs, _trafficCurrentTime + ws);
}
function trafficStepBack() {
if (!_trafficData) return;
const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
const span = Math.max(1, maxTs - minTs);
_trafficCurrentTime = Math.max(minTs, _trafficCurrentTime - span * 0.02);
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / span) * 1000;
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
const ws = _trafficData._ws;
_trafficSeekTarget = Math.max(_trafficData._minTs, _trafficCurrentTime - ws);
}
function trafficTogglePlay() { if (_trafficPlaying) trafficPause(); else trafficStart(); }
function trafficStart() {
if (!_trafficData || _trafficPlaying) return;
_trafficPlaying = true;
_trafficPrevFrameTs = 0;
_trafficPlaying = true; _trafficPrevFrameTs = 0;
document.getElementById('saTrafficPlayBtn').textContent = '⏸ 暂停';
_trafficAnimId = requestAnimationFrame(trafficAnimate);
}
function trafficPause() {
_trafficPlaying = false;
document.getElementById('saTrafficPlayBtn').textContent = '▶ 播放';
if (_trafficAnimId) { cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null; }
cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null;
}
function trafficStop() {
trafficPause();
_trafficData = null; _trafficParticles = []; _trafficAllEdges = []; _trafficEdgeMap = {};
_removeFlowLayer();
trafficPause(); _trafficData = null; _trafficEdgeCache = {};
_removeFlowCanvas();
document.getElementById('saTrafficTime').textContent = '--:--:--';
document.getElementById('saTrafficInfo').textContent = '';
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 (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) {}
});
}
function trafficSetSpeed(speed, btn) {
_trafficSpeed = speed;
document.querySelectorAll('#saTrafficPanel button[data-speed]').forEach(b => b.classList.remove('active'));
@@ -2729,145 +2724,128 @@ function trafficAnimate(timestamp) {
_trafficPrevFrameTs = timestamp;
const dt = Math.min(rawDt, 0.5) * _trafficSpeed;
const minTs = _trafficData._minTs;
const maxTs = _trafficData._maxTs;
const totalSpan = Math.max(1, maxTs - minTs);
const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
const span = Math.max(1, maxTs - minTs);
// If seek is pending, jump to target
if (_trafficSeekTarget != null) {
_trafficCurrentTime = _trafficSeekTarget;
_trafficSeekTarget = null;
_trafficCurrentTime = _trafficSeekTarget; _trafficSeekTarget = null;
} else {
_trafficCurrentTime += dt;
if (_trafficCurrentTime > maxTs) _trafficCurrentTime = minTs + (_trafficCurrentTime - maxTs) % totalSpan;
if (_trafficCurrentTime > maxTs) _trafficCurrentTime = minTs + ((_trafficCurrentTime - maxTs) % span);
}
// Update slider
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / totalSpan) * 1000;
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / span) * 1000;
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
// Render
renderTrafficContinuous(totalSpan, minTs, maxTs);
renderTrafficCrossfade();
_trafficAnimId = requestAnimationFrame(trafficAnimate);
}
// ── Flow canvas overlay ──
let _trafficFlowLayer = null;
let _trafficFlowAnimId = null;
let _trafficCanvasCtx = null;
function _ensureFlowLayer() {
if (!SA.map) return null;
if (_trafficFlowLayer && _trafficFlowLayer.parentNode) return _trafficFlowLayer;
const mapEl = SA.map.getContainer();
const c = document.createElement('canvas');
c.id = 'saTrafficCanvasOverlay';
c.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:10000;';
mapEl.appendChild(c);
const size = SA.map.getSize();
c.width = size.x; c.height = size.y;
c.style.width = size.x + 'px'; c.style.height = size.y + 'px';
SA.map.on('resize', () => {
const s = SA.map.getSize();
c.width = s.x; c.height = s.y;
c.style.width = s.x + 'px'; c.style.height = s.y + 'px';
});
_trafficCanvasCtx = c.getContext('2d');
_trafficFlowLayer = c;
return c;
}
function _removeFlowLayer() {
if (_trafficFlowAnimId) { cancelAnimationFrame(_trafficFlowAnimId); _trafficFlowAnimId = null; }
if (_trafficFlowLayer) { _trafficFlowLayer.remove(); _trafficFlowLayer = null; _trafficCanvasCtx = null; }
}
function renderTrafficContinuous(totalSpan, minTs, maxTs) {
const c = _ensureFlowLayer();
if (!c || !SA.map) return;
const coords = _trafficData.nodes_coords || {};
const edges = Object.values(_trafficEdgeMap);
if (!edges.length) return;
const maxCount = Math.max(1, ...edges.map(e => e.count));
totalSpan = totalSpan || 30;
const ctx = _trafficCanvasCtx;
function renderTrafficCrossfade() {
const c = _ensureFlowCanvas();
if (!c || !SA.map || !_trafficData) return;
const ctx = _trafficCtx;
if (!ctx) return;
const progress = Math.max(0, Math.min(1, (_trafficCurrentTime - minTs) / totalSpan));
const bandWidth = 0.3;
const bandCenter = progress;
const activeEdges = [];
const activeNodes = new Set();
edges.forEach(e => {
const dist = Math.abs(e.phase - bandCenter);
const wrappedDist = Math.min(dist, 1 - dist);
if (wrappedDist < bandWidth) {
activeEdges.push(e);
activeNodes.add(e.source);
activeNodes.add(e.target);
}
});
document.getElementById('saTrafficInfo').textContent =
`${activeEdges.length} 活跃边 · ${activeNodes.size} 活跃节点 · ${tsToBJ(_trafficCurrentTime)}`;
ctx.clearRect(0, 0, c.width, c.height);
// Draw bezier curves THEN particles
activeEdges.slice(0, 300).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 midX = (p1.x + p2.x) / 2;
const midY = (p1.y + p2.y) / 2 - Math.abs(p2.x - p1.x) * 0.1;
const ratio = e.count / maxCount;
const wins = _trafficData.windows || [];
if (!wins.length) return;
// Background trace
const coords = _trafficData.nodes_coords || {};
const mix = getWindowMix();
const w0 = wins[mix.i] || null;
const w1 = mix.i + 1 < wins.length ? wins[mix.i + 1] : null;
const t = mix.t;
// Collect edges from both windows with blend weights
// edgeKey → {source, target, weight, count}
const edgeMap = {};
function addEdges(edges, weight, baseCount) {
if (!edges) return;
edges.forEach(e => {
const k = e.source + '|' + e.target;
if (!edgeMap[k]) edgeMap[k] = {source:e.source, target:e.target, weight:0, count:0};
edgeMap[k].weight = Math.max(edgeMap[k].weight, weight);
edgeMap[k].count = Math.max(edgeMap[k].count, e.count || 0);
});
}
addEdges(w0 ? w0.edges : [], 1 - t, 0);
addEdges(w1 ? w1.edges : [], t, 0);
// Also add edges from next window for smooth fade-in
if (mix.i + 2 < wins.length) {
addEdges(wins[mix.i + 2].edges, Math.max(0, t - 1), 0);
}
const edges = Object.values(edgeMap);
if (!edges.length) return;
const maxCount = Math.max(1, ...edges.map(e => e.count));
// Active node set (union of both windows, weighted)
const activeNodes = new Set();
if (w0 && 1 - t > 0.1) (w0.active_nodes||[]).forEach(n => activeNodes.add(n));
if (w1 && t > 0.1) (w1.active_nodes||[]).forEach(n => activeNodes.add(n));
document.getElementById('saTrafficInfo').textContent =
edges.length + ' 边 · ' + activeNodes.size + ' 节点 · ' + tsToBJ(_trafficCurrentTime);
// Build pixel coordinates for edges (cached until map moves)
edges.forEach(e => {
let cached = _trafficEdgeCache[e.source + '|' + e.target];
if (!cached) {
const sc = coords[e.source], dc = coords[e.target];
if (!sc || !dc) { _trafficEdgeCache[e.source+'|'+e.target] = null; return; }
const p1 = SA.map.latLngToContainerPoint([sc[0], sc[1]]);
const p2 = SA.map.latLngToContainerPoint([dc[0], dc[1]]);
if (!p1 || !p2) return;
cached = {
p1x:p1.x, p1y:p1.y, p2x:p2.x, p2y:p2.y,
midX:(p1.x+p2.x)/2, midY:(p1.y+p2.y)/2 - Math.abs(p2.x-p1.x)*0.1
};
_trafficEdgeCache[e.source+'|'+e.target] = cached;
}
if (!cached) return;
const ratio = e.count / maxCount;
const alpha = 0.3 + 0.6 * e.weight * ratio;
// Bezier trace
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.quadraticCurveTo(midX, midY, p2.x, p2.y);
ctx.strokeStyle = `rgba(67,97,238,${0.4 + 0.5 * ratio})`;
ctx.lineWidth = 1.5 + 4 * ratio;
ctx.moveTo(cached.p1x, cached.p1y);
ctx.quadraticCurveTo(cached.midX, cached.midY, cached.p2x, cached.p2y);
ctx.strokeStyle = 'rgba(67,97,238,' + alpha.toFixed(2) + ')';
ctx.lineWidth = 1 + 4 * ratio;
ctx.stroke();
// FLOWING PARTICLES
const particleCount = Math.ceil(3 + 5 * ratio);
for (let pi = 0; pi < particleCount; pi++) {
// Each particle at a different phase along the curve
const t = ((Date.now() / 2000 + pi / particleCount) % 1);
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;
// Color changes along the flow - start blue, end red
const r = Math.round(67 + (230 - 67) * t);
const gVal = Math.round(97 + (57 - 97) * t);
const bVal = Math.round(238 + (70 - 238) * t);
// Particles (continuous, driven by Date.now)
const nParticles = Math.ceil(3 + 5 * ratio);
for (let pi = 0; pi < nParticles; pi++) {
const pt = ((Date.now() / 2000 + pi / nParticles) % 1);
const px = (1-pt)*(1-pt)*cached.p1x + 2*(1-pt)*pt*cached.midX + pt*pt*cached.p2x;
const py = (1-pt)*(1-pt)*cached.p1y + 2*(1-pt)*pt*cached.midY + pt*pt*cached.p2y;
const r = Math.round(67 + 163 * pt), g = Math.round(97 - 40 * pt), b = Math.round(238 - 168 * pt);
ctx.beginPath();
ctx.arc(px, py, 2.5 + 3 * ratio, 0, Math.PI * 2);
ctx.fillStyle = `rgb(${r},${gVal},${bVal})`;
ctx.shadowColor = `rgba(${r},${gVal},${bVal},0.6)`;
ctx.arc(px, py, 2 + 3 * ratio, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + (0.5 + 0.5 * e.weight) + ')';
ctx.shadowColor = 'rgba(' + r + ',' + g + ',' + b + ',0.5)';
ctx.shadowBlur = 4;
ctx.fill();
ctx.shadowBlur = 0;
}
});
// Dim inactive nodes
// Invalidate edge cache if map has moved (simplified: clear on zoom/move)
SA.map.off('moveend.flowCache').on('moveend.flowCache', () => { _trafficEdgeCache = {}; });
// Node dimming
if (SA.pointLayer && !SA._clusterFocus) {
SA.pointLayer.getLayers().forEach(l => {
if (!l.setStyle) return;
try {
const content = l.getTooltip()?.getContent?.() || '';
const match = content.match(/\b(\d+\.\d+\.\d+\.\d+)\b/);
if (match) {
l.setStyle(activeNodes.has(match[1])
? {fillOpacity: 0.95, opacity: 1, weight: 3, color: '#fff'}
: {fillOpacity: 0.2, opacity: 0.3, weight: 1, color: '#999'});
}
const ct = l.getTooltip()?.getContent?.() || '';
const m = ct.match(/\b(\d+\.\d+\.\d+\.\d+)\b/);
if (m) l.setStyle(activeNodes.has(m[1])
? {fillOpacity:0.95,opacity:1,weight:3,color:'#fff'}
: {fillOpacity:0.2,opacity:0.3,weight:1,color:'#999'});
} catch(e) {}
});
}