fix: 流量动画Canvas贝塞尔曲线渲染修复

根因: Canvas不在map容器内,缺少position:relative;z-index不够高
修复:
- _ensureTrafficCanvas动态创建canvas为#saMap子元素
- 添加position:relative到#saMap容器
- z-index提升到99999确保在Leaflet画布之上
- requestAnimationFrame替代setTimeout实现平滑粒子动画
- 面板关闭/trafficStop时清理canvas和恢复节点样式
- 粒子位置用Date.now()计算实现连续运动

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TianXuan Developer
2026-07-24 23:01:17 +08:00
parent b02258330b
commit f17ba2255a
@@ -79,7 +79,8 @@
/* ── Map layout ──────────────────────────────────── */
.sa-map-row { display: flex; gap: 1rem; margin: 1rem 0; }
.sa-map-container { flex: 1; height: 550px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; }
.sa-map-container { flex: 1; height: 550px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; position: relative; }
#saMap { position: relative; }
.sa-legend { width: 240px; max-height: 550px; overflow-y: auto; padding: 0.5rem; background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; }
.sa-legend-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; cursor: pointer; font-size: 0.85rem; }
.sa-legend-item .color-dot { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; }
@@ -2584,6 +2585,7 @@ function toggleTrafficPanel() {
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');
loadTrafficData();
@@ -2648,8 +2650,9 @@ function trafficTogglePlay() {
function trafficStart() {
if (!_trafficData || _trafficPlaying) return;
_trafficPlaying = true;
_trafficLastFrameTime = 0;
document.getElementById('saTrafficPlayBtn').textContent = '⏸ 暂停';
trafficAnimate();
_trafficAnimId = requestAnimationFrame(trafficAnimate);
}
function trafficPause() {
@@ -2662,10 +2665,18 @@ function trafficStop() {
trafficPause();
_trafficData = null;
_trafficWindowIdx = 0;
const c = document.getElementById('saTrafficCanvas');
if (c && _trafficCanvasCtx) _trafficCanvasCtx.clearRect(0, 0, c.width, c.height);
_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) {}
}
});
}
}
function trafficSetSpeed(speed, btn) {
@@ -2674,16 +2685,53 @@ function trafficSetSpeed(speed, btn) {
if (btn) btn.classList.add('active');
}
function trafficAnimate() {
let _trafficLastFrameTime = 0;
let _trafficFrameDuration = 1000;
function trafficAnimate(timestamp) {
if (!_trafficPlaying || !_trafficData) return;
renderTrafficFrame(_trafficData.windows[_trafficWindowIdx]);
const delay = _trafficData.window_seconds * 1000 / _trafficSpeed;
_trafficAnimId = setTimeout(() => {
_trafficWindowIdx++;
if (_trafficWindowIdx >= _trafficData.n_windows) _trafficWindowIdx = 0;
if (!_trafficLastFrameTime) _trafficLastFrameTime = timestamp;
const elapsed = timestamp - _trafficLastFrameTime;
const frameLen = Math.max(50, _trafficData.window_seconds * 1000 / _trafficSpeed);
if (elapsed >= frameLen) {
_trafficLastFrameTime = timestamp;
_trafficWindowIdx = (_trafficWindowIdx + 1) % _trafficData.n_windows;
document.getElementById('saTrafficSlider').value = _trafficWindowIdx;
trafficAnimate();
}, Math.max(100, Math.min(delay, 2000)));
}
// Always re-render for smooth particle animation
const w = _trafficData.windows[_trafficWindowIdx];
if (w) renderTrafficFrame(w);
_trafficAnimId = requestAnimationFrame(trafficAnimate);
}
function _ensureTrafficCanvas() {
const container = document.getElementById('saMap');
if (!container) 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;';
container.appendChild(c);
}
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';
}
return c;
}
function _removeTrafficCanvas() {
const c = document.getElementById('saTrafficCanvas');
if (c) { c.remove(); _trafficCanvasCtx = null; }
}
function renderTrafficFrame(w) {
@@ -2692,18 +2740,9 @@ function renderTrafficFrame(w) {
document.getElementById('saTrafficInfo').textContent =
`${w.edges.length} 条活跃边 · ${w.active_nodes.length} 活跃节点`;
// Setup canvas
const container = document.getElementById('saMap');
const c = document.getElementById('saTrafficCanvas');
if (!c || !container) return;
const c = _ensureTrafficCanvas();
if (!c) return;
c.style.display = 'block';
const rect = container.getBoundingClientRect();
c.width = rect.width;
c.height = rect.height;
c.style.width = rect.width + 'px';
c.style.height = rect.height + 'px';
c.style.left = rect.left + 'px';
c.style.top = rect.top + 'px';
_trafficCanvasCtx = c.getContext('2d');
_trafficCanvasCtx.clearRect(0, 0, c.width, c.height);
@@ -2717,12 +2756,12 @@ function renderTrafficFrame(w) {
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.2 + 0.6 * (e.count / maxCount);
const width = 1 + 3 * (e.count / maxCount);
const alpha = 0.25 + 0.55 * (e.count / maxCount);
const width = 1.5 + 3 * (e.count / maxCount);
// Draw curved edge
// Curved edge
const midX = (p1.x + p2.x) / 2;
const midY = (p1.y + p2.y) / 2 - Math.abs(p2.x - p1.x) * 0.15;
const midY = (p1.y + p2.y) / 2 - Math.abs(p2.x - p1.x) * 0.12;
_trafficCanvasCtx.beginPath();
_trafficCanvasCtx.moveTo(p1.x, p1.y);
_trafficCanvasCtx.quadraticCurveTo(midX, midY, p2.x, p2.y);
@@ -2730,28 +2769,33 @@ function renderTrafficFrame(w) {
_trafficCanvasCtx.lineWidth = width;
_trafficCanvasCtx.stroke();
// Draw flow particle
const phase = (_trafficWindowIdx % 10) / 10;
const t = 0.15 + phase * 0.7;
// 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 + 2*(e.count/maxCount), 0, Math.PI*2);
_trafficCanvasCtx.arc(px, py, 2.5 + 2.5*(e.count/maxCount), 0, Math.PI*2);
_trafficCanvasCtx.fillStyle = '#e63946';
_trafficCanvasCtx.fill();
});
// Highlight active nodes
// Dim inactive nodes
const activeSet = new Set(w.active_nodes || []);
if (SA.pointLayer && !SA._clusterFocus) {
SA.pointLayer.getLayers().forEach(l => {
const ip = l._tooltip?.getContent?.()?.match(/\b(\d+\.\d+\.\d+\.\d+)\b/)?.[1];
if (ip && l.setStyle) {
if (activeSet.has(ip)) {
l.setStyle({fillOpacity: 0.95, opacity: 1, weight: 3, color: '#fff'});
} else {
l.setStyle({fillOpacity: 0.25, opacity: 0.35, weight: 1, color: '#ddd'});
}
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'});
}
}
} catch(e) {}
}
});
}
@@ -2869,6 +2913,5 @@ function escapeHtml(str) {
<span class="sa-traffic-time" id="saTrafficTime">--:--:--</span>
</div>
<div class="sa-traffic-info" id="saTrafficInfo"></div>
<canvas id="saTrafficCanvas" class="sa-traffic-canvas" style="display:none;"></canvas>
</div>
{% endblock %}