diff --git a/public/js/app.js b/public/js/app.js index 66a8de3..7df6d18 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -137,6 +137,7 @@ let siteThemeQuery = null; // For media query cleanup let siteThemeHandler = null; let backgroundIntervals = []; // To track setIntervals + let lastMapDataHash = ''; // Cache for map rendering optimization // Load sort state from localStorage or use default let currentSort = { column: 'up', direction: 'desc' }; @@ -882,12 +883,46 @@ } }); } - function updateMap2D(servers) { - if (!myMap2D) return; + // Debounced version of the map update to avoid rapid succession calls + // Optimized map update with change detection and performance tuning + const updateMap2D = debounce(function (servers) { + if (!myMap2D || !servers) return; // Shift longitude for Pacific-centered view const shiftLng = (lng) => (lng < -20 ? lng + 360 : lng); + // Create a data fingerprint to avoid redundant renders + // We only care about geographical positions and latency connectivity for the map + const dataFingerprint = JSON.stringify({ + serverCount: servers.length, + latencyCount: currentLatencies.length, + // Sample critical connectivity data (first/last items) + lHash: currentLatencies.length > 0 ? (currentLatencies[0].id + currentLatencies[currentLatencies.length-1].id) : '', + // Include theme to ensure theme toggle still triggers redraw + theme: document.documentElement.classList.contains('light-theme') ? 'light' : 'dark' + }); + + if (dataFingerprint === lastMapDataHash) { + return; + } + lastMapDataHash = dataFingerprint; + + // 1. Pre-build coord lookup for O(1) route lookup + const serverCoords = new Map(); + servers.forEach(s => { + if (s.lat && s.lng) { + const coords = [shiftLng(s.lng), s.lat]; + const city = (s.city || '').toLowerCase().trim(); + const country = (s.country || '').toLowerCase().trim(); + const countryName = (s.countryName || '').toLowerCase().trim(); + + if (city) serverCoords.set(city, coords); + if (country) serverCoords.set(country, coords); + if (countryName) serverCoords.set(countryName, coords); + } + }); + + // 2. Prepare geoData for scatter points const geoData = servers .filter(s => s.lat && s.lng) .map(s => ({ @@ -901,9 +936,10 @@ netTx: s.netTx })); - // Combine all series + // Start with the scatter series const finalSeries = [ { + id: 'nodes-scatter', type: 'scatter', coordinateSystem: 'geo', geoIndex: 0, @@ -914,11 +950,12 @@ shadowColor: 'rgba(6, 182, 212, 0.5)' }, data: geoData, - zlevel: 1 + zlevel: 1, + animation: false // Performance optimization } ]; - // Add latency routes if configured + // 3. Process latency routes with grouping if (currentLatencies && currentLatencies.length > 0) { const countryCoords = { 'china': [116.4074, 39.9042], @@ -950,130 +987,114 @@ 'seoul': [126.9780, 37.5665] }; - const getCoords = (name) => { - const lowerName = (name || '').toLowerCase().trim(); - if (countryCoords[lowerName]) return countryCoords[lowerName]; - - const s = servers.find(sv => - (sv.countryName || '').toLowerCase() === lowerName || - (sv.country || '').toLowerCase() === lowerName || - (sv.city || '').toLowerCase() === lowerName - ); - if (s && s.lng && s.lat) return [shiftLng(s.lng), s.lat]; - return null; - }; - const getShiftedCoords = (name) => { - const c = countryCoords[(name || '').toLowerCase().trim()]; - if (c) return [shiftLng(c[0]), c[1]]; - const raw = getCoords(name); - if (raw) return [shiftLng(raw[0]), raw[1]]; + const lower = (name || '').toLowerCase().trim(); + if (countryCoords[lower]) return [shiftLng(countryCoords[lower][0]), countryCoords[lower][1]]; + const sCoord = serverCoords.get(lower); + if (sCoord) return sCoord; return null; }; - // Group latency routes by path to handle overlap visually const routeGroups = {}; currentLatencies.forEach(route => { const start = getShiftedCoords(route.source); const end = getShiftedCoords(route.dest); if (start && end) { - // Canonical points for grouping (independent of direction) - // Sort by longitude then latitude const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); - const key = `${pts[0][0].toFixed(4)},${pts[0][1].toFixed(4)}_${pts[1][0].toFixed(4)},${pts[1][1].toFixed(4)}`; + const key = `${pts[0][0].toFixed(2)},${pts[0][1].toFixed(2)}_${pts[1][0].toFixed(2)},${pts[1][1].toFixed(2)}`; if (!routeGroups[key]) routeGroups[key] = []; routeGroups[key].push({ route, start, end }); } }); + const styleGroupedSeries = new Map(); + Object.keys(routeGroups).forEach(key => { - const routes = routeGroups[key]; - const count = routes.length; + const routesInGroup = routeGroups[key]; + const count = routesInGroup.length; - routes.forEach((item, i) => { + routesInGroup.forEach((item, i) => { const { route, start, end } = item; - - // Identify if the route is in the "canonical" direction - const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); - const isForward = (start === pts[0]); - - // Normalization and special routing rules const lowS = (route.source || '').toLowerCase().trim(); const lowD = (route.dest || '').toLowerCase().trim(); - const isSeattleJapan = ( (lowS === 'seattle' || lowS === 'us seattle') && lowD === 'japan' ) || - ( (lowD === 'seattle' || lowD === 'us seattle') && lowS === 'japan' ); + + const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const isForward = (start === pts[0]); + const isSeattleJapan = ((lowS === 'seattle' || lowS === 'us seattle') && lowD === 'japan') || + ((lowD === 'seattle' || lowD === 'us seattle') && lowS === 'japan'); - // Canonical hash for deterministic randomization (period, etc) const names = [lowS, lowD].sort(); const routeHash = names.join('').split('').reduce((acc, c) => acc + c.charCodeAt(0), 0); - let finalCurve = 0; + let finalCurve = 0.12; if (isSeattleJapan) { - // Special rule: Seattle ↔ Japan route curves upward (North) - // Based on user feedback that previous signs resulted in a downward curve, - // we flip them to ensure the intended upward arc. - const isWestbound = (lowS === 'seattle' || lowS === 'us seattle'); - finalCurve = isWestbound ? -0.2 : 0.2; - } else if (count === 1) { - // Subtle consistent curve for single routes to maintain parallelism - finalCurve = 0.12; - } else { - // Symmetrical fan-out for multiple lines between the same points + finalCurve = (lowS === 'seattle' || lowS === 'us seattle') ? -0.2 : 0.2; + } else if (count > 1) { const magnitude = 0.12 + Math.floor(i / 2) * 0.12; const spread = (i % 2 === 0) ? magnitude : -magnitude; finalCurve = isForward ? spread : -spread; } - finalSeries.push({ - type: 'lines', - coordinateSystem: 'geo', - zlevel: 2, - latencyLine: true, - effect: { - show: true, - period: 3 + ((routeHash + i) % 5), // Deterministic random period: 3-7s to vary start/speed - trailLength: 0.1, - color: 'rgba(99, 102, 241, 0.8)', - symbol: 'arrow', - symbolSize: 6 - }, - lineStyle: { - color: 'rgba(99, 102, 241, 0.3)', - width: 2, - curveness: finalCurve - }, - tooltip: { - formatter: () => { - const latVal = (route.latency !== null && route.latency !== undefined) ? `${route.latency.toFixed(2)} ms` : '测量中...'; - return ` -
-
${route.source} ↔ ${route.dest}
-
延时: ${latVal}
-
- `; - } - }, - data: [{ - fromName: route.source, - toName: route.dest, - coords: [start, end] - }] + const period = 3 + ((routeHash + i) % 5); + const styleKey = `${finalCurve.toFixed(2)}_${period}`; + + if (!styleGroupedSeries.has(styleKey)) { + styleGroupedSeries.set(styleKey, { + id: 'latency-group-' + styleKey, + type: 'lines', + coordinateSystem: 'geo', + zlevel: 2, + effect: { + show: true, + period: period, + trailLength: 0.1, + color: 'rgba(99, 102, 241, 0.8)', + symbol: 'arrow', + symbolSize: 6 + }, + lineStyle: { + color: 'rgba(99, 102, 241, 0.3)', + width: 2, + curveness: finalCurve + }, + progressive: 50, // Progressive rendering for GPU offload + animation: false, + tooltip: { + formatter: (params) => { + const r = params.data.meta; + if (!r) return ''; + const latVal = (r.latency !== null && r.latency !== undefined) ? `${r.latency.toFixed(2)} ms` : '测量中...'; + return ` +
+
${r.source} ↔ ${r.dest}
+
延时: ${latVal}
+
+ `; + } + }, + data: [] + }); + } + + styleGroupedSeries.get(styleKey).data.push({ + fromName: route.source, + toName: route.dest, + coords: [start, end], + meta: route }); }); }); + + styleGroupedSeries.forEach(s => finalSeries.push(s)); } - myMap2D.setOption({ - series: finalSeries - }); + myMap2D.setOption({ series: finalSeries }, { replaceMerge: ['series'] }); - // Update footer stats if (dom.globeTotalNodes) dom.globeTotalNodes.textContent = geoData.length; if (dom.globeTotalRegions) { - const regions = new Set(geoData.map(d => d.country)).size; - dom.globeTotalRegions.textContent = regions; + dom.globeTotalRegions.textContent = new Set(geoData.map(d => d.country)).size; } - } + }, 800); // ---- Update Dashboard ---- function updateDashboard(data) { diff --git a/public/js/chart.js b/public/js/chart.js index 8d6b34e..31be154 100644 --- a/public/js/chart.js +++ b/public/js/chart.js @@ -18,6 +18,7 @@ class AreaChart { this.prevMaxVal = 0; this.currentMaxVal = 0; + this.lastDataHash = ''; // Fingerprint for optimization // Use debounced resize for performance and safety this._resize = typeof debounce === 'function' ? debounce(this.resize.bind(this), 100) : this.resize.bind(this); @@ -40,6 +41,14 @@ class AreaChart { setData(data) { if (!data || !data.timestamps) return; + // 1. Data Fingerprinting: Skip redundant updates to save GPU/CPU + const fingerprint = data.timestamps.length + '_' + + (data.rx.length > 0 ? data.rx[data.rx.length - 1] : 0) + '_' + + (data.tx.length > 0 ? data.tx[data.tx.length - 1] : 0); + + if (fingerprint === this.lastDataHash) return; + this.lastDataHash = fingerprint; + // Store old data for smooth transition before updating this.data // Only clone if there is data to clone; otherwise use empty set if (this.data && this.data.timestamps && this.data.timestamps.length > 0) { @@ -335,6 +344,7 @@ class MetricChart { this.prevMaxVal = 0; this.currentMaxVal = 0; + this.lastDataHash = ''; // Fingerprint for optimization // Use debounced resize for performance and safety this._resize = typeof debounce === 'function' ? debounce(this.resize.bind(this), 100) : this.resize.bind(this); @@ -358,6 +368,15 @@ class MetricChart { } setData(data) { + if (!data || !data.timestamps) return; + + // 1. Simple fingerprinting to avoid constant re-animation of same data + const lastVal = data.values && data.values.length > 0 ? data.values[data.values.length - 1] : 0; + const fingerprint = data.timestamps.length + '_' + lastVal + '_' + (data.series ? 's' : 'v'); + + if (fingerprint === this.lastDataHash) return; + this.lastDataHash = fingerprint; + if (this.data && this.data.values && this.data.values.length > 0) { this.prevData = JSON.parse(JSON.stringify(this.data)); } else {