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:
@@ -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 ────────────── */
|
||||
|
||||
Reference in New Issue
Block a user