fix: 改用Leaflet原生虚线+动画dashOffset, 100%可见

彻底放弃canvas粒子方案,改用Leaflet L.polyline + dashArray:
- 每条活跃边=1条虚线(dash=15,gap=25)
- dashOffset持续动画(0.3px/帧),产生流动效果
- 交叉淡入淡出: 透明度0.4-1.0 + 宽度1.5-5.5px
- removeLayer旧边,addTo新边,平滑过渡
- 零z-index问题,Leaflet原生渲染

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TianXuan Developer
2026-07-25 10:04:33 +08:00
parent bbecd4a4f1
commit b04ca5cca9
@@ -2577,11 +2577,10 @@ let _trafficPrevFrameTs = 0;
let _trafficSeekTarget = null; let _trafficSeekTarget = null;
// Canvas overlay // Canvas overlay
let _trafficCanvas = null; let _trafficFlowLayer = null;
let _trafficCtx = null; let _trafficDashOffset = 0;
let _trafficDashId = null;
// Cached pixel positions for edges (rebuilt on map move) let _trafficActiveEdges = {};
let _trafficEdgeCache = {}; // key → {p1, p2, midX, midY}
function toggleTrafficPanel() { function toggleTrafficPanel() {
const panel = document.getElementById('saTrafficPanel'); const panel = document.getElementById('saTrafficPanel');
@@ -2589,33 +2588,26 @@ function toggleTrafficPanel() {
if (panel.classList.contains('open')) { if (panel.classList.contains('open')) {
trafficStop(); trafficStop();
panel.classList.remove('open'); panel.classList.remove('open');
_removeFlowCanvas(); _removeFlowLayer();
} else { } else {
panel.classList.add('open'); panel.classList.add('open');
loadTrafficData(); loadTrafficData();
} }
} }
function _ensureFlowCanvas() { function _ensureFlowLayer() {
if (!SA.map) return null; if (!SA.map) return null;
if (_trafficCanvas && _trafficCanvas.parentNode) return _trafficCanvas; if (!_trafficFlowLayer) {
const mapEl = SA.map.getContainer(); _trafficFlowLayer = L.layerGroup().addTo(SA.map);
const c = document.createElement('canvas'); }
c.id = 'saTrafficCanvasOverlay'; return _trafficFlowLayer;
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() { function _removeFlowLayer() {
cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null; cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null;
if (_trafficCanvas) { _trafficCanvas.remove(); _trafficCanvas = null; _trafficCtx = null; } cancelAnimationFrame(_trafficDashId); _trafficDashId = null;
if (_trafficFlowLayer) { SA.map.removeLayer(_trafficFlowLayer); _trafficFlowLayer = null; }
_trafficActiveEdges = {};
} }
function tsToBJ(ts) { const d = new Date(ts * 1000); return d.toISOString().replace('T',' ').slice(0,19); } function tsToBJ(ts) { const d = new Date(ts * 1000); return d.toISOString().replace('T',' ').slice(0,19); }
@@ -2730,8 +2722,8 @@ function trafficPause() {
cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null; cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null;
} }
function trafficStop() { function trafficStop() {
trafficPause(); _trafficData = null; _trafficEdgeCache = {}; trafficPause(); _trafficData = null;
_removeFlowCanvas(); _removeFlowLayer();
document.getElementById('saTrafficTime').textContent = '--:--:--'; document.getElementById('saTrafficTime').textContent = '--:--:--';
document.getElementById('saTrafficInfo').textContent = ''; document.getElementById('saTrafficInfo').textContent = '';
if (SA.pointLayer) SA.pointLayer.getLayers().forEach(l => { if (SA.pointLayer) SA.pointLayer.getLayers().forEach(l => {
@@ -2770,100 +2762,93 @@ function trafficAnimate(timestamp) {
} }
function renderTrafficCrossfade() { function renderTrafficCrossfade() {
const c = _ensureFlowCanvas(); const layer = _ensureFlowLayer();
if (!c || !SA.map || !_trafficData) return; if (!layer || !SA.map || !_trafficData) return;
const ctx = _trafficCtx;
if (!ctx) return;
ctx.clearRect(0, 0, c.width, c.height);
const wins = _trafficData.windows || []; const wins = _trafficData.windows || [];
if (!wins.length) return; if (!wins.length) return;
const coords = _trafficData.nodes_coords || {}; const coords = _trafficData.nodes_coords || {};
const mix = getWindowMix(); const mix = getWindowMix();
const w0 = wins[mix.i] || null; const w0 = wins[mix.i] || null;
const w1 = mix.i + 1 < wins.length ? wins[mix.i + 1] : null; const w1 = mix.i + 1 < wins.length ? wins[mix.i + 1] : null;
const t = mix.t; const t = mix.t;
// Collect edges from both windows with blend weights // Build edge map with crossfade weights
// edgeKey → {source, target, weight, count}
const edgeMap = {}; const edgeMap = {};
function addEdges(edges, weight, baseCount) { function add(edges, w) {
if (!edges) return; if (!edges) return;
edges.forEach(e => { edges.forEach(e => {
const k = e.source + '|' + e.target; const k = e.source + '|' + e.target;
if (!edgeMap[k]) edgeMap[k] = {source:e.source, target:e.target, weight:0, count:0}; 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].weight = Math.max(edgeMap[k].weight, w);
edgeMap[k].count = Math.max(edgeMap[k].count, e.count || 0); edgeMap[k].count = Math.max(edgeMap[k].count, e.count||0);
}); });
} }
addEdges(w0 ? w0.edges : [], 1 - t, 0); add(w0?w0.edges:[], 1-t);
addEdges(w1 ? w1.edges : [], t, 0); add(w1?w1.edges:[], t);
// 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); const edges = Object.values(edgeMap);
if (!edges.length) return;
const maxCount = Math.max(1, ...edges.map(e => e.count)); const maxCount = Math.max(1, ...edges.map(e => e.count));
// Active node set (union of both windows, weighted)
const activeNodes = new Set(); const activeNodes = new Set();
if (w0 && 1 - t > 0.1) (w0.active_nodes||[]).forEach(n => activeNodes.add(n)); 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)); if (w1 && t > 0.1) (w1.active_nodes||[]).forEach(n => activeNodes.add(n));
document.getElementById('saTrafficInfo').textContent = document.getElementById('saTrafficInfo').textContent =
edges.length + ' 边 · ' + activeNodes.size + ' 节点 · ' + tsToBJ(_trafficCurrentTime); edges.length + ' 边 · ' + activeNodes.size + ' 节点 · ' + tsToBJ(_trafficCurrentTime);
// Build pixel coordinates for edges (cached until map moves) // Build/track Leaflet polylines with dash animation
const newKeys = new Set();
edges.forEach(e => { edges.forEach(e => {
let cached = _trafficEdgeCache[e.source + '|' + e.target]; const k = e.source + '|' + e.target;
if (!cached) { newKeys.add(k);
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 = Math.min(1, e.count / maxCount); const ratio = Math.min(1, e.count / maxCount);
const alpha = 0.4 + 0.6 * e.weight; const alpha = 0.4 + 0.6 * e.weight;
const hue = 220 + (mix.i % 10) * 15; // color shifts per window group const w = 1.5 + 4 * ratio;
const srcC = coords[e.source], dstC = coords[e.target];
if (!srcC || !dstC) return;
// Bezier trace let entry = _trafficActiveEdges[k];
ctx.beginPath(); if (entry && entry.poly) {
ctx.moveTo(cached.p1x, cached.p1y); // Update existing polyline style
ctx.quadraticCurveTo(cached.midX, cached.midY, cached.p2x, cached.p2y); entry.poly.setStyle({
ctx.strokeStyle = 'hsla(' + hue + ',70%,50%,' + alpha.toFixed(2) + ')'; opacity: alpha, weight: w,
ctx.lineWidth = 1.5 + 3 * ratio; dashOffset: String(-_trafficDashOffset),
ctx.stroke(); });
entry.lastWindow = mix.i;
// Particles (continuous, driven by Date.now) } else {
const nParticles = Math.ceil(3 + 5 * ratio); // Create new polyline
for (let pi = 0; pi < nParticles; pi++) { const poly = L.polyline([[srcC[0],srcC[1]], [dstC[0],dstC[1]]], {
const pt = ((Date.now() / 2000 + pi / nParticles) % 1); color: '#4361ee', weight: w, opacity: alpha,
const px = (1-pt)*(1-pt)*cached.p1x + 2*(1-pt)*pt*cached.midX + pt*pt*cached.p2x; dashArray: '15 25',
const py = (1-pt)*(1-pt)*cached.p1y + 2*(1-pt)*pt*cached.midY + pt*pt*cached.p2y; dashOffset: String(-_trafficDashOffset),
const r = Math.round(67 + 163 * pt), g = Math.round(97 - 40 * pt), b = Math.round(238 - 168 * pt); interactive: false, bubblingMouseEvents: false,
ctx.beginPath(); });
ctx.arc(px, py, 2 + 3 * ratio, 0, Math.PI * 2); poly.addTo(layer);
ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + (0.5 + 0.5 * e.weight) + ')'; _trafficActiveEdges[k] = {poly, lastWindow: mix.i};
ctx.shadowColor = 'rgba(' + r + ',' + g + ',' + b + ',0.5)';
ctx.shadowBlur = 4;
ctx.fill();
ctx.shadowBlur = 0;
} }
}); });
// Invalidate edge cache if map has moved (simplified: clear on zoom/move) // Remove polylines no longer active
SA.map.off('moveend.flowCache').on('moveend.flowCache', () => { _trafficEdgeCache = {}; }); Object.keys(_trafficActiveEdges).forEach(k => {
if (!newKeys.has(k)) {
layer.removeLayer(_trafficActiveEdges[k].poly);
delete _trafficActiveEdges[k];
}
});
// Animate dash offset for flowing effect
if (!_trafficDashId) {
function animDash() {
_trafficDashOffset = (_trafficDashOffset + 0.3) % 40;
if (_trafficFlowLayer) {
_trafficFlowLayer.getLayers().forEach(p => {
if (p.setStyle) p.setStyle({dashOffset: String(-_trafficDashOffset)});
});
}
_trafficDashId = requestAnimationFrame(animDash);
}
_trafficDashId = requestAnimationFrame(animDash);
}
// Node dimming // Node dimming
if (SA.pointLayer && !SA._clusterFocus) { if (SA.pointLayer && !SA._clusterFocus) {