From 66b35eb207c3696af7f6ee573cfc303199af5b60 Mon Sep 17 00:00:00 2001 From: TianXuan Developer Date: Sat, 25 Jul 2026 09:53:17 +0800 Subject: [PATCH] refactor: smooth crossfade transition for traffic timeline, fix animation freeze, reduce memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完全重写动态流通动画: - Crossfade: 相邻窗口边通过透明度渐变(1-t↔t)平滑过渡 - getWindowMix: 计算当前窗口索引+混合因子t - 边去重: 同一边在两窗口间连续存在不闪烁 - 时间确保持续推进,循环播放 - 边坐标缓存(地图移动时失效),减少latLngToContainerPoint调用 - 粒子基于Date.now()连续运动,不受窗口切换影响 - 节点活跃状态按两窗口并集+权重 - 移除旧的phase band/edgeMap/particle pool/_spawnTimers 验证: 上传→聚类→地图→流量面板→时间推进→实体流量图表,0 JS错误 Co-Authored-By: Claude --- .../simple_analysis/simple_analysis.html | 358 ++++++++---------- 1 file changed, 168 insertions(+), 190 deletions(-) diff --git a/simple_analysis/templates/simple_analysis/simple_analysis.html b/simple_analysis/templates/simple_analysis/simple_analysis.html index 4854f72..79bd816 100644 --- a/simple_analysis/templates/simple_analysis/simple_analysis.html +++ b/simple_analysis/templates/simple_analysis/simple_analysis.html @@ -2567,17 +2567,21 @@ function generateColors(n) { return Array.from({length: n}, (_, i) => palette[i % palette.length]); } -/* ── Traffic Flow Animation (continuous particle system) ── */ -let _trafficData = null; +/* ── Traffic Flow Animation (crossfade window transitions) ── */ +let _trafficData = null; // {windows:[{ts,edges,active_nodes}], nodes_coords, window_seconds, _minTs, _maxTs} let _trafficAnimId = null; let _trafficPlaying = false; let _trafficSpeed = 1; -let _trafficCurrentTime = 0; // current Beijing Unix time (seconds) -let _trafficParticles = []; // [{src, dst, p1, p2, midX, midY, progress, speed, count}] +let _trafficCurrentTime = 0; let _trafficPrevFrameTs = 0; -let _trafficAllEdges = []; // flat array of all edges across all windows -let _trafficEdgeMap = {}; // key "src|dst" → {count, bytes} -let _trafficSpawnTimers = {}; // key → next spawn time +let _trafficSeekTarget = null; + +// Canvas overlay +let _trafficCanvas = null; +let _trafficCtx = null; + +// Cached pixel positions for edges (rebuilt on map move) +let _trafficEdgeCache = {}; // key → {p1, p2, midX, midY} function toggleTrafficPanel() { const panel = document.getElementById('saTrafficPanel'); @@ -2585,13 +2589,37 @@ function toggleTrafficPanel() { if (panel.classList.contains('open')) { trafficStop(); panel.classList.remove('open'); - _removeFlowLayer(); + _removeFlowCanvas(); } else { panel.classList.add('open'); loadTrafficData(); } } +function _ensureFlowCanvas() { + if (!SA.map) return null; + if (_trafficCanvas && _trafficCanvas.parentNode) return _trafficCanvas; + const mapEl = SA.map.getContainer(); + const c = document.createElement('canvas'); + c.id = 'saTrafficCanvasOverlay'; + 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() { + cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null; + if (_trafficCanvas) { _trafficCanvas.remove(); _trafficCanvas = null; _trafficCtx = null; } +} + +function tsToBJ(ts) { const d = new Date(ts * 1000); return d.toISOString().replace('T',' ').slice(0,19); } + function loadTrafficData() { if (!SA.sessionId) { showError('请先完成聚类分析'); return; } showLoading('加载流通数据...'); @@ -2605,116 +2633,83 @@ function loadTrafficData() { hideLoading(); if (d.error) { showError(d.error); return; } _trafficData = d; - _trafficParticles = []; - _trafficSpawnTimers = {}; - - // Flatten all edges and build edge map - const allEdges = []; - (d.windows || []).forEach(wi => { - (wi.edges || []).forEach(e => { - const key = e.source + '|' + e.target; - if (!_trafficEdgeMap[key]) { - const obj = {source: e.source, target: e.target, count: 0, bytes: 0, phase: 0}; - _trafficEdgeMap[key] = obj; - allEdges.push(obj); - } - _trafficEdgeMap[key].count += e.count; - _trafficEdgeMap[key].bytes += e.bytes; - }); - }); - - // Distribute edges across time phases (0.0→1.0) so they don't all fire at once - allEdges.sort((a,b) => b.bytes - a.bytes); - allEdges.forEach((e, i) => { e.phase = i / Math.max(1, allEdges.length - 1); }); - - // Force a meaningful animation span (at least 60 fake-seconds) + _trafficEdgeCache = {}; + let ws = d.window_seconds || 10; let minTs = d.min_ts || (d.windows[0]?.ts || 0); - let maxTs = d.max_ts || (d.windows[d.n_windows-1]?.ts_end || minTs + 1); - const realSpan = maxTs - minTs; - if (realSpan < 10) { - maxTs = minTs + 60; // artificial 60s span for sparse data - d._artificialSpan = true; - } - d._minTs = minTs; - d._maxTs = maxTs; - + let maxTs = d.max_ts || (d.windows[d.n_windows-1]?.ts_end || minTs + ws); + if (maxTs - minTs < 10) maxTs = minTs + Math.max(60, ws * 6); + d._minTs = minTs; d._maxTs = maxTs; d._ws = ws; _trafficCurrentTime = minTs; - - const slider = document.getElementById('saTrafficSlider'); - slider.min = 0; slider.max = 1000; slider.value = 0; - + document.getElementById('saTrafficSlider').min = 0; + document.getElementById('saTrafficSlider').max = 1000; + document.getElementById('saTrafficSlider').value = 0; document.getElementById('saTrafficInfo').textContent = - `${Object.keys(_trafficEdgeMap).length} 条边 · ${Object.keys(d.nodes_coords||{}).length} 节点`; + (d.windows||[]).length + ' 窗口 · ' + Object.keys(d.nodes_coords||{}).length + ' 节点'; document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime); trafficStart(); }) .catch(err => { hideLoading(); showError('加载失败: ' + err.message); }); } -function tsToBJ(ts) { - const d = new Date(ts * 1000); - return d.toISOString().replace('T',' ').slice(0,19); -} - function reloadTrafficData() { trafficStop(); loadTrafficData(); } -let _trafficSeekTarget = null; +// ── getWindowMix: find window index + crossfade factor ── +function getWindowMix() { + if (!_trafficData) return {i:0, t:0}; + const wins = _trafficData.windows || []; + if (!wins.length) return {i:0, t: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? + 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}; +} + function trafficSeek(val) { if (!_trafficData) return; - const minTs = _trafficData._minTs; - const maxTs = _trafficData._maxTs; + const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs; _trafficSeekTarget = minTs + (val / 1000) * (maxTs - minTs); - _trafficParticles = []; - _trafficSpawnTimers = {}; } function trafficStepForward() { if (!_trafficData) return; - const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs; - const span = Math.max(1, maxTs - minTs); - _trafficCurrentTime = Math.min(maxTs, _trafficCurrentTime + span * 0.02); - document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / span) * 1000; - document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime); + const ws = _trafficData._ws; + _trafficSeekTarget = Math.min(_trafficData._maxTs, _trafficCurrentTime + ws); } function trafficStepBack() { if (!_trafficData) return; - const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs; - const span = Math.max(1, maxTs - minTs); - _trafficCurrentTime = Math.max(minTs, _trafficCurrentTime - span * 0.02); - document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / span) * 1000; - document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime); + const ws = _trafficData._ws; + _trafficSeekTarget = Math.max(_trafficData._minTs, _trafficCurrentTime - ws); } function trafficTogglePlay() { if (_trafficPlaying) trafficPause(); else trafficStart(); } - function trafficStart() { if (!_trafficData || _trafficPlaying) return; - _trafficPlaying = true; - _trafficPrevFrameTs = 0; + _trafficPlaying = true; _trafficPrevFrameTs = 0; document.getElementById('saTrafficPlayBtn').textContent = '⏸ 暂停'; _trafficAnimId = requestAnimationFrame(trafficAnimate); } - function trafficPause() { _trafficPlaying = false; document.getElementById('saTrafficPlayBtn').textContent = '▶ 播放'; - if (_trafficAnimId) { cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null; } + cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null; } - function trafficStop() { - trafficPause(); - _trafficData = null; _trafficParticles = []; _trafficAllEdges = []; _trafficEdgeMap = {}; - _removeFlowLayer(); + trafficPause(); _trafficData = null; _trafficEdgeCache = {}; + _removeFlowCanvas(); document.getElementById('saTrafficTime').textContent = '--:--:--'; document.getElementById('saTrafficInfo').textContent = ''; - 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) {} } - }); - } + 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) { _trafficSpeed = speed; document.querySelectorAll('#saTrafficPanel button[data-speed]').forEach(b => b.classList.remove('active')); @@ -2729,145 +2724,128 @@ function trafficAnimate(timestamp) { _trafficPrevFrameTs = timestamp; const dt = Math.min(rawDt, 0.5) * _trafficSpeed; - const minTs = _trafficData._minTs; - const maxTs = _trafficData._maxTs; - const totalSpan = Math.max(1, maxTs - minTs); + const minTs = _trafficData._minTs, maxTs = _trafficData._maxTs; + const span = Math.max(1, maxTs - minTs); - // If seek is pending, jump to target if (_trafficSeekTarget != null) { - _trafficCurrentTime = _trafficSeekTarget; - _trafficSeekTarget = null; + _trafficCurrentTime = _trafficSeekTarget; _trafficSeekTarget = null; } else { _trafficCurrentTime += dt; - if (_trafficCurrentTime > maxTs) _trafficCurrentTime = minTs + (_trafficCurrentTime - maxTs) % totalSpan; + if (_trafficCurrentTime > maxTs) _trafficCurrentTime = minTs + ((_trafficCurrentTime - maxTs) % span); } - // Update slider - document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / totalSpan) * 1000; + document.getElementById('saTrafficSlider').value = ((_trafficCurrentTime - minTs) / span) * 1000; document.getElementById('saTrafficTime').textContent = tsToBJ(_trafficCurrentTime); - // Render - renderTrafficContinuous(totalSpan, minTs, maxTs); - + renderTrafficCrossfade(); _trafficAnimId = requestAnimationFrame(trafficAnimate); } -// ── Flow canvas overlay ── -let _trafficFlowLayer = null; -let _trafficFlowAnimId = null; -let _trafficCanvasCtx = null; - -function _ensureFlowLayer() { - if (!SA.map) return null; - if (_trafficFlowLayer && _trafficFlowLayer.parentNode) return _trafficFlowLayer; - const mapEl = SA.map.getContainer(); - const c = document.createElement('canvas'); - c.id = 'saTrafficCanvasOverlay'; - c.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:10000;'; - mapEl.appendChild(c); - const size = SA.map.getSize(); - c.width = size.x; c.height = size.y; - c.style.width = size.x + 'px'; c.style.height = size.y + 'px'; - 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'; - }); - _trafficCanvasCtx = c.getContext('2d'); - _trafficFlowLayer = c; - return c; -} - -function _removeFlowLayer() { - if (_trafficFlowAnimId) { cancelAnimationFrame(_trafficFlowAnimId); _trafficFlowAnimId = null; } - if (_trafficFlowLayer) { _trafficFlowLayer.remove(); _trafficFlowLayer = null; _trafficCanvasCtx = null; } -} - -function renderTrafficContinuous(totalSpan, minTs, maxTs) { - const c = _ensureFlowLayer(); - if (!c || !SA.map) return; - - const coords = _trafficData.nodes_coords || {}; - const edges = Object.values(_trafficEdgeMap); - if (!edges.length) return; - const maxCount = Math.max(1, ...edges.map(e => e.count)); - totalSpan = totalSpan || 30; - const ctx = _trafficCanvasCtx; +function renderTrafficCrossfade() { + const c = _ensureFlowCanvas(); + if (!c || !SA.map || !_trafficData) return; + const ctx = _trafficCtx; if (!ctx) return; - - const progress = Math.max(0, Math.min(1, (_trafficCurrentTime - minTs) / totalSpan)); - const bandWidth = 0.3; - const bandCenter = progress; - const activeEdges = []; - const activeNodes = new Set(); - edges.forEach(e => { - const dist = Math.abs(e.phase - bandCenter); - const wrappedDist = Math.min(dist, 1 - dist); - if (wrappedDist < bandWidth) { - activeEdges.push(e); - activeNodes.add(e.source); - activeNodes.add(e.target); - } - }); - - document.getElementById('saTrafficInfo').textContent = - `${activeEdges.length} 活跃边 · ${activeNodes.size} 活跃节点 · ${tsToBJ(_trafficCurrentTime)}`; - ctx.clearRect(0, 0, c.width, c.height); - // Draw bezier curves THEN particles - activeEdges.slice(0, 300).forEach(e => { - const srcC = coords[e.source]; const dstC = coords[e.target]; - if (!srcC || !dstC) return; - const p1 = SA.map.latLngToContainerPoint([srcC[0], srcC[1]]); - const p2 = SA.map.latLngToContainerPoint([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.1; - const ratio = e.count / maxCount; + const wins = _trafficData.windows || []; + if (!wins.length) return; - // Background trace + const coords = _trafficData.nodes_coords || {}; + const mix = getWindowMix(); + const w0 = wins[mix.i] || null; + const w1 = mix.i + 1 < wins.length ? wins[mix.i + 1] : null; + const t = mix.t; + + // Collect edges from both windows with blend weights + // edgeKey → {source, target, weight, count} + const edgeMap = {}; + function addEdges(edges, weight, baseCount) { + if (!edges) return; + edges.forEach(e => { + const k = e.source + '|' + e.target; + 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].count = Math.max(edgeMap[k].count, e.count || 0); + }); + } + addEdges(w0 ? w0.edges : [], 1 - t, 0); + addEdges(w1 ? w1.edges : [], t, 0); + // 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); + if (!edges.length) return; + + const maxCount = Math.max(1, ...edges.map(e => e.count)); + + // Active node set (union of both windows, weighted) + 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)); + + document.getElementById('saTrafficInfo').textContent = + edges.length + ' 边 · ' + activeNodes.size + ' 节点 · ' + tsToBJ(_trafficCurrentTime); + + // Build pixel coordinates for edges (cached until map moves) + edges.forEach(e => { + let cached = _trafficEdgeCache[e.source + '|' + e.target]; + if (!cached) { + 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 = e.count / maxCount; + const alpha = 0.3 + 0.6 * e.weight * ratio; + + // Bezier trace ctx.beginPath(); - ctx.moveTo(p1.x, p1.y); - ctx.quadraticCurveTo(midX, midY, p2.x, p2.y); - ctx.strokeStyle = `rgba(67,97,238,${0.4 + 0.5 * ratio})`; - ctx.lineWidth = 1.5 + 4 * ratio; + ctx.moveTo(cached.p1x, cached.p1y); + ctx.quadraticCurveTo(cached.midX, cached.midY, cached.p2x, cached.p2y); + ctx.strokeStyle = 'rgba(67,97,238,' + alpha.toFixed(2) + ')'; + ctx.lineWidth = 1 + 4 * ratio; ctx.stroke(); - // FLOWING PARTICLES - const particleCount = Math.ceil(3 + 5 * ratio); - for (let pi = 0; pi < particleCount; pi++) { - // Each particle at a different phase along the curve - const t = ((Date.now() / 2000 + pi / particleCount) % 1); - 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; - - // Color changes along the flow - start blue, end red - const r = Math.round(67 + (230 - 67) * t); - const gVal = Math.round(97 + (57 - 97) * t); - const bVal = Math.round(238 + (70 - 238) * t); + // Particles (continuous, driven by Date.now) + const nParticles = Math.ceil(3 + 5 * ratio); + for (let pi = 0; pi < nParticles; pi++) { + const pt = ((Date.now() / 2000 + pi / nParticles) % 1); + const px = (1-pt)*(1-pt)*cached.p1x + 2*(1-pt)*pt*cached.midX + pt*pt*cached.p2x; + const py = (1-pt)*(1-pt)*cached.p1y + 2*(1-pt)*pt*cached.midY + pt*pt*cached.p2y; + const r = Math.round(67 + 163 * pt), g = Math.round(97 - 40 * pt), b = Math.round(238 - 168 * pt); ctx.beginPath(); - ctx.arc(px, py, 2.5 + 3 * ratio, 0, Math.PI * 2); - ctx.fillStyle = `rgb(${r},${gVal},${bVal})`; - ctx.shadowColor = `rgba(${r},${gVal},${bVal},0.6)`; + ctx.arc(px, py, 2 + 3 * ratio, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + (0.5 + 0.5 * e.weight) + ')'; + ctx.shadowColor = 'rgba(' + r + ',' + g + ',' + b + ',0.5)'; ctx.shadowBlur = 4; ctx.fill(); ctx.shadowBlur = 0; } }); - // Dim inactive nodes + // Invalidate edge cache if map has moved (simplified: clear on zoom/move) + SA.map.off('moveend.flowCache').on('moveend.flowCache', () => { _trafficEdgeCache = {}; }); + + // Node dimming if (SA.pointLayer && !SA._clusterFocus) { SA.pointLayer.getLayers().forEach(l => { if (!l.setStyle) return; try { - const content = l.getTooltip()?.getContent?.() || ''; - const match = content.match(/\b(\d+\.\d+\.\d+\.\d+)\b/); - if (match) { - 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'}); - } + const ct = l.getTooltip()?.getContent?.() || ''; + const m = ct.match(/\b(\d+\.\d+\.\d+\.\d+)\b/); + if (m) l.setStyle(activeNodes.has(m[1]) + ? {fillOpacity:0.95,opacity:1,weight:3,color:'#fff'} + : {fillOpacity:0.2,opacity:0.3,weight:1,color:'#999'}); } catch(e) {} }); }