import uPlot from '../lib/uPlot.esm' import { RelativeScale } from './scale' import { formatNumber, formatTimestamp, isMobileBrowser } from './util' import { uPlotTooltipPlugin } from './tooltip' import { FAVORITE_SERVERS_STORAGE_KEY } from './favorites' const HIDDEN_SERVERS_STORAGE_KEY = 'minetrack_hidden_servers' const SHOW_FAVORITES_STORAGE_KEY = 'minetrack_show_favorites' export class GraphDisplayManager { // Only emit graph data request if not on mobile due to graph data size isVisible = !isMobileBrowser() constructor (app) { this._app = app this._graphData = [] this._graphTimestamps = [] this._hasLoadedSettings = false this._initEventListenersOnce = false this._showOnlyFavorites = false } addGraphPoint (serverId, timestamp, playerCount) { if (!this._hasLoadedSettings) { // _hasLoadedSettings is controlled by #setGraphData // It will only be true once the context has been loaded and initial payload received // #addGraphPoint should not be called prior to that since it means the data is racing // and the application has received updates prior to the initial state return } // FIXME } loadLocalStorage () { if (typeof localStorage !== 'undefined') { const showOnlyFavorites = localStorage.getItem(SHOW_FAVORITES_STORAGE_KEY) if (showOnlyFavorites) { this._showOnlyFavorites = true } // If only favorites mode is active, use the stored favorite servers data instead let serverNames if (this._showOnlyFavorites) { serverNames = localStorage.getItem(FAVORITE_SERVERS_STORAGE_KEY) } else { serverNames = localStorage.getItem(HIDDEN_SERVERS_STORAGE_KEY) } if (serverNames) { serverNames = JSON.parse(serverNames) // Iterate over all active serverRegistrations // This merges saved state with current state to prevent desyncs for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) { // isVisible will be true if showOnlyFavorites && contained in FAVORITE_SERVERS_STORAGE_KEY // OR, if it is NOT contains within HIDDEN_SERVERS_STORAGE_KEY // Checks between FAVORITE/HIDDEN keys are mutually exclusive if (this._showOnlyFavorites) { serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) >= 0 } else { serverRegistration.isVisible = serverNames.indexOf(serverRegistration.data.name) < 0 } } } } } updateLocalStorage () { if (typeof localStorage !== 'undefined') { // Mutate the serverIds array into server names for storage use const serverNames = this._app.serverRegistry.getServerRegistrations() .filter(serverRegistration => !serverRegistration.isVisible) .map(serverRegistration => serverRegistration.data.name) // Only store if the array contains data, otherwise clear the item // If showOnlyFavorites is true, do NOT store serverNames since the state will be auto managed instead if (serverNames.length > 0 && !this._showOnlyFavorites) { localStorage.setItem(HIDDEN_SERVERS_STORAGE_KEY, JSON.stringify(serverNames)) } else { localStorage.removeItem(HIDDEN_SERVERS_STORAGE_KEY) } // Only store SHOW_FAVORITES_STORAGE_KEY if true if (this._showOnlyFavorites) { localStorage.setItem(SHOW_FAVORITES_STORAGE_KEY, true) } else { localStorage.removeItem(SHOW_FAVORITES_STORAGE_KEY) } } } getVisibleGraphData () { return this._app.serverRegistry.getServerRegistrations() .filter(serverRegistration => serverRegistration.isVisible) .map(serverRegistration => this._graphData[serverRegistration.serverId]) } getPlotSize () { return { width: Math.max(window.innerWidth, 800) * 0.9, height: 400 } } buildPlotInstance (graphData) { // Lazy load settings from localStorage, if any and if enabled if (!this._hasLoadedSettings) { this._hasLoadedSettings = true this.loadLocalStorage() } // FIXME: timestamps are not shared! this._graphTimestamps = graphData[0].map(val => Math.floor(val[0] / 1000)) this._graphData = Object.values(graphData).map(val => { return val.map(element => { // Safely handle null data points, they represent gaps in the graph return element === null ? null : element[1] }) }) const series = this._app.serverRegistry.getServerRegistrations().map(serverRegistration => { return { scale: 'Players', stroke: serverRegistration.data.color, width: 2, value: (_, raw) => formatNumber(raw) + ' Players', show: serverRegistration.isVisible } }) const tickCount = 10 // eslint-disable-next-line new-cap this._plotInstance = new uPlot({ plugins: [ uPlotTooltipPlugin((pos, id, plot) => { if (pos) { // FIXME let text = '' + formatTimestamp(this._graphTimestamps[id] * 1000) + '

' for (let i = 1; i < plot.series.length; i++) { const serverRegistration = this._app.serverRegistry.getServerRegistration(i - 1) const serverGraphData = this._graphData[serverRegistration.serverId] let playerCount if (id >= serverGraphData.length || typeof serverGraphData[id] !== 'number') { playerCount = '-' } else { playerCount = formatNumber(serverGraphData[id]) } text += serverRegistration.data.name + ': ' + playerCount + '
' } this._app.tooltip.set(pos.left, pos.top, 10, 10, text) } else { this._app.tooltip.hide() } }) ], ...this.getPlotSize(), cursor: { y: false }, series: [ { }, ...series ], axes: [ { font: '14px "Open Sans", sans-serif', stroke: '#FFF', grid: { show: false }, space: 60 }, { font: '14px "Open Sans", sans-serif', stroke: '#FFF', size: 60, grid: { stroke: '#333', width: 1 }, split: () => { const visibleGraphData = this.getVisibleGraphData() const [, max, scale] = RelativeScale.scaleMatrix(visibleGraphData, tickCount) const ticks = RelativeScale.generateTicks(0, max, scale) return ticks } } ], scales: { Players: { auto: false, range: () => { const visibleGraphData = this.getVisibleGraphData() const [, scaledMax] = RelativeScale.scaleMatrix(visibleGraphData, tickCount) return [0, scaledMax] } } }, legend: { show: false } }, [ this._graphTimestamps, ...this._graphData ], document.getElementById('big-graph')) // Show the settings-toggle element document.getElementById('settings-toggle').style.display = 'inline-block' } redraw = () => { // Use drawing as a hint to update settings // This may cause unnessecary localStorage updates, but its a rare and harmless outcome this.updateLocalStorage() // Copy application state into the series data used by uPlot for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) { this._plotInstance.series[serverRegistration.serverId + 1].show = serverRegistration.isVisible } this._plotInstance.redraw() } requestResize () { // Only resize when _plotInstance is defined // Set a timeout to resize after resize events have not been fired for some duration of time // This prevents burning CPU time for multiple, rapid resize events if (this._plotInstance) { if (this._resizeRequestTimeout) { clearTimeout(this._resizeRequestTimeout) } // Schedule new delayed resize call // This can be cancelled by #requestResize, #resize and #reset this._resizeRequestTimeout = setTimeout(this.resize, 200) } } resize = () => { this._plotInstance.setSize(this.getPlotSize()) // undefine value so #clearTimeout is not called // This is safe even if #resize is manually called since it removes the pending work if (this._resizeRequestTimeout) { clearTimeout(this._resizeRequestTimeout) } this._resizeRequestTimeout = undefined } initEventListeners () { if (!this._initEventListenersOnce) { this._initEventListenersOnce = true // These listeners should only be init once since they attach to persistent elements document.getElementById('settings-toggle').addEventListener('click', this.handleSettingsToggle, false) document.querySelectorAll('.graph-controls-show').forEach((element) => { element.addEventListener('click', this.handleShowButtonClick, false) }) } // These listeners should be bound each #initEventListeners call since they are for newly created elements document.querySelectorAll('.graph-control').forEach((element) => { element.addEventListener('click', this.handleServerButtonClick, false) }) } handleServerButtonClick = (event) => { const serverId = parseInt(event.target.getAttribute('minetrack-server-id')) const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId) if (serverRegistration.isVisible !== event.target.checked) { serverRegistration.isVisible = event.target.checked // Any manual changes automatically disables "Only Favorites" mode // Otherwise the auto management might overwrite their manual changes this._showOnlyFavorites = false this.redraw() } } handleShowButtonClick = (event) => { const showType = event.target.getAttribute('minetrack-show-type') // If set to "Only Favorites", set internal state so that // visible graphData is automatically updating when a ServerRegistration's #isVisible changes // This is also saved and loaded by #loadLocalStorage & #updateLocalStorage this._showOnlyFavorites = showType === 'favorites' let redraw = false this._app.serverRegistry.getServerRegistrations().forEach(function (serverRegistration) { let isVisible if (showType === 'all') { isVisible = true } else if (showType === 'none') { isVisible = false } else if (showType === 'favorites') { isVisible = serverRegistration.isFavorite } if (serverRegistration.isVisible !== isVisible) { serverRegistration.isVisible = isVisible redraw = true } }) if (redraw) { this.redraw() this.updateCheckboxes() } } handleSettingsToggle = () => { const element = document.getElementById('big-graph-controls-drawer') if (element.style.display !== 'block') { element.style.display = 'block' } else { element.style.display = 'none' } } handleServerIsFavoriteUpdate = (serverRegistration) => { // When in "Only Favorites" mode, visibility is dependent on favorite status // Redraw and update elements as needed if (this._showOnlyFavorites && serverRegistration.isVisible !== serverRegistration.isFavorite) { serverRegistration.isVisible = serverRegistration.isFavorite this.redraw() this.updateCheckboxes() } } updateCheckboxes () { document.querySelectorAll('.graph-control').forEach((checkbox) => { const serverId = parseInt(checkbox.getAttribute('minetrack-server-id')) const serverRegistration = this._app.serverRegistry.getServerRegistration(serverId) checkbox.checked = serverRegistration.isVisible }) } reset () { this._graphTimestamps = [] this._graphData = [] this._plotInstance = undefined this._hasLoadedSettings = false // Fire #clearTimeout if the timeout is currently defined if (this._resizeRequestTimeout) { clearTimeout(this._resizeRequestTimeout) this._resizeRequestTimeout = undefined } // Reset modified DOM structures document.getElementById('big-graph-checkboxes').innerHTML = '' document.getElementById('big-graph-controls').style.display = 'none' document.getElementById('settings-toggle').style.display = 'none' const graphElement = document.getElementById('big-graph') graphElement.innerHTML = '' graphElement.removeAttribute('style') } }