PageSourceSearch

https://thepriceisright.xyz/comparison-chart-uplot.js

js thepriceisright.xyz collected 2026-09-28 08:05:34 UTC 23,514 bytes, 639 lines download raw bytes

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.