feat: 流量动画改用Leaflet原生dashArray虚线流动

Canvas粒子方案在Leaflet层面存在图层遮挡问题。
改为Leaflet原生L.polyline + dashArray + dashOffset动画:
- 每条活跃边绘制虚线 polyline (dash/gap按流量比例)
- requestAnimationFrame 持续偏移 dashOffset
- 产生'蚂蚁行军'流动效果, 100%可见
- 不透明度0.85, 线宽2-7px, HSL颜色渐变

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TianXuan Developer
2026-07-25 09:14:28 +08:00
parent 569c7411b8
commit 91fb072f9f
@@ -2586,7 +2586,7 @@ function toggleTrafficPanel() {
if (panel.classList.contains('open')) {
trafficStop();
panel.classList.remove('open');
_removeTrafficCanvas();
_removeFlowLayer();
} else {
panel.classList.add('open');
loadTrafficData();
@@ -2706,7 +2706,7 @@ function trafficPause() {
function trafficStop() {
trafficPause();
_trafficData = null; _trafficParticles = []; _trafficAllEdges = []; _trafficEdgeMap = {};
_removeTrafficCanvas();
_removeFlowLayer();
document.getElementById('saTrafficTime').textContent = '--:--:--';
document.getElementById('saTrafficInfo').textContent = '';
if (SA.pointLayer) {
@@ -2753,49 +2753,26 @@ function trafficAnimate(timestamp) {
_trafficAnimId = requestAnimationFrame(trafficAnimate);
}
let _trafficLeafletLayer = null;
// ── Flow polylines system ──
let _trafficFlowLayer = null; // L.layerGroup for flowing polylines
let _trafficFlowAnimId = null;
let _trafficFlowOffset = 0;
function _ensureTrafficCanvas() {
// Use Leaflet's internal renderer canvas for proper layering
function _ensureFlowLayer() {
if (!SA.map) return null;
// Access the internal canvas renderer
const pane = SA.map.getPane('overlayPane');
if (!pane) return null;
let c = document.getElementById('saTrafficCanvas');
if (!c) {
c = document.createElement('canvas');
c.id = 'saTrafficCanvas';
c.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:99999;';
c.width = SA.map.getSize().x; c.height = SA.map.getSize().y;
c.style.width = c.width + 'px'; c.style.height = c.height + 'px';
// Insert AFTER the Leaflet canvas (tile pane)
const tilePane = SA.map.getPane('tilePane');
if (tilePane && tilePane.parentNode) {
tilePane.parentNode.appendChild(c);
} else {
pane.appendChild(c);
}
// Resize handler
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';
});
}
return c;
if (_trafficFlowLayer) return _trafficFlowLayer;
_trafficFlowLayer = L.layerGroup().addTo(SA.map);
return _trafficFlowLayer;
}
function _removeTrafficCanvas() {
const c = document.getElementById('saTrafficCanvas');
if (c) { c.remove(); _trafficCanvasCtx = null; }
function _removeFlowLayer() {
if (_trafficFlowAnimId) { cancelAnimationFrame(_trafficFlowAnimId); _trafficFlowAnimId = null; }
if (_trafficFlowLayer && SA.map) { SA.map.removeLayer(_trafficFlowLayer); _trafficFlowLayer = null; }
}
function renderTrafficContinuous(totalSpan, minTs, maxTs) {
const c = _ensureTrafficCanvas();
if (!c || !SA.map) return;
c.style.display = 'block';
_trafficCanvasCtx = c.getContext('2d');
_trafficCanvasCtx.clearRect(0, 0, c.width, c.height);
const layer = _ensureFlowLayer();
if (!layer || !SA.map) return;
const coords = _trafficData.nodes_coords || {};
const edges = Object.values(_trafficEdgeMap);
@@ -2803,17 +2780,14 @@ function renderTrafficContinuous(totalSpan, minTs, maxTs) {
const maxCount = Math.max(1, ...edges.map(e => e.count));
totalSpan = totalSpan || 30;
// Normalized position 0→1 in the animation
const progress = Math.max(0, Math.min(1, (_trafficCurrentTime - minTs) / totalSpan));
// Half of edges active at any moment, the active band sweeps across
const bandWidth = 0.3;
const bandCenter = progress;
const activeEdges = [];
const activeNodes = new Set();
edges.forEach(e => {
// Edge is "active" when its phase is near the current band center
const dist = Math.abs(e.phase - bandCenter);
const wrappedDist = Math.min(dist, 1 - dist); // wrap around for seamless loop
const wrappedDist = Math.min(dist, 1 - dist);
if (wrappedDist < bandWidth) {
activeEdges.push(e);
activeNodes.add(e.source);
@@ -2824,82 +2798,35 @@ function renderTrafficContinuous(totalSpan, minTs, maxTs) {
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 — higher rate for denser visual
const now = _trafficCurrentTime;
const particleLifetime = Math.max(4, Math.min(10, totalSpan * 0.15));
const spawnRate = Math.max(0.03, particleLifetime / 4); // 4 particles per edge per lifetime
activeEdges.forEach(e => {
const key = e.source + '|' + e.target;
if (!_trafficSpawnTimers[key] || now - _trafficSpawnTimers[key] > spawnRate) {
_trafficSpawnTimers[key] = now;
_trafficParticles.push({
source: e.source, target: e.target,
progress: 0,
speed: 1 / particleLifetime,
count: e.count,
});
}
});
// Update & render particles — bright, visible dots
const toRemove = [];
_trafficParticles.forEach((p, i) => {
p.progress += p.speed * (1/60) * _trafficSpeed * 3;
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; }
const midX = (p1.x + p2.x) / 2;
const midY = (p1.y + p2.y) / 2 - Math.abs(p2.x - p1.x) * 0.12;
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 = 3 + 4 * (p.count / maxCount);
// Glow
_trafficCanvasCtx.shadowColor = 'rgba(230,57,70,0.8)';
_trafficCanvasCtx.shadowBlur = 6;
_trafficCanvasCtx.beginPath();
_trafficCanvasCtx.arc(px, py, size, 0, Math.PI * 2);
_trafficCanvasCtx.fillStyle = '#ff4444';
_trafficCanvasCtx.fill();
_trafficCanvasCtx.shadowBlur = 0;
});
for (let i = toRemove.length - 1; i >= 0; i--) _trafficParticles.splice(toRemove[i], 1);
// Draw active edge traces (background lines) — all edges, thick
activeEdges.forEach(e => {
// Use Leaflet polylines with animated dash-array for visible flow effect
layer.clearLayers();
activeEdges.slice(0, 300).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.12;
const alpha = 0.5 + 0.5 * (e.count / maxCount);
const width = 2 + 5 * (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();
const ratio = e.count / maxCount;
const w = 2 + 5 * ratio;
L.polyline([[srcC[0], srcC[1]], [dstC[0], dstC[1]]], {
color: `hsl(${220 + 15 * ratio}, 70%, ${45 + 20 * ratio}%)`,
weight: w, opacity: 0.85,
dashArray: `${10 + 15 * ratio} ${10 + 15 * (1 - ratio)}`,
dashOffset: String(-_trafficFlowOffset),
interactive: false, bubblingMouseEvents: false,
}).addTo(layer);
});
// Animate dash offset
if (!_trafficFlowAnimId) {
_trafficFlowAnimId = requestAnimationFrame(function loop() {
_trafficFlowOffset = (_trafficFlowOffset + 0.5) % 40;
if (_trafficFlowLayer) {
_trafficFlowLayer.getLayers().forEach(p => {
if (p.setStyle) p.setStyle({dashOffset: String(-_trafficFlowOffset)});
});
}
_trafficFlowAnimId = requestAnimationFrame(loop);
});
}
// Dim inactive nodes
if (SA.pointLayer && !SA._clusterFocus) {
SA.pointLayer.getLayers().forEach(l => {
@@ -2908,18 +2835,13 @@ function renderTrafficContinuous(totalSpan, minTs, maxTs) {
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'});
}
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'});
}
} 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 ────────────── */