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'
}
}