"use strict"; /** * ArrowIndicator - A clean implementation for managing metric value change indicators * * This module provides a simple, self-contained system for managing arrow indicators * that show whether metric values have increased, decreased, or remained stable * between refreshes. */ class ArrowIndicator { constructor() { this.previousMetrics = {}; this.arrowStates = {}; this.changeThreshold = 0.00001; this.debug = false; // Load saved state immediately this.loadFromStorage(); // DOM ready handling if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.initializeDOM()); } else { setTimeout(() => this.initializeDOM(), 100); } // Handle tab visibility changes document.addEventListener("visibilitychange", () => { if (!document.hidden) { this.loadFromStorage(); this.forceApplyArrows(); } }); // Handle storage changes for cross-tab sync window.addEventListener('storage', this.handleStorageEvent.bind(this)); } initializeDOM() { // First attempt to apply arrows this.forceApplyArrows(); // Set up a detection system to find indicator elements this.detectIndicatorElements(); } detectIndicatorElements() { // Scan the DOM for all elements that match our indicator pattern const indicatorElements = {}; // Look for elements with IDs starting with "indicator_" const elements = document.querySelectorAll('[id^="indicator_"]'); elements.forEach(element => { const key = element.id.replace('indicator_', ''); indicatorElements[key] = element; }); // Apply arrows to the found elements this.applyArrowsToFoundElements(indicatorElements); // Set up a MutationObserver to catch dynamically added elements this.setupMutationObserver(); // Schedule additional attempts with increasing delays [500, 1000, 2000].forEach(delay => { setTimeout(() => this.forceApplyArrows(), delay); }); } setupMutationObserver() { // Watch for changes to the DOM that might add indicator elements const observer = new MutationObserver(mutations => { let newElementsFound = false; mutations.forEach(mutation => { if (mutation.type === 'childList' && mutation.addedNodes.length) { mutation.addedNodes.forEach(node => { if (node.nodeType === 1) { // Element node // Check the node itself if (node.id && node.id.startsWith('indicator_')) { newElementsFound = true; } // Check children of the node const childIndicators = node.querySelectorAll('[id^="indicator_"]'); if (childIndicators.length) { newElementsFound = true; } } }); } }); if (newElementsFound) { this.forceApplyArrows(); } }); // Start observing observer.observe(document.body, { childList: true, subtree: true }); } forceApplyArrows() { let applied = 0; let missing = 0; // Apply arrows to all indicators we know about Object.keys(this.arrowStates).forEach(key => { const element = document.getElementById(`indicator_${key}`); if (element) { // Double-check if the element is visible const arrowValue = this.arrowStates[key] || ""; // Use direct DOM manipulation instead of innerHTML for better reliability if (arrowValue) { // Clear existing content while (element.firstChild) { element.removeChild(element.firstChild); } // Create the new icon element const tmpDiv = document.createElement('div'); tmpDiv.innerHTML = arrowValue; const iconElement = tmpDiv.firstChild; // Make the arrow more visible if (iconElement) { element.appendChild(iconElement); // Force the arrow to be visible iconElement.style.display = "inline-block"; } } applied++; } else { missing++; } }); return applied; } applyArrowsToFoundElements(elements) { let applied = 0; Object.keys(elements).forEach(key => { if (this.arrowStates[key]) { const element = elements[key]; element.innerHTML = this.arrowStates[key]; applied++; } }); } updateIndicators(newMetrics, forceReset = false) { if (!newMetrics) return this.arrowStates; // Define metrics that should have indicators const metricKeys = [ "pool_total_hashrate", "hashrate_24hr", "hashrate_3hr", "hashrate_10min", "hashrate_60sec", "block_number", "btc_price", "network_hashrate", "difficulty", "daily_revenue", "daily_power_cost", "daily_profit_usd", "monthly_profit_usd", "daily_mined_sats", "monthly_mined_sats", "unpaid_earnings", "estimated_earnings_per_day_sats", "estimated_earnings_next_block_sats", "estimated_rewards_in_window_sats", "workers_hashing" ]; // Clear all arrows if requested if (forceReset) { metricKeys.forEach(key => { this.arrowStates[key] = ""; }); } // Current theme affects arrow colors const theme = getCurrentTheme(); const upArrowColor = THEME.SHARED.GREEN; const downArrowColor = THEME.SHARED.RED; // Get normalized values and compare with previous metrics for (const key of metricKeys) { if (newMetrics[key] === undefined) continue; const newValue = this.getNormalizedValue(newMetrics, key); if (newValue === null) continue; if (this.previousMetrics[key] !== undefined) { const prevValue = this.previousMetrics[key]; if (newValue > prevValue * (1 + this.changeThreshold)) { this.arrowStates[key] = ""; } else if (newValue < prevValue * (1 - this.changeThreshold)) { this.arrowStates[key] = ""; } } this.previousMetrics[key] = newValue; } // Apply arrows to DOM this.forceApplyArrows(); // Save to localStorage for persistence this.saveToStorage(); return this.arrowStates; } // Get a normalized value for a metric to ensure consistent comparisons getNormalizedValue(metrics, key) { const value = parseFloat(metrics[key]); if (isNaN(value)) return null; // Special handling for hashrate values to normalize units if (key.includes('hashrate')) { const unit = metrics[key + '_unit'] || 'th/s'; return this.normalizeHashrate(value, unit); } return value; } // Normalize hashrate to a common unit (TH/s) normalizeHashrate(value, unit) { // Use the enhanced global normalizeHashrate function return window.normalizeHashrate(value, unit); } // Save current state to localStorage saveToStorage() { try { // Save arrow states localStorage.setItem('dashboardArrows', JSON.stringify(this.arrowStates)); // Save previous metrics for comparison after page reload localStorage.setItem('dashboardPreviousMetrics', JSON.stringify(this.previousMetrics)); } catch (e) { console.error("Error saving arrow indicators to localStorage:", e); } } // Load state from localStorage loadFromStorage() { try { // Load arrow states const savedArrows = localStorage.getItem('dashboardArrows'); if (savedArrows) { this.arrowStates = JSON.parse(savedArrows); } // Load previous metrics const savedMetrics = localStorage.getItem('dashboardPreviousMetrics'); if (savedMetrics) { this.previousMetrics = JSON.parse(savedMetrics); } } catch (e) { console.error("Error loading arrow indicators from localStorage:", e); // On error, reset to defaults this.arrowStates = {}; this.previousMetrics = {}; } } // Handle storage events for cross-tab synchronization handleStorageEvent(event) { if (event.key === 'dashboardArrows') { try { const newArrows = JSON.parse(event.newValue); this.arrowStates = newArrows; this.forceApplyArrows(); } catch (e) { console.error("Error handling storage event:", e); } } } // Reset for new refresh cycle prepareForRefresh() { Object.keys(this.arrowStates).forEach(key => { this.arrowStates[key] = ""; }); this.forceApplyArrows(); } // Clear all indicators clearAll() { this.arrowStates = {}; this.previousMetrics = {}; this.forceApplyArrows(); this.saveToStorage(); } } // Create the singleton instance const arrowIndicator = new ArrowIndicator(); // Global timezone configuration let dashboardTimezone = 'America/Los_Angeles'; // Default window.dashboardTimezone = dashboardTimezone; // Make it globally accessible // Fetch the configured timezone when the page loads function fetchTimezoneConfig() { fetch('/api/timezone') .then(response => response.json()) .then(data => { if (data && data.timezone) { dashboardTimezone = data.timezone; window.dashboardTimezone = dashboardTimezone; // Make it globally accessible console.log(`Using configured timezone: ${dashboardTimezone}`); } }) .catch(error => console.error('Error fetching timezone config:', error)); } // Call this on page load document.addEventListener('DOMContentLoaded', fetchTimezoneConfig); // Global variables let previousMetrics = {}; let latestMetrics = null; let initialLoad = true; let trendData = []; let trendLabels = []; let trendChart = null; let connectionRetryCount = 0; let maxRetryCount = 10; let reconnectionDelay = 1000; // Start with 1 second let pingInterval = null; let lastPingTime = Date.now(); let connectionLostTimeout = null; // Server time variables for uptime calculation let serverTimeOffset = 0; let serverStartTime = null; // Register Chart.js annotation plugin if available if (window['chartjs-plugin-annotation']) { Chart.register(window['chartjs-plugin-annotation']); } // Hashrate Normalization Utilities // Enhanced normalizeHashrate function with better error handling for units /** * Normalizes hashrate values to TH/s (terahashes per second) for consistent comparison * @param {number|string} value - The hashrate value to normalize * @param {string} unit - The unit of the provided hashrate (e.g., 'ph/s', 'th/s', 'gh/s') * @param {boolean} [debug=false] - Whether to output detailed debugging information * @returns {number} - The normalized hashrate value in TH/s */ function normalizeHashrate(value, unit, debug = false) { // Handle null, undefined, empty strings or non-numeric values if (value === null || value === undefined || value === '' || isNaN(parseFloat(value))) { if (debug) console.warn(`Invalid hashrate value: ${value}`); return 0; } // Convert to number and handle scientific notation (e.g., "1.23e+5") value = parseFloat(value); // Standardize unit handling with a lookup table const unit_normalized = (unit || 'th/s').toLowerCase().trim(); // Store original values for logging const originalValue = value; const originalUnit = unit; // Lookup table for conversion factors (all relative to TH/s) const unitConversions = { // Zettahash (ZH/s) - 1 ZH/s = 1,000,000,000 TH/s 'zh/s': 1000000000, 'z/s': 1000000000, 'z': 1000000000, 'zettahash': 1000000000, 'zettahash/s': 1000000000, 'zetta': 1000000000, // Exahash (EH/s) - 1 EH/s = 1,000,000 TH/s 'eh/s': 1000000, 'e/s': 1000000, 'e': 1000000, 'exahash': 1000000, 'exahash/s': 1000000, 'exa': 1000000, // Petahash (PH/s) - 1 PH/s = 1,000 TH/s 'ph/s': 1000, 'p/s': 1000, 'p': 1000, 'petahash': 1000, 'petahash/s': 1000, 'peta': 1000, // Terahash (TH/s) - Base unit 'th/s': 1, 't/s': 1, 't': 1, 'terahash': 1, 'terahash/s': 1, 'tera': 1, // Gigahash (GH/s) - 1 TH/s = 1,000 GH/s 'gh/s': 1 / 1000, 'g/s': 1 / 1000, 'g': 1 / 1000, 'gigahash': 1 / 1000, 'gigahash/s': 1 / 1000, 'giga': 1 / 1000, // Megahash (MH/s) - 1 TH/s = 1,000,000 MH/s 'mh/s': 1 / 1000000, 'm/s': 1 / 1000000, 'm': 1 / 1000000, 'megahash': 1 / 1000000, 'megahash/s': 1 / 1000000, 'mega': 1 / 1000000, // Kilohash (KH/s) - 1 TH/s = 1,000,000,000 KH/s 'kh/s': 1 / 1000000000, 'k/s': 1 / 1000000000, 'k': 1 / 1000000000, 'kilohash': 1 / 1000000000, 'kilohash/s': 1 / 1000000000, 'kilo': 1 / 1000000000, // Hash (H/s) - 1 TH/s = 1,000,000,000,000 H/s 'h/s': 1 / 1000000000000, 'h': 1 / 1000000000000, 'hash': 1 / 1000000000000, 'hash/s': 1 / 1000000000000 }; let conversionFactor = null; let matchedUnit = null; // Direct lookup for exact matches if (unitConversions.hasOwnProperty(unit_normalized)) { conversionFactor = unitConversions[unit_normalized]; matchedUnit = unit_normalized; } else { // Fuzzy matching for non-exact matches for (const knownUnit in unitConversions) { if (unit_normalized.includes(knownUnit) || knownUnit.includes(unit_normalized)) { conversionFactor = unitConversions[knownUnit]; matchedUnit = knownUnit; if (debug) { console.log(`Fuzzy matching unit: "${unit}" → interpreted as "${knownUnit}" (conversion: ${unitConversions[knownUnit]})`); } break; } } } // Handle unknown units if (conversionFactor === null) { console.warn(`Unrecognized hashrate unit: "${unit}", assuming TH/s. Value: ${value}`); // Automatically detect and suggest the appropriate unit based on magnitude if (value > 1000) { console.warn(`NOTE: Value ${value} is quite large for TH/s. Could it be PH/s?`); } else if (value > 1000000) { console.warn(`NOTE: Value ${value} is extremely large for TH/s. Could it be EH/s?`); } else if (value < 0.001) { console.warn(`NOTE: Value ${value} is quite small for TH/s. Could it be GH/s or MH/s?`); } // Assume TH/s as fallback conversionFactor = 1; matchedUnit = 'th/s'; } // Calculate normalized value const normalizedValue = value * conversionFactor; // Log abnormally large conversions for debugging if ((normalizedValue > 1000000 || normalizedValue < 0.000001) && normalizedValue !== 0) { console.log(`Large scale conversion detected: ${originalValue} ${originalUnit} → ${normalizedValue.toExponential(2)} TH/s`); } // Extra debugging for very large values to help track the Redis storage issue if (debug && originalValue > 900000 && matchedUnit === 'th/s') { console.group('High Hashrate Debug Info'); console.log(`Original: ${originalValue} ${originalUnit}`); console.log(`Normalized: ${normalizedValue} TH/s`); console.log(`Should be displayed as: ${(normalizedValue / 1000).toFixed(2)} PH/s`); console.log('Call stack:', new Error().stack); console.groupEnd(); } return normalizedValue; } // Helper function to format hashrate values for display function formatHashrateForDisplay(value, unit) { if (isNaN(value) || value === null || value === undefined) return "N/A"; // Always normalize to TH/s first if unit is provided let normalizedValue = unit ? normalizeHashrate(value, unit) : value; // Select appropriate unit based on magnitude if (normalizedValue >= 1000000) { // EH/s range return (normalizedValue / 1000000).toFixed(2) + ' EH/s'; } else if (normalizedValue >= 1000) { // PH/s range return (normalizedValue / 1000).toFixed(2) + ' PH/s'; } else if (normalizedValue >= 1) { // TH/s range return normalizedValue.toFixed(2) + ' TH/s'; } else if (normalizedValue >= 0.001) { // GH/s range return (normalizedValue * 1000).toFixed(2) + ' GH/s'; } else { // MH/s range or smaller return (normalizedValue * 1000000).toFixed(2) + ' MH/s'; } } // Function to calculate block finding probability based on hashrate and network hashrate function calculateBlockProbability(yourHashrate, yourHashrateUnit, networkHashrate) { // First normalize both hashrates to the same unit (TH/s) const normalizedYourHashrate = normalizeHashrate(yourHashrate, yourHashrateUnit); // Network hashrate is in EH/s, convert to TH/s (1 EH/s = 1,000,000 TH/s) const networkHashrateInTH = networkHashrate * 1000000; // Calculate probability as your_hashrate / network_hashrate const probability = normalizedYourHashrate / networkHashrateInTH; // Format the probability for display return formatProbability(probability); } // Format probability for display function formatProbability(probability) { // Format as 1 in X chance (more intuitive for small probabilities) if (probability > 0) { const oneInX = Math.round(1 / probability); return `1 : ${numberWithCommas(oneInX)}`; } else { return "N/A"; } } // Calculate theoretical time to find a block based on hashrate function calculateBlockTime(yourHashrate, yourHashrateUnit, networkHashrate) { // First normalize both hashrates to the same unit (TH/s) const normalizedYourHashrate = normalizeHashrate(yourHashrate, yourHashrateUnit); // Make sure network hashrate is a valid number if (typeof networkHashrate !== 'number' || isNaN(networkHashrate) || networkHashrate <= 0) { console.error("Invalid network hashrate:", networkHashrate); return "N/A"; } // Network hashrate is in EH/s, convert to TH/s (1 EH/s = 1,000,000 TH/s) const networkHashrateInTH = networkHashrate * 1000000; // Calculate the probability of finding a block per hash attempt const probability = normalizedYourHashrate / networkHashrateInTH; // Bitcoin produces a block every 10 minutes (600 seconds) on average const secondsToFindBlock = 600 / probability; // Log the calculation for debugging console.log(`Block time calculation using network hashrate: ${networkHashrate} EH/s`); console.log(`Your hashrate: ${yourHashrate} ${yourHashrateUnit} (normalized: ${normalizedYourHashrate} TH/s)`); console.log(`Probability: ${normalizedYourHashrate} / (${networkHashrate} * 1,000,000) = ${probability}`); console.log(`Time to find block: 600 seconds / ${probability} = ${secondsToFindBlock} seconds`); console.log(`Estimated time: ${secondsToFindBlock / 86400} days (${secondsToFindBlock / 86400 / 365.25} years)`); return formatTimeRemaining(secondsToFindBlock); } // Format time in seconds to a readable format (similar to est_time_to_payout) function formatTimeRemaining(seconds) { if (!seconds || seconds <= 0 || !isFinite(seconds)) { return "N/A"; } // Extremely large values (over 100 years) are not useful if (seconds > 3153600000) { // 100 years in seconds return "Never (statistically)"; } const minutes = seconds / 60; const hours = minutes / 60; const days = hours / 24; const months = days / 30.44; // Average month length const years = days / 365.25; // Account for leap years if (years >= 1) { // For very long timeframes, show years and months const remainingMonths = Math.floor((years - Math.floor(years)) * 12); if (remainingMonths > 0) { return `${Math.floor(years)} year${Math.floor(years) !== 1 ? 's' : ''}, ${remainingMonths} month${remainingMonths !== 1 ? 's' : ''}`; } return `${Math.floor(years)} year${Math.floor(years) !== 1 ? 's' : ''}`; } else if (months >= 1) { // For months, show months and days const remainingDays = Math.floor((months - Math.floor(months)) * 30.44); if (remainingDays > 0) { return `${Math.floor(months)} month${Math.floor(months) !== 1 ? 's' : ''}, ${remainingDays} day${remainingDays !== 1 ? 's' : ''}`; } return `${Math.floor(months)} month${Math.floor(months) !== 1 ? 's' : ''}`; } else if (days >= 1) { // For days, show days and hours const remainingHours = Math.floor((days - Math.floor(days)) * 24); if (remainingHours > 0) { return `${Math.floor(days)} day${Math.floor(days) !== 1 ? 's' : ''}, ${remainingHours} hour${remainingHours !== 1 ? 's' : ''}`; } return `${Math.floor(days)} day${Math.floor(days) !== 1 ? 's' : ''}`; } else if (hours >= 1) { // For hours, show hours and minutes const remainingMinutes = Math.floor((hours - Math.floor(hours)) * 60); if (remainingMinutes > 0) { return `${Math.floor(hours)} hour${Math.floor(hours) !== 1 ? 's' : ''}, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}`; } return `${Math.floor(hours)} hour${Math.floor(hours) !== 1 ? 's' : ''}`; } else { // For minutes, just show minutes return `${Math.ceil(minutes)} minute${Math.ceil(minutes) !== 1 ? 's' : ''}`; } } // Calculate pool luck as a percentage function calculatePoolLuck(actualSats, estimatedSats) { if (!actualSats || !estimatedSats || estimatedSats === 0) { return null; } // Calculate luck as a percentage (actual/estimated * 100) const luck = (actualSats / estimatedSats) * 100; return luck; } // Format luck percentage for display with color coding function formatLuckPercentage(luckPercentage) { if (luckPercentage === null) { return "N/A"; } const formattedLuck = luckPercentage.toFixed(1) + "%"; // Don't add classes here, just return the formatted value // The styling will be applied separately based on the value return formattedLuck; } // SSE Connection with Error Handling and Reconnection Logic function setupEventSource() { console.log("Setting up EventSource connection..."); if (window.eventSource) { console.log("Closing existing EventSource connection"); window.eventSource.close(); window.eventSource = null; } // Always use absolute URL with origin to ensure it works from any path const baseUrl = window.location.origin; const streamUrl = `${baseUrl}/stream`; // Clear any existing ping interval if (pingInterval) { clearInterval(pingInterval); pingInterval = null; } // Clear any connection lost timeout if (connectionLostTimeout) { clearTimeout(connectionLostTimeout); connectionLostTimeout = null; } try { const eventSource = new EventSource(streamUrl); eventSource.onopen = function (e) { console.log("EventSource connection opened successfully"); connectionRetryCount = 0; // Reset retry count on successful connection reconnectionDelay = 1000; // Reset reconnection delay hideConnectionIssue(); // Start ping interval to detect dead connections lastPingTime = Date.now(); pingInterval = setInterval(function () { const now = Date.now(); if (now - lastPingTime > 60000) { // 60 seconds without data console.warn("No data received for 60 seconds, reconnecting..."); showConnectionIssue("Connection stalled"); eventSource.close(); setupEventSource(); } }, 30000); // Check every 30 seconds }; eventSource.onmessage = function (e) { lastPingTime = Date.now(); // Update ping time on any message try { const data = JSON.parse(e.data); // Handle different message types if (data.type === "ping") { // Update connection count if available if (data.connections !== undefined) { console.log(`Active connections: ${data.connections}`); } return; } if (data.type === "timeout_warning") { console.log(`Connection timeout warning: ${data.remaining}s remaining`); // If less than 30 seconds remaining, prepare for reconnection if (data.remaining < 30) { console.log("Preparing for reconnection due to upcoming timeout"); } return; } if (data.type === "timeout") { console.log("Connection timeout from server:", data.message); eventSource.close(); // If reconnect flag is true, reconnect immediately if (data.reconnect) { console.log("Server requested reconnection"); setTimeout(setupEventSource, 500); } else { setupEventSource(); } return; } if (data.error) { console.error("Server reported error:", data.error); showConnectionIssue(data.error); // If retry time provided, use it, otherwise use default const retryTime = data.retry || 5000; setTimeout(function () { manualRefresh(); }, retryTime); return; } // Process regular data update latestMetrics = data; updateUI(); hideConnectionIssue(); // Notify BitcoinMinuteRefresh that we did a refresh BitcoinMinuteRefresh.notifyRefresh(); } catch (err) { console.error("Error processing SSE data:", err); showConnectionIssue("Data processing error"); } }; eventSource.onerror = function (e) { console.error("SSE connection error", e); showConnectionIssue("Connection lost"); eventSource.close(); // Implement exponential backoff for reconnection connectionRetryCount++; if (connectionRetryCount > maxRetryCount) { console.log("Maximum retry attempts reached, switching to polling mode"); if (pingInterval) { clearInterval(pingInterval); pingInterval = null; } // Switch to regular polling showConnectionIssue("Using polling mode"); setInterval(manualRefresh, 30000); // Poll every 30 seconds manualRefresh(); // Do an immediate refresh return; } // Exponential backoff with jitter const jitter = Math.random() * 0.3 + 0.85; // 0.85-1.15 reconnectionDelay = Math.min(30000, reconnectionDelay * 1.5 * jitter); console.log(`Reconnecting in ${(reconnectionDelay / 1000).toFixed(1)} seconds... (attempt ${connectionRetryCount}/${maxRetryCount})`); setTimeout(setupEventSource, reconnectionDelay); }; window.eventSource = eventSource; // Set a timeout to detect if connection is established connectionLostTimeout = setTimeout(function () { if (eventSource.readyState !== 1) { // 1 = OPEN console.warn("Connection not established within timeout, switching to manual refresh"); showConnectionIssue("Connection timeout"); eventSource.close(); manualRefresh(); } }, 30000); // 30 seconds timeout to establish connection } catch (error) { console.error("Failed to create EventSource:", error); showConnectionIssue("Connection setup failed"); setTimeout(setupEventSource, 5000); // Try again in 5 seconds } // Add page visibility change listener // This helps reconnect when user returns to the tab after it's been inactive document.removeEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange); } // Handle page visibility changes function handleVisibilityChange() { if (!document.hidden) { console.log("Page became visible, checking connection"); if (!window.eventSource || window.eventSource.readyState !== 1) { console.log("Connection not active, reestablishing"); setupEventSource(); } manualRefresh(); // Always refresh data when page becomes visible } } // Helper function to show connection issues to the user function showConnectionIssue(message) { const theme = getCurrentTheme(); let $connectionStatus = $("#connectionStatus"); if (!$connectionStatus.length) { $("body").append(`
`); $connectionStatus = $("#connectionStatus"); } $connectionStatus.html(` ${message}`).show(); // Show manual refresh button with theme color $("#refreshButton").css('background-color', theme.PRIMARY).show(); } // Helper function to hide connection issue message function hideConnectionIssue() { $("#connectionStatus").hide(); $("#refreshButton").hide(); } // Improved manual refresh function as fallback function manualRefresh() { console.log("Manually refreshing data..."); // Prepare arrow indicators for a new refresh cycle arrowIndicator.prepareForRefresh(); $.ajax({ url: '/api/metrics', method: 'GET', dataType: 'json', timeout: 15000, // 15 second timeout success: function (data) { console.log("Manual refresh successful"); lastPingTime = Date.now(); latestMetrics = data; updateUI(); hideConnectionIssue(); // Notify BitcoinMinuteRefresh that we've refreshed the data BitcoinMinuteRefresh.notifyRefresh(); }, error: function (xhr, status, error) { console.error("Manual refresh failed:", error); showConnectionIssue("Manual refresh failed"); // Try again with exponential backoff const retryDelay = Math.min(30000, 1000 * Math.pow(1.5, Math.min(5, connectionRetryCount))); connectionRetryCount++; setTimeout(manualRefresh, retryDelay); } }); } // Modify the initializeChart function to use blue colors for the chart function initializeChart() { try { const ctx = document.getElementById('trendGraph').getContext('2d'); if (!ctx) { console.error("Could not find trend graph canvas"); return null; } if (!window.Chart) { console.error("Chart.js not loaded"); return null; } // Get the current theme colors const theme = getCurrentTheme(); // Check if Chart.js plugin is available const hasAnnotationPlugin = window['chartjs-plugin-annotation'] !== undefined; return new Chart(ctx, { type: 'line', data: { labels: [], datasets: [{ label: 'HASHRATE TREND (TH/s)', data: [], borderWidth: 2, borderColor: function (context) { const chart = context.chart; const { ctx, chartArea } = chart; if (!chartArea) { return theme.PRIMARY; } // Create gradient for line const gradient = ctx.createLinearGradient(0, 0, 0, chartArea.bottom); gradient.addColorStop(0, theme.CHART.GRADIENT_START); gradient.addColorStop(1, theme.CHART.GRADIENT_END); return gradient; }, backgroundColor: function (context) { const chart = context.chart; const { ctx, chartArea } = chart; if (!chartArea) { return `rgba(${theme.PRIMARY_RGB}, 0.1)`; } // Create gradient for fill const gradient = ctx.createLinearGradient(0, 0, 0, chartArea.bottom); gradient.addColorStop(0, `rgba(${theme.PRIMARY_RGB}, 0.3)`); gradient.addColorStop(0.5, `rgba(${theme.PRIMARY_RGB}, 0.2)`); gradient.addColorStop(1, `rgba(${theme.PRIMARY_RGB}, 0.05)`); return gradient; }, fill: true, tension: 0.3, }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 0 // Disable animations for better performance }, scales: { x: { display: true, ticks: { maxTicksLimit: 8, // Limit number of x-axis labels maxRotation: 0, // Don't rotate labels autoSkip: true, // Automatically skip some labels color: '#FFFFFF', font: { family: "'VT323', monospace", // Terminal font size: 14 } }, grid: { color: '#333333', lineWidth: 0.5 } }, y: { title: { display: true, text: 'HASHRATE (TH/S)', color: theme.PRIMARY, font: { family: "'VT323', monospace", size: 16, weight: 'bold' } }, ticks: { color: '#FFFFFF', maxTicksLimit: 6, // Limit total number of ticks precision: 1, // Control decimal precision autoSkip: true, // Skip labels to prevent overcrowding autoSkipPadding: 10, // Padding between skipped labels font: { family: "'VT323', monospace", // Terminal font size: 14 }, callback: function (value) { // For zero, just return 0 if (value === 0) return '0'; // For large values (1000+ TH/s), show in PH/s if (value >= 1000) { return (value / 1000).toFixed(1) + ' PH'; } // For values between 10 and 1000 TH/s else if (value >= 10) { return Math.round(value); } // For small values, limit decimal places else if (value >= 1) { return value.toFixed(1); } // For tiny values, use appropriate precision else { return value.toPrecision(2); } } }, grid: { color: '#333333', lineWidth: 0.5, drawBorder: false, zeroLineColor: '#555555', zeroLineWidth: 1, drawTicks: false } } }, plugins: { tooltip: { backgroundColor: 'rgba(0, 0, 0, 0.8)', titleColor: theme.PRIMARY, bodyColor: '#FFFFFF', titleFont: { family: "'VT323', monospace", size: 16, weight: 'bold' }, bodyFont: { family: "'VT323', monospace", size: 14 }, padding: 10, cornerRadius: 0, displayColors: false, callbacks: { title: function (tooltipItems) { return tooltipItems[0].label.toUpperCase(); }, label: function (context) { // Format tooltip values with appropriate unit const value = context.raw; return 'HASHRATE: ' + formatHashrateForDisplay(value).toUpperCase(); } } }, legend: { display: false }, annotation: hasAnnotationPlugin ? { annotations: { averageLine: { type: 'line', yMin: 0, yMax: 0, borderColor: theme.CHART.ANNOTATION, borderWidth: 3, borderDash: [8, 4], shadowColor: `rgba(${theme.PRIMARY_RGB}, 0.5)`, shadowBlur: 8, shadowOffsetX: 0, shadowOffsetY: 0, label: { enabled: true, content: '24HR AVG: 0 TH/S', backgroundColor: 'rgba(0,0,0,0.8)', color: theme.CHART.ANNOTATION, font: { family: "'VT323', monospace", size: 16, weight: 'bold' }, padding: { top: 4, bottom: 4, left: 8, right: 8 }, borderRadius: 0, position: 'start' } } } } : {} } } }); } catch (error) { console.error("Error initializing chart:", error); return null; } } // Helper function to safely format numbers with commas function numberWithCommas(x) { if (x == null) return "N/A"; return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } // Server time update via polling function updateServerTime() { $.ajax({ url: "/api/time", method: "GET", timeout: 5000, success: function (data) { // Calculate the offset between server time and local time serverTimeOffset = new Date(data.server_timestamp).getTime() - Date.now(); serverStartTime = new Date(data.server_start_time).getTime(); // Update BitcoinMinuteRefresh with server time info BitcoinMinuteRefresh.updateServerTime(serverTimeOffset, serverStartTime); console.log("Server time synchronized. Offset:", serverTimeOffset, "ms"); }, error: function (jqXHR, textStatus, errorThrown) { console.error("Error fetching server time:", textStatus, errorThrown); } }); } // Update UI indicators (arrows) - replaced with ArrowIndicator call function updateIndicators(newMetrics) { arrowIndicator.updateIndicators(newMetrics); } // Helper function to safely update element text content function updateElementText(elementId, text) { const element = document.getElementById(elementId); if (element) { element.textContent = text; } } // Helper function to safely update element HTML content function updateElementHTML(elementId, html) { const element = document.getElementById(elementId); if (element) { element.innerHTML = html; } } // Update workers_hashing value from metrics, but don't try to access worker details function updateWorkersCount() { if (latestMetrics && latestMetrics.workers_hashing !== undefined) { $("#workers_hashing").text(latestMetrics.workers_hashing || 0); // Update miner status with online/offline indicator based on worker count if (latestMetrics.workers_hashing > 0) { updateElementHTML("miner_status", "ONLINE "); } else { updateElementHTML("miner_status", "OFFLINE "); } } } // Check for block updates and show congratulatory messages function checkForBlockUpdates(data) { if (previousMetrics.last_block_height !== undefined && data.last_block_height !== previousMetrics.last_block_height) { showCongrats("Congrats! New Block Found: " + data.last_block_height); } if (previousMetrics.blocks_found !== undefined && data.blocks_found !== previousMetrics.blocks_found) { showCongrats("Congrats! Blocks Found updated: " + data.blocks_found); } } // Helper function to show congratulatory messages with timestamps function showCongrats(message) { const $congrats = $("#congratsMessage"); // Add timestamp to the message const now = new Date(Date.now() + serverTimeOffset); // Use server time offset for accuracy const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }; const timeString = now.toLocaleTimeString('en-US', options); // Format the message with the timestamp const messageWithTimestamp = `${message} [${timeString}]`; // Display the message $congrats.text(messageWithTimestamp).fadeIn(500, function () { setTimeout(function () { $congrats.fadeOut(500); }, 900000); // 15 minutes fade out }); } // Enhanced Chart Update Function to handle temporary hashrate spikes // Modified the updateChartWithNormalizedData function to ensure the 24hr avg line is visible in low hashrate mode // Enhanced Chart Update Function with localStorage persistence function updateChartWithNormalizedData(chart, data) { if (!chart || !data) { console.warn("Cannot update chart - chart or data is null"); return; } try { // Try to load lowHashrate state from localStorage first const storedLowHashrateState = localStorage.getItem('lowHashrateState'); // Initialize mode state by combining stored state with defaults if (!chart.lowHashrateState) { const defaultState = { isLowHashrateMode: false, highHashrateSpikeTime: 0, spikeCount: 0, lowHashrateConfirmTime: 0, modeSwitchTimeoutId: null, lastModeChange: 0, stableModePeriod: 600000 }; // If we have stored state, use it if (storedLowHashrateState) { try { const parsedState = JSON.parse(storedLowHashrateState); chart.lowHashrateState = { ...defaultState, ...parsedState, // Reset any volatile state that shouldn't persist highHashrateSpikeTime: parsedState.highHashrateSpikeTime || 0, modeSwitchTimeoutId: null }; console.log("Restored low hashrate mode from localStorage:", chart.lowHashrateState.isLowHashrateMode); } catch (e) { console.error("Error parsing stored low hashrate state:", e); chart.lowHashrateState = defaultState; } } else { chart.lowHashrateState = defaultState; } } // Get values with enhanced stability let useHashrate3hr = false; const currentTime = Date.now(); const LOW_HASHRATE_THRESHOLD = 0.01; // TH/s const HIGH_HASHRATE_THRESHOLD = 20.0; // TH/s const MODE_SWITCH_DELAY = 120000; // Increase to 2 minutes for more stability const CONSECUTIVE_SPIKES_THRESHOLD = 3; // Increase to require more consistent high readings const MIN_MODE_STABILITY_TIME = 120000; // 2 minutes minimum between mode switches // Check if we changed modes recently - enforce a minimum stability period const timeSinceLastModeChange = currentTime - chart.lowHashrateState.lastModeChange; const enforceStabilityPeriod = timeSinceLastModeChange < MIN_MODE_STABILITY_TIME; // IMPORTANT: Calculate normalized hashrate values const normalizedHashrate60sec = normalizeHashrate(data.hashrate_60sec || 0, data.hashrate_60sec_unit || 'th/s'); const normalizedHashrate3hr = normalizeHashrate(data.hashrate_3hr || 0, data.hashrate_3hr_unit || 'th/s'); const normalizedAvg = normalizeHashrate(data.hashrate_24hr || 0, data.hashrate_24hr_unit || 'th/s'); // First check if we should use 3hr data based on the stored state useHashrate3hr = chart.lowHashrateState.isLowHashrateMode; // Case 1: Currently in low hashrate mode if (chart.lowHashrateState.isLowHashrateMode) { // Default to staying in low hashrate mode useHashrate3hr = true; // If we're enforcing stability, don't even check for mode change if (!enforceStabilityPeriod && normalizedHashrate60sec >= HIGH_HASHRATE_THRESHOLD) { // Only track spikes if we aren't in stability enforcement period if (!chart.lowHashrateState.highHashrateSpikeTime) { chart.lowHashrateState.highHashrateSpikeTime = currentTime; console.log("High hashrate spike detected in low hashrate mode"); } // Increment spike counter chart.lowHashrateState.spikeCount++; console.log(`Spike count: ${chart.lowHashrateState.spikeCount}/${CONSECUTIVE_SPIKES_THRESHOLD}`); // Check if spikes have persisted long enough const spikeElapsedTime = currentTime - chart.lowHashrateState.highHashrateSpikeTime; if (chart.lowHashrateState.spikeCount >= CONSECUTIVE_SPIKES_THRESHOLD && spikeElapsedTime > MODE_SWITCH_DELAY) { useHashrate3hr = false; chart.lowHashrateState.isLowHashrateMode = false; chart.lowHashrateState.highHashrateSpikeTime = 0; chart.lowHashrateState.spikeCount = 0; chart.lowHashrateState.lastModeChange = currentTime; console.log("Exiting low hashrate mode after sustained high hashrate"); // Save state changes to localStorage saveLowHashrateState(chart.lowHashrateState); } else { console.log(`Remaining in low hashrate mode despite spike (waiting: ${Math.round(spikeElapsedTime / 1000)}/${MODE_SWITCH_DELAY / 1000}s, count: ${chart.lowHashrateState.spikeCount}/${CONSECUTIVE_SPIKES_THRESHOLD})`); } } else { // Don't reset counters immediately on every drop - make the counter more persistent if (chart.lowHashrateState.spikeCount > 0 && normalizedHashrate60sec < HIGH_HASHRATE_THRESHOLD) { // Don't reset immediately, use a gradual decay approach if (Math.random() < 0.2) { // 20% chance to decrement counter each update chart.lowHashrateState.spikeCount--; console.log("Spike counter decayed to:", chart.lowHashrateState.spikeCount); // Save state changes to localStorage saveLowHashrateState(chart.lowHashrateState); } } } } // Case 2: Currently in normal mode else { // Default to staying in normal mode useHashrate3hr = false; // Don't switch to low hashrate mode immediately if we recently switched modes if (!enforceStabilityPeriod && normalizedHashrate60sec < LOW_HASHRATE_THRESHOLD && normalizedHashrate3hr > LOW_HASHRATE_THRESHOLD) { // Record when low hashrate condition was first observed if (!chart.lowHashrateState.lowHashrateConfirmTime) { chart.lowHashrateState.lowHashrateConfirmTime = currentTime; console.log("Low hashrate condition detected"); // Save state changes to localStorage saveLowHashrateState(chart.lowHashrateState); } // Require at least 60 seconds of low hashrate before switching modes const lowHashrateTime = currentTime - chart.lowHashrateState.lowHashrateConfirmTime; if (lowHashrateTime > 60000) { // 1 minute useHashrate3hr = true; chart.lowHashrateState.isLowHashrateMode = true; chart.lowHashrateState.lastModeChange = currentTime; console.log("Entering low hashrate mode after persistent low hashrate condition"); // Save state changes to localStorage saveLowHashrateState(chart.lowHashrateState); } else { console.log(`Low hashrate detected but waiting for persistence: ${Math.round(lowHashrateTime / 1000)}/60s`); } } else { // Only reset the confirmation timer if we've been above threshold consistently if (chart.lowHashrateState.lowHashrateConfirmTime && currentTime - chart.lowHashrateState.lowHashrateConfirmTime > 30000) { // 30 seconds above threshold chart.lowHashrateState.lowHashrateConfirmTime = 0; console.log("Low hashrate condition cleared after consistent normal hashrate"); // Save state changes to localStorage saveLowHashrateState(chart.lowHashrateState); } else if (chart.lowHashrateState.lowHashrateConfirmTime) { console.log("Brief hashrate spike, maintaining low hashrate detection timer"); } } } // Helper function to save lowHashrateState to localStorage function saveLowHashrateState(state) { try { // Create a clean copy without circular references or functions const stateToSave = { isLowHashrateMode: state.isLowHashrateMode, highHashrateSpikeTime: state.highHashrateSpikeTime, spikeCount: state.spikeCount, lowHashrateConfirmTime: state.lowHashrateConfirmTime, lastModeChange: state.lastModeChange, stableModePeriod: state.stableModePeriod }; localStorage.setItem('lowHashrateState', JSON.stringify(stateToSave)); console.log("Saved low hashrate state:", state.isLowHashrateMode); } catch (e) { console.error("Error saving low hashrate state to localStorage:", e); } } /** * Process history data with comprehensive validation, unit normalization, and performance optimizations * @param {Object} data - The metrics data containing hashrate history * @param {Object} chart - The Chart.js chart instance to update * @param {boolean} useHashrate3hr - Whether to use 3hr average data instead of 60sec data * @param {number} normalizedAvg - The normalized 24hr average hashrate for reference */ if (data.arrow_history && data.arrow_history.hashrate_60sec) { // Validate history data try { const perfStart = performance.now(); // Performance measurement // Determine which history data to use (3hr or 60sec) with proper fallback let historyData; let dataSource; if (useHashrate3hr && data.arrow_history.hashrate_3hr && data.arrow_history.hashrate_3hr.length > 0) { historyData = data.arrow_history.hashrate_3hr; dataSource = "3hr"; chart.data.datasets[0].label = 'Hashrate Trend (3HR AVG)'; } else { historyData = data.arrow_history.hashrate_60sec; dataSource = "60sec"; chart.data.datasets[0].label = 'Hashrate Trend (60SEC AVG)'; // If we wanted 3hr data but it wasn't available, log a warning if (useHashrate3hr) { console.warn("3hr data requested but not available, falling back to 60sec data"); } } console.log(`Using ${dataSource} history data with ${historyData?.length || 0} points`); if (historyData && historyData.length > 0) { // Pre-process history data to filter out invalid entries const validHistoryData = historyData.filter(item => { return item && (typeof item.value !== 'undefined') && !isNaN(parseFloat(item.value)) && (parseFloat(item.value) >= 0) && typeof item.time === 'string'; }); if (validHistoryData.length < historyData.length) { console.warn(`Filtered out ${historyData.length - validHistoryData.length} invalid data points`); } if (validHistoryData.length === 0) { console.warn("No valid history data points after filtering"); useSingleDataPoint(); return; } // Format time labels more efficiently (do this once, not in a map callback) const timeZone = dashboardTimezone || 'America/Los_Angeles'; const now = new Date(); const yearMonthDay = { year: now.getFullYear(), month: now.getMonth(), day: now.getDate() }; // Create time formatter function with consistent options const timeFormatter = new Intl.DateTimeFormat('en-US', { timeZone: timeZone, hour: '2-digit', minute: '2-digit', hour12: true }); // Format all time labels at once const formattedLabels = validHistoryData.map(item => { const timeStr = item.time; try { // Parse time efficiently let hours = 0, minutes = 0, seconds = 0; if (timeStr.length === 8 && timeStr.indexOf(':') !== -1) { // Format: HH:MM:SS const parts = timeStr.split(':'); hours = parseInt(parts[0], 10); minutes = parseInt(parts[1], 10); seconds = parseInt(parts[2], 10); } else if (timeStr.length === 5 && timeStr.indexOf(':') !== -1) { // Format: HH:MM const parts = timeStr.split(':'); hours = parseInt(parts[0], 10); minutes = parseInt(parts[1], 10); } else { return timeStr; // Use original if format is unexpected } // Create time date with validation if (isNaN(hours) || isNaN(minutes) || isNaN(seconds) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59) { return timeStr; // Use original for invalid times } const timeDate = new Date(yearMonthDay.year, yearMonthDay.month, yearMonthDay.day, hours, minutes, seconds); // Format using the formatter const formatted = timeFormatter.format(timeDate); return formatted.replace(/\s[AP]M$/i, ''); // Remove AM/PM } catch (e) { console.error("Time formatting error:", e); return timeStr; // Use original on error } }); chart.data.labels = formattedLabels; // Process and normalize hashrate values with validation (optimize by avoiding multiple iterations) const hashrateValues = []; const validatedData = new Array(validHistoryData.length); // Enhanced unit validation const validUnits = new Set(['th/s', 'ph/s', 'eh/s', 'gh/s', 'mh/s', 'zh/s']); // Process all data points with error boundaries around each item for (let i = 0; i < validHistoryData.length; i++) { try { const item = validHistoryData[i]; // Safety conversion in case value is a string const val = parseFloat(item.value); // Get unit with better validation let unit = (item.unit || 'th/s').toLowerCase().trim(); // Use storeHashrateWithUnit to properly handle unit conversions for large values // This increases chart precision by storing values in appropriate units if (typeof window.storeHashrateWithUnit === 'function') { // Use our specialized function if available const storedFormat = window.storeHashrateWithUnit(val, unit); const normalizedValue = normalizeHashrate(val, unit); // Store the properly adjusted values for tooltip display item.storageValue = storedFormat.value; item.storageUnit = storedFormat.unit; item.originalValue = val; item.originalUnit = unit; validatedData[i] = normalizedValue; // Collect valid values for statistics if (normalizedValue > 0) { hashrateValues.push(normalizedValue); } } else { // Original approach if storeHashrateWithUnit isn't available const normalizedValue = normalizeHashrate(val, unit); // Store original values for tooltip reference item.originalValue = val; item.originalUnit = unit; validatedData[i] = normalizedValue; // Collect valid values for statistics if (normalizedValue > 0) { hashrateValues.push(normalizedValue); } } } catch (err) { console.error(`Error processing hashrate at index ${i}:`, err); validatedData[i] = 0; // Use 0 as a safe fallback } } // Assign the processed data to chart chart.data.datasets[0].data = validatedData; chart.originalData = validHistoryData; // Store for tooltip reference // Update tooltip callback to display proper units chart.options.plugins.tooltip.callbacks.label = function (context) { const index = context.dataIndex; const originalData = chart.originalData?.[index]; if (originalData) { if (originalData.storageValue !== undefined && originalData.storageUnit) { // Use the optimized storage value/unit if available return `HASHRATE: ${originalData.storageValue} ${originalData.storageUnit.toUpperCase()}`; } else if (originalData.originalValue !== undefined && originalData.originalUnit) { // Fall back to original values return `HASHRATE: ${originalData.originalValue} ${originalData.originalUnit.toUpperCase()}`; } } // Last resort fallback return 'HASHRATE: ' + formatHashrateForDisplay(context.raw).toUpperCase(); }; // Calculate statistics for anomaly detection with optimization if (hashrateValues.length > 1) { // Calculate mean, min, max in a single pass for efficiency let sum = 0, min = Infinity, max = -Infinity; for (let i = 0; i < hashrateValues.length; i++) { const val = hashrateValues[i]; sum += val; if (val < min) min = val; if (val > max) max = val; } const mean = sum / hashrateValues.length; // Enhanced outlier detection const standardDeviation = calculateStandardDeviation(hashrateValues, mean); const outlierThreshold = 3; // Standard deviations // Check for outliers using both range and statistical methods const hasOutliersByRange = (max > mean * 10 || min < mean / 10); const hasOutliersByStats = hashrateValues.some(v => Math.abs(v - mean) > outlierThreshold * standardDeviation); // Log more helpful diagnostics for outliers if (hasOutliersByRange || hasOutliersByStats) { console.warn("WARNING: Hashrate variance detected in chart data. Possible unit inconsistency."); console.warn(`Stats: Min: ${min.toFixed(2)}, Max: ${max.toFixed(2)}, Mean: ${mean.toFixed(2)}, StdDev: ${standardDeviation.toFixed(2)} TH/s`); // Give more specific guidance if (max > 1000 && min < 10) { console.warn("ADVICE: Data contains mixed units (likely TH/s and PH/s). Check API response consistency."); } } // Log performance timing for large datasets if (hashrateValues.length > 100) { const perfEnd = performance.now(); console.log(`Processed ${hashrateValues.length} hashrate points in ${(perfEnd - perfStart).toFixed(1)}ms`); } } // Find filtered valid values for y-axis limits (more efficient than creating a new array) let activeValues = 0, yMin = Infinity, yMax = -Infinity; for (let i = 0; i < validatedData.length; i++) { const v = validatedData[i]; if (!isNaN(v) && v !== null && v > 0) { activeValues++; if (v < yMin) yMin = v; if (v > yMax) yMax = v; } } if (activeValues > 0) { // Optimized y-axis range calculation with padding const padding = useHashrate3hr ? 0.5 : 0.2; // More padding in low hashrate mode // When in low hashrate mode, ensure the y-axis includes the 24hr average if (useHashrate3hr && normalizedAvg > 0) { // Ensure the 24-hour average is visible with adequate padding const minPadding = normalizedAvg * padding; const maxPadding = normalizedAvg * padding; chart.options.scales.y.min = Math.min(yMin * (1 - padding), normalizedAvg - minPadding); chart.options.scales.y.max = Math.max(yMax * (1 + padding), normalizedAvg + maxPadding); console.log(`Low hashrate mode: Y-axis range [${chart.options.scales.y.min.toFixed(2)}, ${chart.options.scales.y.max.toFixed(2)}] TH/s`); } else { // Normal mode scaling with smarter padding (less padding for large ranges) const dynamicPadding = Math.min(0.2, 10 / yMax); // Reduce padding as max increases chart.options.scales.y.min = Math.max(0, yMin * (1 - dynamicPadding)); // Never go below zero chart.options.scales.y.max = yMax * (1 + dynamicPadding); } // Set appropriate step size based on range - improved algorithm const range = chart.options.scales.y.max - chart.options.scales.y.min; // Dynamic target ticks based on chart height for better readability const chartHeight = chart.height || 300; const targetTicks = Math.max(4, Math.min(8, Math.floor(chartHeight / 50))); // Calculate ideal step size const rawStepSize = range / targetTicks; // Find a "nice" step size that's close to the raw step size const stepSize = calculateNiceStepSize(rawStepSize); // Set the calculated stepSize chart.options.scales.y.ticks.stepSize = stepSize; // Log the chosen stepSize console.log(`Y-axis range: ${range.toFixed(2)} TH/s, using stepSize: ${stepSize} (target ticks: ${targetTicks})`); } } else { console.warn("No history data items available"); useSingleDataPoint(); } } catch (historyError) { console.error("Error processing hashrate history data:", historyError); // Fall back to single datapoint if history processing fails useSingleDataPoint(); } } else { // No history data, use single datapoint useSingleDataPoint(); } /** * Calculate standard deviation of an array of values * @param {Array