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:
@@ -2567,17 +2567,21 @@ function generateColors(n) {
|
|||||||
return Array.from({length: n}, (_, i) => palette[i % palette.length]);
|
return Array.from({length: n}, (_, i) => palette[i % palette.length]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Traffic Flow Animation (continuous particle system) ── */
|
/* ── Traffic Flow Animation (crossfade window transitions) ── */
|
||||||
let _trafficData = null;
|
let _trafficData = null; // {windows:[{ts,edges,active_nodes}], nodes_coords, window_seconds, _minTs, _maxTs}
|
||||||
let _trafficAnimId = null;
|
let _trafficAnimId = null;
|
||||||
let _trafficPlaying = false;
|
let _trafficPlaying = false;
|
||||||
let _trafficSpeed = 1;
|
let _trafficSpeed = 1;
|
||||||
let _trafficCurrentTime = 0; // current Beijing Unix time (seconds)
|
let _trafficCurrentTime = 0;
|
||||||
let _trafficParticles = []; // [{src, dst, p1, p2, midX, midY, progress, speed, count}]
|
|
||||||
let _trafficPrevFrameTs = 0;
|
let _trafficPrevFrameTs = 0;
|
||||||
let _trafficAllEdges = []; // flat array of all edges across all windows
|
let _trafficSeekTarget = null;
|
||||||
let _trafficEdgeMap = {}; // key "src|dst" → {count, bytes}
|
|
||||||
let _trafficSpawnTimers = {}; // key → next spawn time
|
// 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() {
|
function toggleTrafficPanel() {
|
||||||
const panel = document.getElementById('saTrafficPanel');
|
const panel = document.getElementById('saTrafficPanel');
|
||||||
@@ -2585,13 +2589,37 @@ function toggleTrafficPanel() {
|
|||||||
if (panel.classList.contains('open')) {
|
if (panel.classList.contains('open')) {
|
||||||
trafficStop();
|
trafficStop();
|
||||||
panel.classList.remove('open');
|
panel.classList.remove('open');
|
||||||
_removeFlowLayer();
|
_removeFlowCanvas();
|
||||||
} else {
|
} else {
|
||||||
panel.classList.add('open');
|
panel.classList.add('open');
|
||||||
loadTrafficData();
|
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() {
|
function loadTrafficData() {
|
||||||
if (!SA.sessionId) { showError('请先完成聚类分析'); return; }
|
if (!SA.sessionId) { showError('请先完成聚类分析'); return; }
|
||||||
showLoading('加载流通数据...');
|
showLoading('加载流通数据...');
|
||||||
@@ -2605,116 +2633,83 @@ function loadTrafficData() {
|
|||||||
hideLoading();
|
hideLoading();
|
||||||
if (d.error) { showError(d.error); return; }
|
if (d.error) { showError(d.error); return; }
|
||||||
_trafficData = d;
|
_trafficData = d;
|
||||||
_trafficParticles = [];
|
_trafficEdgeCache = {};
|
||||||
_trafficSpawnTimers = {};
|
let ws = d.window_seconds || 10;
|
||||||
|
|
||||||
// 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)
|
|
||||||
let minTs = d.min_ts || (d.windows[0]?.ts || 0);
|
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);
|
let maxTs = d.max_ts || (d.windows[d.n_windows-1]?.ts_end || minTs + ws);
|
||||||
const realSpan = maxTs - minTs;
|
if (maxTs - minTs < 10) maxTs = minTs + Math.max(60, ws * 6);
|
||||||
if (realSpan < 10) {
|
d._minTs = minTs; d._maxTs = maxTs; d._ws = ws;
|
||||||
maxTs = minTs + 60; // artificial 60s span for sparse data
|
|
||||||
d._artificialSpan = true;
|
|
||||||
}
|
|
||||||
d._minTs = minTs;
|
|
||||||
d._maxTs = maxTs;
|
|
||||||
|
|
||||||
_trafficCurrentTime = minTs;
|
_trafficCurrentTime = minTs;
|
||||||
|
document.getElementById('saTrafficSlider').min = 0;
|
||||||
const slider = document.getElementById('saTrafficSlider');
|
document.getElementById('saTrafficSlider').max = 1000;
|
||||||
slider.min = 0; slider.max = 1000; slider.value = 0;
|
document.getElementById('saTrafficSlider').value = 0;
|
||||||
|
|
||||||
document.getElementById('saTrafficInfo').textContent =
|
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);
|
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
|
||||||
trafficStart();
|
trafficStart();
|
||||||
})
|
})
|
||||||
.catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
|
.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(); }
|
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) {
|
function trafficSeek(val) {
|
||||||
if (!_trafficData) return;
|
if (!_trafficData) return;
|
||||||
const minTs = _trafficData._minTs;
|
const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
|
||||||
const maxTs = _trafficData._maxTs;
|
|
||||||
_trafficSeekTarget = minTs + (val / 1000) * (maxTs - minTs);
|
_trafficSeekTarget = minTs + (val / 1000) * (maxTs - minTs);
|
||||||
_trafficParticles = [];
|
|
||||||
_trafficSpawnTimers = {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function trafficStepForward() {
|
function trafficStepForward() {
|
||||||
if (!_trafficData) return;
|
if (!_trafficData) return;
|
||||||
const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
|
const ws = _trafficData._ws;
|
||||||
const span = Math.max(1, maxTs - minTs);
|
_trafficSeekTarget = Math.min(_trafficData._maxTs, _trafficCurrentTime + ws);
|
||||||
_trafficCurrentTime = Math.min(maxTs, _trafficCurrentTime + span * 0.02);
|
|
||||||
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / span) * 1000;
|
|
||||||
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function trafficStepBack() {
|
function trafficStepBack() {
|
||||||
if (!_trafficData) return;
|
if (!_trafficData) return;
|
||||||
const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
|
const ws = _trafficData._ws;
|
||||||
const span = Math.max(1, maxTs - minTs);
|
_trafficSeekTarget = Math.max(_trafficData._minTs, _trafficCurrentTime - ws);
|
||||||
_trafficCurrentTime = Math.max(minTs, _trafficCurrentTime - span * 0.02);
|
|
||||||
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / span) * 1000;
|
|
||||||
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function trafficTogglePlay() { if (_trafficPlaying) trafficPause(); else trafficStart(); }
|
function trafficTogglePlay() { if (_trafficPlaying) trafficPause(); else trafficStart(); }
|
||||||
|
|
||||||
function trafficStart() {
|
function trafficStart() {
|
||||||
if (!_trafficData || _trafficPlaying) return;
|
if (!_trafficData || _trafficPlaying) return;
|
||||||
_trafficPlaying = true;
|
_trafficPlaying = true; _trafficPrevFrameTs = 0;
|
||||||
_trafficPrevFrameTs = 0;
|
|
||||||
document.getElementById('saTrafficPlayBtn').textContent = '⏸ 暂停';
|
document.getElementById('saTrafficPlayBtn').textContent = '⏸ 暂停';
|
||||||
_trafficAnimId = requestAnimationFrame(trafficAnimate);
|
_trafficAnimId = requestAnimationFrame(trafficAnimate);
|
||||||
}
|
}
|
||||||
|
|
||||||
function trafficPause() {
|
function trafficPause() {
|
||||||
_trafficPlaying = false;
|
_trafficPlaying = false;
|
||||||
document.getElementById('saTrafficPlayBtn').textContent = '▶ 播放';
|
document.getElementById('saTrafficPlayBtn').textContent = '▶ 播放';
|
||||||
if (_trafficAnimId) { cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null; }
|
cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trafficStop() {
|
function trafficStop() {
|
||||||
trafficPause();
|
trafficPause(); _trafficData = null; _trafficEdgeCache = {};
|
||||||
_trafficData = null; _trafficParticles = []; _trafficAllEdges = []; _trafficEdgeMap = {};
|
_removeFlowCanvas();
|
||||||
_removeFlowLayer();
|
|
||||||
document.getElementById('saTrafficTime').textContent = '--:--:--';
|
document.getElementById('saTrafficTime').textContent = '--:--:--';
|
||||||
document.getElementById('saTrafficInfo').textContent = '';
|
document.getElementById('saTrafficInfo').textContent = '';
|
||||||
if (SA.pointLayer) {
|
if (SA.pointLayer) SA.pointLayer.getLayers().forEach(l => {
|
||||||
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) {} }
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function trafficSetSpeed(speed, btn) {
|
function trafficSetSpeed(speed, btn) {
|
||||||
_trafficSpeed = speed;
|
_trafficSpeed = speed;
|
||||||
document.querySelectorAll('#saTrafficPanel button[data-speed]').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('#saTrafficPanel button[data-speed]').forEach(b => b.classList.remove('active'));
|
||||||
@@ -2729,145 +2724,128 @@ function trafficAnimate(timestamp) {
|
|||||||
_trafficPrevFrameTs = timestamp;
|
_trafficPrevFrameTs = timestamp;
|
||||||
const dt = Math.min(rawDt, 0.5) * _trafficSpeed;
|
const dt = Math.min(rawDt, 0.5) * _trafficSpeed;
|
||||||
|
|
||||||
const minTs = _trafficData._minTs;
|
const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs;
|
||||||
const maxTs = _trafficData._maxTs;
|
const span = Math.max(1, maxTs - minTs);
|
||||||
const totalSpan = Math.max(1, maxTs - minTs);
|
|
||||||
|
|
||||||
// If seek is pending, jump to target
|
|
||||||
if (_trafficSeekTarget != null) {
|
if (_trafficSeekTarget != null) {
|
||||||
_trafficCurrentTime = _trafficSeekTarget;
|
_trafficCurrentTime = _trafficSeekTarget; _trafficSeekTarget = null;
|
||||||
_trafficSeekTarget = null;
|
|
||||||
} else {
|
} else {
|
||||||
_trafficCurrentTime += dt;
|
_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) / span) * 1000;
|
||||||
document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / totalSpan) * 1000;
|
|
||||||
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
|
document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime);
|
||||||
|
|
||||||
// Render
|
renderTrafficCrossfade();
|
||||||
renderTrafficContinuous(totalSpan, minTs, maxTs);
|
|
||||||
|
|
||||||
_trafficAnimId = requestAnimationFrame(trafficAnimate);
|
_trafficAnimId = requestAnimationFrame(trafficAnimate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Flow canvas overlay ──
|
function renderTrafficCrossfade() {
|
||||||
let _trafficFlowLayer = null;
|
const c = _ensureFlowCanvas();
|
||||||
let _trafficFlowAnimId = null;
|
if (!c || !SA.map || !_trafficData) return;
|
||||||
let _trafficCanvasCtx = null;
|
const ctx = _trafficCtx;
|
||||||
|
|
||||||
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;
|
|
||||||
if (!ctx) return;
|
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);
|
ctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
|
||||||
// Draw bezier curves THEN particles
|
const wins = _trafficData.windows || [];
|
||||||
activeEdges.slice(0, 300).forEach(e => {
|
if (!wins.length) return;
|
||||||
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;
|
|
||||||
|
|
||||||
// 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.beginPath();
|
||||||
ctx.moveTo(p1.x, p1.y);
|
ctx.moveTo(cached.p1x, cached.p1y);
|
||||||
ctx.quadraticCurveTo(midX, midY, p2.x, p2.y);
|
ctx.quadraticCurveTo(cached.midX, cached.midY, cached.p2x, cached.p2y);
|
||||||
ctx.strokeStyle = `rgba(67,97,238,${0.4 + 0.5 * ratio})`;
|
ctx.strokeStyle = 'rgba(67,97,238,' + alpha.toFixed(2) + ')';
|
||||||
ctx.lineWidth = 1.5 + 4 * ratio;
|
ctx.lineWidth = 1 + 4 * ratio;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// FLOWING PARTICLES
|
// Particles (continuous, driven by Date.now)
|
||||||
const particleCount = Math.ceil(3 + 5 * ratio);
|
const nParticles = Math.ceil(3 + 5 * ratio);
|
||||||
for (let pi = 0; pi < particleCount; pi++) {
|
for (let pi = 0; pi < nParticles; pi++) {
|
||||||
// Each particle at a different phase along the curve
|
const pt = ((Date.now() / 2000 + pi / nParticles) % 1);
|
||||||
const t = ((Date.now() / 2000 + pi / particleCount) % 1);
|
const px = (1-pt)*(1-pt)*cached.p1x + 2*(1-pt)*pt*cached.midX + pt*pt*cached.p2x;
|
||||||
const px = (1-t)*(1-t)*p1.x + 2*(1-t)*t*midX + t*t*p2.x;
|
const py = (1-pt)*(1-pt)*cached.p1y + 2*(1-pt)*pt*cached.midY + pt*pt*cached.p2y;
|
||||||
const py = (1-t)*(1-t)*p1.y + 2*(1-t)*t*midY + t*t*p2.y;
|
const r = Math.round(67 + 163 * pt), g = Math.round(97 - 40 * pt), b = Math.round(238 - 168 * pt);
|
||||||
|
|
||||||
// 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);
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(px, py, 2.5 + 3 * ratio, 0, Math.PI * 2);
|
ctx.arc(px, py, 2 + 3 * ratio, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = `rgb(${r},${gVal},${bVal})`;
|
ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + (0.5 + 0.5 * e.weight) + ')';
|
||||||
ctx.shadowColor = `rgba(${r},${gVal},${bVal},0.6)`;
|
ctx.shadowColor = 'rgba(' + r + ',' + g + ',' + b + ',0.5)';
|
||||||
ctx.shadowBlur = 4;
|
ctx.shadowBlur = 4;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.shadowBlur = 0;
|
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) {
|
if (SA.pointLayer && !SA._clusterFocus) {
|
||||||
SA.pointLayer.getLayers().forEach(l => {
|
SA.pointLayer.getLayers().forEach(l => {
|
||||||
if (!l.setStyle) return;
|
if (!l.setStyle) return;
|
||||||
try {
|
try {
|
||||||
const content = l.getTooltip()?.getContent?.() || '';
|
const ct = l.getTooltip()?.getContent?.() || '';
|
||||||
const match = content.match(/\b(\d+\.\d+\.\d+\.\d+)\b/);
|
const m = ct.match(/\b(\d+\.\d+\.\d+\.\d+)\b/);
|
||||||
if (match) {
|
if (m) l.setStyle(activeNodes.has(m[1])
|
||||||
l.setStyle(activeNodes.has(match[1])
|
? {fillOpacity:0.95,opacity:1,weight:3,color:'#fff'}
|
||||||
? {fillOpacity: 0.95, opacity: 1, weight: 3, color: '#fff'}
|
: {fillOpacity:0.2,opacity:0.3,weight:1,color:'#999'});
|
||||||
: {fillOpacity: 0.2, opacity: 0.3, weight: 1, color: '#999'});
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user