1// uPlot-based price comparison chart - much cleaner and more performant 2class PriceComparisonChart { 3 constructor(canvasId) { 4 this.container = document.getElementById(canvasId).parentElement; 5 this.canvasId = canvasId; 6 7 // Remove the canvas and create uPlot container 8 const canvas = document.getElementById(canvasId); 9 canvas.remove(); 10 11 this.wrapper = document.createElement('div'); 12 this.wrapper.id = canvasId; 13 this.wrapper.style.width = '100%'; 14 this.wrapper.style.height = '280px'; 15 this.container.appendChild(this.wrapper); 16 17 // Initialize with empty data - ensure arrays are properly initialized 18 this.data = [ 19 new Array(), // timestamps 20 new Array(), // switchboard prices (scaled 0-100) 21 new Array(), // pyth prices (scaled 0-100) 22 new Array(), // chainlink prices (scaled 0-100) 23 new Array() // redstone prices (scaled 0-100) 24 ]; 25 26 // Store original prices for tooltip display 27 this.originalPrices = { 28 switchboard: [], 29 pyth: [], 30 chainlink: [], 31 redstone: [] 32 }; 33 34 // Smooth scrolling animation 35 this.animationFrame = null; 36 this.isAnimating = false; 37 this.isPaused = false; 38 39 // Time window (30 seconds) 40 this.windowSize = 30; 41 42 // uPlot options 43 this.opts = { 44 width: this.container.offsetWidth, 45 height: 280, 46 title: '', 47 scales: { 48 x: { 49 time: true, 50 range: (u, dataMin, dataMax) => { 51 // Fixed time window that moves with current time 52 const now = Date.now() / 1000; 53 return [now - this.windowSize, now]; 54 } 55 }, 56 y: { 57 auto: false, 58 range: [0, 100], 59 } 60 }, 61 axes: [ 62 { 63 stroke: 'rgba(255, 255, 255, 0.1)', 64 grid: { 65 stroke: 'rgba(255, 255, 255, 0.04)', 66 }, 67 ticks: { 68 stroke: 'rgba(255, 255, 255, 0.1)', 69 }, 70 font: '12px Inter, sans-serif', 71 labelFont: '12px Inter, sans-serif', 72 labelSize: 20, 73 labelColor: '#9ca3af', 74 space: 50, 75 }, 76 { 77 stroke: 'rgba(255, 255, 255, 0.1)', 78 grid: { 79 stroke: 'rgba(255, 255, 255, 0.04)', 80 }, 81 ticks: { 82 stroke: 'rgba(255, 255, 255, 0.1)', 83 }, 84 font: '12px Inter, sans-serif', 85 labelFont: '12px Inter, sans-serif', 86 labelSize: 20, 87 labelColor: '#9ca3af', 88 space: 30, 89 values: (u, vals) => vals.map(v => { 90 if (v === 0) return 'Min'; 91 if (v === 100) return 'Max'; 92 if (v === 50) return 'Mid'; 93 return ''; 94 }), 95 side: 3, 96 label: 'Normalized Price (Each Oracle Scaled)', 97 labelSize: 14, 98 labelColor: '#9ca3af', 99 } 100 ], 101 series: [ 102 { 103 label: "Time", 104 }, 105 { 106 label: 'Switchboard', 107 stroke: '#10b981', 108 width: 2, 109 spanGaps: false, 110 points: { 111 show: false 112 }, 113 paths: uPlot.paths.stepped({align: 1}), // Step chart aligned to right 114 }, 115 { 116 label: 'Pyth', 117 stroke: '#8b5cf6', 118 width: 2, 119 spanGaps: false, 120 points: { 121 show: false 122 }, 123 paths: uPlot.paths.stepped({align: 1}), // Step chart aligned to right 124 }, 125 { 126 label: 'Chainlink', 127 stroke: '#3b82f6', 128 width: 2, 129 spanGaps: false, 130 points: { 131 show: false 132 }, 133 paths: uPlot.paths.stepped({align: 1}), // Step chart aligned to right 134 }, 135 { 136 label: 'Redstone', 137 stroke: '#f59e0b', 138 width: 2, 139 spanGaps: false, 140 points: { 141 show: false 142 }, 143 paths: uPlot.paths.stepped({align: 1}), // Step chart aligned to right 144 } 145 ], 146 legend: { 147 show: true, 148 live: false, 149 markers: { 150 show: true, 151 width: 2, 152 dash: "solid", 153 }, 154 font: '12px Inter, sans-serif', 155 textColor: '#e5e7eb', 156 }, 157 cursor: { 158 show: true, 159 x: true, 160 y: true, 161 lock: false, 162 drag: { 163 x: true, 164 y: false, 165 uni: 50, 166 dist: 10, 167 setScale: false, // Disable zoom on drag 168 }, 169 focus: { 170 prox: 16, 171 }, 172 sync: { 173 key: 'comparison', 174 setSeries: true, 175 }, 176 dataIdx: (u, seriesIdx, closestIdx, xValue) => { 177 // Show timestamp in tooltip 178 return closestIdx; 179 } 180 }, 181 select: { 182 show: true, 183 over: true, 184 left: 0, 185 top: 0, 186 width: 0, 187 height: 0, 188 }, 189 hooks: { 190 setSelect: [ 191 (u) => { 192 const sel = u.select; 193 if (sel.width > 0) { 194 const startTime = u.posToVal(sel.left, 'x'); 195 const endTime = u.posToVal(sel.left + sel.width, 'x'); 196 const timeDiff = (endTime - startTime) * 1000; // Convert to ms 197 198 // Display time difference at top left 199 this.showTimeDiff(timeDiff, startTime * 1000, endTime * 1000); 200 201 // Freeze the chart when selection is active 202 this.isPaused = true; 203 } else { 204 this.hideTimeDiff(); 205 // Resume updates when selection is cleared 206 this.isPaused = false;
207 } 208 } 209 ], 210 setCursor: [ 211 (u) => { 212 const c = u.cursor; 213 if (c.idx != null && c.idx >= 0) { 214 const timestamp = u.data[0][c.idx] * 1000; // Convert to ms 215 const prices = { 216 pyth: this.originalPrices.pyth[c.idx], 217 redstone: this.originalPrices.redstone[c.idx], 218 switchboard: this.originalPrices.switchboard[c.idx], 219 chainlink: this.originalPrices.chainlink[c.idx] 220 }; 221 this.showTooltip(timestamp, prices, c.left); 222 } else { 223 this.hideTooltip(); 224 } 225 } 226 ] 227 } 228 }; 229 230 // Create the chart with error handling 231 try { 232 this.chart = new uPlot(this.opts, this.data, this.wrapper); 233 } catch (error) { 234 console.error('Failed to create uPlot chart:', error); 235 // Fallback to a simple div 236 this.wrapper.innerHTML = '<div style="color: #e5e7eb; text-align: center; padding: 50px;">Chart loading...</div>'; 237 return; 238 } 239 240 this.isPaused = false; 241 242 // Create tooltip element 243 this.tooltip = document.createElement('div'); 244 this.tooltip.style.cssText = ` 245 position: absolute; 246 display: none; 247 padding: 8px 12px; 248 background: rgba(0, 0, 0, 0.9); 249 border: 1px solid rgba(255, 255, 255, 0.2); 250 border-radius: 4px; 251 color: #fff; 252 font-size: 12px; 253 font-family: 'Inter', sans-serif; 254 pointer-events: none; 255 z-index: 1000; 256 white-space: nowrap; 257 `; 258 this.container.appendChild(this.tooltip); 259 260 // Create time difference display 261 this.timeDiffDisplay = document.createElement('div'); 262 this.timeDiffDisplay.style.cssText = ` 263 position: absolute; 264 display: none; 265 padding: 8px 12px; 266 background: rgba(0, 0, 0, 0.9); 267 border: 1px solid rgba(59, 130, 246, 0.5); 268 border-radius: 4px; 269 color: #60a5fa; 270 font-size: 12px; 271 font-family: 'Inter', sans-serif; 272 font-weight: 500; 273 pointer-events: none; 274 z-index: 1001; 275 top: 10px; 276 left: 10px; 277 `; 278 this.container.appendChild(this.timeDiffDisplay); 279 280 // Handle resize 281 this.resizeObserver = new ResizeObserver(() => { 282 this.chart.setSize({ 283 width: this.container.offsetWidth, 284 height: 280 285 }); 286 }); 287 this.resizeObserver.observe(this.container); 288 } 289 290 update(switchboardData, pythData, chainlinkData, redstoneData) { 291 if (this.isPaused) return; 292 293 // Store the raw data 294 this.rawData = { switchboardData, pythData, chainlinkData, redstoneData }; 295 296 // Start animation if not already running 297 if (!this.isAnimating) { 298 this.startAnimation(); 299 } 300 } 301 302 startAnimation() { 303 this.isAnimating = true; 304 305 const animate = () => { 306 if (!this.isAnimating || !this.chart) { 307 return; 308 } 309 310 // Only update chart data if not paused 311 if (!this.isPaused) { 312 this.updateChartData(); 313 } 314 315 // Continue animation loop even when paused 316 this.animationFrame = requestAnimationFrame(animate); 317 }; 318 319 animate(); 320 } 321 322 stopAnimation() { 323 this.isAnimating = false; 324 if (this.animationFrame) { 325 cancelAnimationFrame(this.animationFrame); 326 this.animationFrame = null; 327 } 328 } 329 330 updateChartData() { 331 if (!this.rawData) return; 332 333 const { switchboardData, pythData, chainlinkData, redstoneData } = this.rawData; 334 335 // Simple approach - show last 30 seconds 336 const now = Date.now(); 337 const cutoff = now - 30000; // 30 seconds 338 339 // Filter to last 30 seconds 340 const pythRecent = pythData.filter(d => d && d.receivedAt >= cutoff); 341 const redstoneRecent = redstoneData.filter(d => d && d.receivedAt >= cutoff); 342 const switchboardRecent = switchboardData.filter(d => d && d.receivedAt >= cutoff); 343 const chainlinkRecent = (chainlinkData || []).filter(d => d && d.receivedAt >= cutoff); 344 345 // Get all unique timestamps 346 const allTimestamps = new Set(); 347 pythRecent.forEach(d => allTimestamps.add(d.receivedAt)); 348 redstoneRecent.forEach(d => allTimestamps.add(d.receivedAt)); 349 switchboardRecent.forEach(d => allTimestamps.add(d.receivedAt)); 350 chainlinkRecent.forEach(d => allTimestamps.add(d.receivedAt)); 351 352 const uniqueTimestamps = Array.from(allTimestamps).sort((a, b) => a - b); 353 354 if (uniqueTimestamps.length === 0) { 355 this.data = [ 356 new Array(), 357 new Array(), 358 new Array(), 359 new Array(), 360 new Array() 361 ]; 362 this.originalPrices = { 363 pyth: [], 364 redstone: [], 365 switchboard: [], 366 chainlink: [] 367 }; 368 this.chart.setData(this.data); 369 return; 370 } 371 372 // Add current timestamp to ensure all lines end at same point 373 if (uniqueTimestamps[uniqueTimestamps.length - 1] < now) { 374 uniqueTimestamps.push(now); 375 } 376 377 // Create maps for fast lookup 378 const pythMap = new Map(pythRecent.map(d => [d.receivedAt, d.price])); 379 const redstoneMap = new Map(redstoneRecent.map(d => [d.receivedAt, d.price])); 380 const switchboardMap = new Map(switchboardRecent.map(d => [d.receivedAt, d.price])); 381 const chainlinkMap = new Map(chainlinkRecent.map(d => [d.receivedAt, d.price])); 382 383 // Forward fill prices 384 const pythPrices = []; 385 const redstonePrices = []; 386 const switchboardPrices = []; 387 const chainlinkPrices = []; 388 389 let lastPythPrice = pythRecent.length > 0 ? pythRecent[0].price : null; 390 let lastRedstonePrice = redstoneRecent.length > 0 ? redstoneRecent[0].price : null; 391 let lastSwitchboardPrice = switchboardRecent.length > 0 ? switchboardRecent[0].price : null; 392 let lastChainlinkPrice = chainlinkRecent.length > 0 ? chainlinkRecent[0].price : null; 393 394 for (const ts of uniqueTimestamps) { 395 // Update prices if we have new data
396 if (pythMap.has(ts)) lastPythPrice = pythMap.get(ts); 397 if (redstoneMap.has(ts)) lastRedstonePrice = redstoneMap.get(ts); 398 if (switchboardMap.has(ts)) lastSwitchboardPrice = switchboardMap.get(ts); 399 if (chainlinkMap.has(ts)) lastChainlinkPrice = chainlinkMap.get(ts); 400 401 pythPrices.push(lastPythPrice); 402 redstonePrices.push(lastRedstonePrice); 403 switchboardPrices.push(lastSwitchboardPrice); 404 chainlinkPrices.push(lastChainlinkPrice); 405 } 406 407 // Count unique price updates (actual changes, not just timestamps) 408 const countUniqueUpdates = (recentData) => { 409 const prices = recentData 410 .filter(d => d && d.price !== null && d.price !== undefined) 411 .map(d => d.price); 412 413 // Count unique price changes 414 const uniquePrices = new Set(); 415 let lastPrice = null; 416 417 for (const price of prices) { 418 if (price !== lastPrice) { 419 uniquePrices.add(price); 420 lastPrice = price; 421 } 422 } 423 424 return uniquePrices.size; 425 }; 426 427 // Check if we have enough actual data points for each oracle 428 const pythCount = countUniqueUpdates(pythRecent); 429 const redstoneCount = countUniqueUpdates(redstoneRecent); 430 const switchboardCount = countUniqueUpdates(switchboardRecent); 431 const chainlinkCount = countUniqueUpdates(chainlinkRecent); 432 433 const pythHasEnoughData = pythCount >= 10; 434 const redstoneHasEnoughData = redstoneCount >= 10; 435 const switchboardHasEnoughData = switchboardCount >= 10; 436 const chainlinkHasEnoughData = chainlinkCount >= 10; 437 438 // Scale each dataset to 0-100 439 const scaleData = (data, hasEnoughData) => { 440 // Don't show line if we don't have enough actual data points 441 if (!hasEnoughData) return data.map(() => null); 442 443 const validData = data.filter(v => v !== null); 444 if (validData.length < 2) return data.map(() => null); 445 446 const min = Math.min(...validData); 447 const max = Math.max(...validData); 448 const range = max - min; 449 450 if (range === 0) { 451 return data.map(v => v === null ? null : 50); 452 } 453 454 return data.map(v => v === null ? null : ((v - min) / range) * 100); 455 }; 456 457 const pythScaled = scaleData(pythPrices, pythHasEnoughData); 458 const redstoneScaled = scaleData(redstonePrices, redstoneHasEnoughData); 459 const switchboardScaled = scaleData(switchboardPrices, switchboardHasEnoughData); 460 const chainlinkScaled = scaleData(chainlinkPrices, chainlinkHasEnoughData); 461 462 // Store original prices for tooltip 463 this.originalPrices = { 464 switchboard: switchboardPrices, 465 pyth: pythPrices, 466 chainlink: chainlinkPrices, 467 redstone: redstonePrices 468 }; 469 470 // Convert timestamps to seconds for uPlot 471 const timeData = uniqueTimestamps.map(ts => ts / 1000); 472 473 // Update chart data with error handling 474 try { 475 // For series with insufficient data, use empty arrays instead of null-filled arrays 476 this.data = [ 477 timeData, 478 switchboardHasEnoughData ? switchboardScaled : [], 479 pythHasEnoughData ? pythScaled : [], 480 chainlinkHasEnoughData ? chainlinkScaled : [], 481 redstoneHasEnoughData ? redstoneScaled : [] 482 ]; 483 484 if (this.chart && this.chart.setData) { 485 this.chart.setData(this.data, false); 486 487 // Also hide/show series for good measure 488 this.chart.setSeries(1, { show: switchboardHasEnoughData }); 489 this.chart.setSeries(2, { show: pythHasEnoughData }); 490 this.chart.setSeries(3, { show: chainlinkHasEnoughData }); 491 this.chart.setSeries(4, { show: redstoneHasEnoughData }); 492 493 // Manually update x-axis range for smooth scrolling 494 const now = Date.now() / 1000; 495 this.chart.setScale('x', { min: now - this.windowSize, max: now }); 496 } 497 } catch (error) { 498 console.error('Failed to update chart data:', error); 499 } 500 } 501 502 pause() { 503 this.isPaused = true; 504 } 505 506 resume() { 507 this.isPaused = false;
508 // Ensure animation is running 509 if (!this.isAnimating) { 510 this.startAnimation(); 511 } 512 } 513 514 toggle() { 515 this.isPaused = !this.isPaused; 516 // Clear selection when resuming 517 if (!this.isPaused) { 518 this.clearSelection(); 519 // Ensure animation is running when unpausing 520 if (!this.isAnimating) { 521 this.startAnimation(); 522 } 523 } 524 return this.isPaused; 525 } 526 527 showTooltip(timestamp, prices, x) { 528 const date = new Date(timestamp); 529 const timeStr = date.toLocaleTimeString('en-US', { 530 hour12: false, 531 hour: '2-digit', 532 minute: '2-digit', 533 second: '2-digit', 534 fractionalSecondDigits: 3 535 }); 536 537 let html = `<div style="font-weight: 600; margin-bottom: 6px;">${timeStr}</div>`; 538 539 if (prices.pyth !== null && prices.pyth !== undefined) { 540 html += `<div style="color: #667eea;">Pyth: $${prices.pyth.toFixed(2)}</div>`; 541 } 542 if (prices.redstone !== null && prices.redstone !== undefined) { 543 html += `<div style="color: #8b5cf6;">Redstone: $${prices.redstone.toFixed(2)}</div>`; 544 } 545 if (prices.switchboard !== null && prices.switchboard !== undefined) { 546 html += `<div style="color: #10b981;">Switchboard: $${prices.switchboard.toFixed(2)}</div>`; 547 } 548 if (prices.chainlink !== null && prices.chainlink !== undefined) { 549 html += `<div style="color: #f59e0b;">Chainlink: $${prices.chainlink.toFixed(2)}</div>`; 550 } 551 552 this.tooltip.innerHTML = html; 553 this.tooltip.style.display = 'block'; 554 this.tooltip.style.left = `${x}px`; 555 this.tooltip.style.bottom = '100%'; 556 this.tooltip.style.marginBottom = '10px'; 557 558 // Adjust position if tooltip goes off screen 559 const rect = this.tooltip.getBoundingClientRect(); 560 const containerRect = this.container.getBoundingClientRect(); 561 562 if (rect.right > containerRect.right) { 563 this.tooltip.style.left = `${x - rect.width}px`; 564 } 565 } 566 567 hideTooltip() { 568 this.tooltip.style.display = 'none'; 569 } 570 571 showTimeDiff(timeDiffMs, startTimeMs, endTimeMs) { 572 const formatTime = (ms) => { 573 const date = new Date(ms); 574 return date.toLocaleTimeString('en-US', { 575 hour12: false, 576 hour: '2-digit', 577 minute: '2-digit', 578 second: '2-digit', 579 fractionalSecondDigits: 1 580 }); 581 }; 582 583 let diffText; 584 if (timeDiffMs < 1000) { 585 diffText = `${Math.round(timeDiffMs)}ms`; 586 } else if (timeDiffMs < 60000) { 587 diffText = `${(timeDiffMs / 1000).toFixed(1)}s`; 588 } else { 589 const minutes = Math.floor(timeDiffMs / 60000); 590 const seconds = ((timeDiffMs % 60000) / 1000).toFixed(0); 591 diffText = `${minutes}m ${seconds}s`; 592 } 593 594 this.timeDiffDisplay.innerHTML = ` 595 <div style="font-weight: 600; color: #60a5fa; margin-bottom: 4px;">Time Range: ${diffText}</div> 596 <div style="font-size: 11px; color: #e5e7eb;">${formatTime(startTimeMs)} â ${formatTime(endTimeMs)}</div> 597 `; 598 this.timeDiffDisplay.style.display = 'block'; 599 } 600 601 hideTimeDiff() { 602 this.timeDiffDisplay.style.display = 'none'; 603 } 604 605 clearSelection() { 606 if (this.chart && this.chart.select) { 607 // Clear the selection 608 this.chart.setSelect({ 609 left: 0, 610 top: 0, 611 width: 0, 612 height: 0 613 }, false); 614 // Hide time diff display 615 this.hideTimeDiff(); 616 // Resume updates 617 this.isPaused = false; 618 } 619 } 620 621 destroy() { 622 this.stopAnimation(); 623 if (this.resizeObserver) { 624 this.resizeObserver.disconnect(); 625 } 626 if (this.chart) { 627 this.chart.destroy(); 628 } 629 if (this.tooltip) { 630 this.tooltip.remove(); 631 } 632 if (this.timeDiffDisplay) { 633 this.timeDiffDisplay.remove(); 634 } 635 } 636} 637 638// Export for use 639window.PriceComparisonChart = PriceComparisonChart;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.