feat: crossfade窗口过渡,末尾20%淡出旧边淡入新边

getWindowMix: 返回crossfade(0→1), window末尾20%线性过渡
renderTrafficCrossfade: 两窗口边集按(1-cf)/cf透明度混合
- 同边去重(取max alpha)避免闪烁
- 粒子透明度跟随窗口混合
- 节点活跃=两窗口并集

验证: cf=0→20条(单窗口), cf=0.45→40条(双窗口混合)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TianXuan Developer
2026-07-25 15:35:47 +08:00
parent e2349f951d
commit 490f37d8fc
@@ -2679,21 +2679,26 @@ function loadTrafficData() {
function reloadTrafficData() { trafficStop(); loadTrafficData(); }
// ── getWindowMix: find window index + crossfade factor ──
// crossfade 0→1 over last 20% of window[i], edges from both windows blend
function getWindowMix() {
if (!_trafficData) return {i:0, t:0};
if (!_trafficData) return {i:0, crossfade:0};
const wins = _trafficData.windows || [];
if (!wins.length) return {i:0, t:0};
if (!wins.length) return {i:0, crossfade: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?
// How far into this window are we? (0..1)
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};
let progress = 0;
if (ws > 0) progress = Math.max(0, Math.min(1, (_trafficCurrentTime - wTs) / ws));
// Crossfade: last 20% → blend from window[idx] to window[idx+1]
let crossfade = 0;
if (progress > 0.8) {
crossfade = Math.min(1, (progress - 0.8) / 0.2);
}
return {i: idx, crossfade};
}
function trafficSeek(val) {
@@ -2777,27 +2782,33 @@ function renderTrafficCrossfade() {
if (!wins.length) return;
const coords = _trafficData.nodes_coords || {};
const mix = getWindowMix();
const cf = mix.crossfade; // 0=only w0, 1=only w1
const w0 = wins[mix.i] || null;
const w1 = mix.i + 1 < wins.length ? wins[mix.i + 1] : null;
const t = mix.t;
// Each frame: add w0 edges with fade-out, w1 edges with fade-in
// Only show current window edges — clean switch, no blending
// Brief fade-in for newly appearing edges
const curEdges = t < 0.5 ? (w0 ? w0.edges : []) : (w1 ? w1.edges : []);
// Build edge map from both windows with crossfade weights
const edgeMap = {};
curEdges.forEach(e => {
const k = e.source + '|' + e.target;
edgeMap[k] = {source:e.source, target:e.target, weight:1.0, count:e.count||0};
});
function addEdges(edges, windowAlpha) {
if (!edges || windowAlpha <= 0.02) return;
edges.forEach(e => {
const k = e.source + '|' + e.target;
if (!edgeMap[k]) edgeMap[k] = {source:e.source, target:e.target, alpha:0, count:0};
// For edges in both windows: keep max alpha (no flicker)
if (windowAlpha > edgeMap[k].alpha) edgeMap[k].alpha = windowAlpha;
if ((e.count||0) > edgeMap[k].count) edgeMap[k].count = e.count||0;
});
}
addEdges(w0 ? w0.edges : null, 1 - cf); // fading out
addEdges(w1 ? w1.edges : null, cf); // fading in
const edges = Object.values(edgeMap);
if (!edges.length) return;
const maxCount = Math.max(1, ...edges.map(e => e.count));
// Active nodes: union of both windows
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));
if (w0 && 1 - cf > 0.1) (w0.active_nodes||[]).forEach(n => activeNodes.add(n));
if (w1 && cf > 0.1) (w1.active_nodes||[]).forEach(n => activeNodes.add(n));
document.getElementById('saTrafficInfo').textContent =
edges.length + ' 边 · ' + activeNodes.size + ' 节点 · ' + tsToBJ(_trafficCurrentTime);
@@ -2811,12 +2822,13 @@ function renderTrafficCrossfade() {
const midX = (p1.x + p2.x) / 2;
const midY = (p1.y + p2.y) / 2 - Math.abs(p2.x - p1.x) * 0.1;
const ratio = Math.min(1, e.count / maxCount);
const alpha = 0.6 + 0.4 * ratio;
const baseAlpha = 0.6 + 0.4 * ratio;
const wa = baseAlpha * e.alpha; // window blend
// Edge trace with src→dst color gradient
// Edge trace
const grad = ctx.createLinearGradient(p1.x, p1.y, p2.x, p2.y);
grad.addColorStop(0, 'rgba(46,196,182,' + alpha.toFixed(2) + ')');
grad.addColorStop(1, 'rgba(230,57,70,' + alpha.toFixed(2) + ')');
grad.addColorStop(0, 'rgba(46,196,182,' + wa.toFixed(2) + ')');
grad.addColorStop(1, 'rgba(230,57,70,' + wa.toFixed(2) + ')');
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.quadraticCurveTo(midX, midY, p2.x, p2.y);
@@ -2824,7 +2836,7 @@ function renderTrafficCrossfade() {
ctx.lineWidth = 1.5 + 4 * ratio;
ctx.stroke();
// Flowing particles
// Particles with same window blend
const speedMul = 0.5 + 1.5 * ratio;
const nP = Math.ceil(3 + 6 * ratio);
for (let pi = 0; pi < nP; pi++) {
@@ -2834,8 +2846,8 @@ function renderTrafficCrossfade() {
const cr = Math.round(46 + 184*pt), cg = Math.round(196 - 139*pt), cb = Math.round(182 - 112*pt);
ctx.beginPath();
ctx.arc(px, py, 2 + 3*ratio, 0, Math.PI*2);
ctx.fillStyle = 'rgba(' + cr + ',' + cg + ',' + cb + ',' + (0.6+0.4*e.weight) + ')';
ctx.shadowColor = 'rgba(' + cr + ',' + cg + ',' + cb + ',0.6)';
ctx.fillStyle = 'rgba(' + cr + ',' + cg + ',' + cb + ',' + (0.5+0.5*wa) + ')';
ctx.shadowColor = 'rgba(' + cr + ',' + cg + ',' + cb + ',' + (0.4+0.4*wa) + ')';
ctx.shadowBlur = 5;
ctx.fill();
ctx.shadowBlur = 0;