Minetrack/assets/js/graph.js

383 lines
12 KiB
JavaScript

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 = '<strong>' + formatTimestamp(this._graphTimestamps[id]) + '</strong><br><br>'
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) {
playerCount = '-'
} else {
playerCount = formatNumber(serverGraphData[id])
}
text += serverRegistration.data.name + ': ' + playerCount + '<br>'
}
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')
}
}