import uPlot from 'uplot' import { RelativeScale } from './scale' import { formatNumber, formatTimestampSeconds } from './util' import { uPlotTooltipPlugin, uPlotIsZoomedPlugin } from './plugins' 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 { constructor (app) { this._app = app this._graphData = [] this._graphTimestamps = [] this._hasLoadedSettings = false this._initEventListenersOnce = false this._showOnlyFavorites = false this._isPlotZoomed = false } addGraphPoint (timestamp, playerCounts) { 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 } this._graphTimestamps.push(timestamp) for (let i = 0; i < playerCounts.length; i++) { this._graphData[i].push(playerCounts[i]) } // Trim all data arrays to only the relevant portion // This keeps it in sync with backend data structures const graphMaxLength = this._app.publicConfig.graphMaxLength if (this._graphTimestamps.length > graphMaxLength) { this._graphTimestamps.splice(0, this._graphTimestamps.length - graphMaxLength) } for (const series of this._graphData) { if (series.length > graphMaxLength) { series.splice(0, series.length - graphMaxLength) } } // If not zoomed, paint updated data structure // Otherwise flag the plot data as dirty with repainting to be handled by #handlePlotZoomOut // This prevents #addGraphPoint calls from resetting the graph's zoom state if (!this._isPlotZoomed) { this._plotInstance.setData(this.getGraphData()) } else { this._isPlotZoomedDataDirty = true } } 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 } } getGraphData () { return [ this._graphTimestamps, ...this._graphData ] } getGraphDataPoint (serverId, index) { const graphData = this._graphData[serverId] if (graphData && index < graphData.length && typeof graphData[index] === 'number') { return graphData[index] } } getClosestPlotSeriesIndex (idx) { let closestSeriesIndex = -1 let closestSeriesDist = Number.MAX_VALUE const plotHeight = this._plotInstance.bbox.height / devicePixelRatio for (let i = 1; i < this._plotInstance.series.length; i++) { const series = this._plotInstance.series[i] if (!series.show) { continue } const point = this._plotInstance.data[i][idx] if (typeof point === 'number') { const scale = this._plotInstance.scales[series.scale] const posY = (1 - ((point - scale.min) / (scale.max - scale.min))) * plotHeight const dist = Math.abs(posY - this._plotInstance.cursor.top) if (dist < closestSeriesDist) { closestSeriesIndex = i closestSeriesDist = dist } } } return closestSeriesIndex } buildPlotInstance (timestamps, data) { // Lazy load settings from localStorage, if any and if enabled if (!this._hasLoadedSettings) { this._hasLoadedSettings = true this.loadLocalStorage() } for (const playerCounts of data) { // Each playerCounts value corresponds to a ServerRegistration // Require each array is the length of timestamps, if not, pad at the start with null values to fit to length // This ensures newer ServerRegistrations do not left align due to a lower length const lengthDiff = timestamps.length - playerCounts.length if (lengthDiff > 0) { const padding = Array(lengthDiff).fill(null) playerCounts.unshift(...padding) } } this._graphTimestamps = timestamps this._graphData = data 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, spanGaps: true, points: { show: false } } }) const tickCount = 10 const maxFactor = 4 // eslint-disable-next-line new-cap this._plotInstance = new uPlot({ plugins: [ uPlotTooltipPlugin((pos, idx) => { if (pos) { const closestSeriesIndex = this.getClosestPlotSeriesIndex(idx) const text = this._app.serverRegistry.getServerRegistrations() .filter(serverRegistration => serverRegistration.isVisible) .sort((a, b) => { if (a.isFavorite !== b.isFavorite) { return a.isFavorite ? -1 : 1 } else { return a.data.name.localeCompare(b.data.name) } }) .map(serverRegistration => { const point = this.getGraphDataPoint(serverRegistration.serverId, idx) let serverName = serverRegistration.data.name if (closestSeriesIndex === serverRegistration.getGraphDataIndex()) { serverName = `${serverName}` } if (serverRegistration.isFavorite) { serverName = ` ${serverName}` } return `${serverName}: ${formatNumber(point)}` }).join('
') + `

${formatTimestampSeconds(this._graphTimestamps[idx])}` this._app.tooltip.set(pos.left, pos.top, 10, 10, text) } else { this._app.tooltip.hide() } }), uPlotIsZoomedPlugin(this.handlePlotZoomIn, this.handlePlotZoomOut) ], ...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: 65, grid: { stroke: '#333', width: 1 }, split: () => { const visibleGraphData = this.getVisibleGraphData() const { scaledMax, scale } = RelativeScale.scaleMatrix(visibleGraphData, tickCount, maxFactor) const ticks = RelativeScale.generateTicks(0, scaledMax, scale) return ticks } } ], scales: { Players: { auto: false, range: () => { const visibleGraphData = this.getVisibleGraphData() const { scaledMin, scaledMax } = RelativeScale.scaleMatrix(visibleGraphData, tickCount, maxFactor) return [scaledMin, scaledMax] } } }, legend: { show: false } }, this.getGraphData(), 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.getGraphDataIndex()].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 } handlePlotZoomIn = () => { this._isPlotZoomed = true } handlePlotZoomOut = () => { this._isPlotZoomed = false // Test if the data has changed while the plot was zoomed in if (this._isPlotZoomedDataDirty) { this._isPlotZoomedDataDirty = false this._plotInstance.setData(this.getGraphData()) } } 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 () { // Destroy graphs and unload references // uPlot#destroy handles listener de-registration, DOM reset, etc if (this._plotInstance) { this._plotInstance.destroy() this._plotInstance = undefined } this._graphTimestamps = [] this._graphData = [] this._hasLoadedSettings = false this._isPlotZoomed = false this._isPlotZoomedDataDirty = 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' } }