aframe-stats-panel
Version:
A-Frame statistics panel for additional statistics, in the style of A-Frame stats
283 lines (223 loc) • 8.44 kB
JavaScript
AFRAME.registerComponent('stats-panel', {
schema: {
merge: {type: 'boolean', default: true}
},
init() {
const container = document.querySelector('.rs-container')
if (container && this.data.merge) {
//stats panel exists, just merge into it.
this.container = container
return;
}
// if stats panel doesn't exist, add one to support our custom stats.
this.base = document.createElement('div')
this.base.classList.add('rs-base')
const body = document.body || document.getElementsByTagName('body')[0]
if (container && !this.data.merge) {
this.base.style.top = "auto"
this.base.style.bottom = "20px"
}
body.appendChild(this.base)
this.container = document.createElement('div')
this.container.classList.add('rs-container')
this.base.appendChild(this.container)
}
});
AFRAME.registerComponent('stats-group', {
multiple: true,
schema: {
label: {type: 'string'}
},
init() {
let container
const baseComponent = this.el.components['stats-panel']
if (baseComponent) {
container = baseComponent.container
}
else {
container = document.querySelector('.rs-container')
}
if (!container) {
console.warn(`Couldn't find stats container to add stats to.
Add either stats or stats-panel component to a-scene`)
return;
}
this.groupHeader = document.createElement('h1')
this.groupHeader.innerHTML = this.data.label
container.appendChild(this.groupHeader)
this.group = document.createElement('div')
this.group.classList.add('rs-group')
// rs-group hs style flex-direction of 'column-reverse'
// No idea why it's like that, but it's not what we want for our stats.
// We prefer them rendered in the order speified.
// So override this style.
this.group.style.flexDirection = 'column'
this.group.style.webKitFlexDirection = 'column'
container.appendChild(this.group)
}
});
AFRAME.registerComponent('stats-row', {
multiple: true,
schema: {
// name of the group to add the stats row to.
group: {type: 'string'},
// name of an event to listen for
event: {type: 'string'},
// property from event to output in stats panel
properties: {type: 'array'},
// label for the row in the stats panel
label: {type: 'string'}
},
init () {
const groupComponentName = "stats-group__" + this.data.group
const groupComponent = this.el.components[groupComponentName] ||
this.el.sceneEl.components[groupComponentName] ||
this.el.components["stats-group"] ||
this.el.sceneEl.components["stats-group"]
if (!groupComponent) {
console.warn(`Couldn't find stats group ${groupComponentName}`)
return;
}
this.counter = document.createElement('div')
this.counter.classList.add('rs-counter-base')
groupComponent.group.appendChild(this.counter)
this.counterId = document.createElement('div')
this.counterId.classList.add('rs-counter-id')
this.counterId.innerHTML = this.data.label
this.counter.appendChild(this.counterId)
this.counterValues = {}
this.data.properties.forEach((property) => {
const counterValue = document.createElement('div')
counterValue.classList.add('rs-counter-value')
counterValue.innerHTML = "..."
this.counter.appendChild(counterValue)
this.counterValues[property] = counterValue
})
this.updateStatsData = this.updateStatsData.bind(this)
this.el.addEventListener(this.data.event, this.updateStatsData)
this.splitCache = {}
},
updateStatsData(e) {
if (!this.data.properties) return
this.data.properties.forEach((property) => {
const split = this.splitDot(property);
let value = e.detail;
for (i = 0; i < split.length; i++) {
value = value[split[i]];
}
this.counterValues[property].innerHTML = value
})
},
splitDot (path) {
if (path in this.splitCache) { return this.splitCache[path]; }
this.splitCache[path] = path.split('.');
return this.splitCache[path];
}
});
AFRAME.registerComponent('stats-collector', {
multiple: true,
schema: {
// name of an event to listen for
inEvent: {type: 'string'},
// property from event to output in stats panel
properties: {type: 'array'},
// frequency of output in terms of events received.
outputFrequency: {type: 'number', default: 100},
// name of event to emit
outEvent: {type: 'string'},
// outputs (generated for each property)
// Combination of: mean, max, percentile__XX.X (where XX.X is a number)
outputs: {type: 'array'},
// Whether to output to console as well as generating events
// If a string is specified, this is output to console, together with the event data
// If no string is specified, nothing is output to console.
outputToConsole: {type: 'string'}
},
init() {
this.statsData = {}
this.resetData()
this.outputDetail = {}
this.data.properties.forEach((property) => {
this.outputDetail[property] = {}
})
this.statsReceived = this.statsReceived.bind(this)
this.el.addEventListener(this.data.inEvent, this.statsReceived)
},
resetData() {
this.counter = 0
this.data.properties.forEach((property) => {
// For calculating percentiles like 0.01 and 99.9% we'll want to store
// additional data - something like this...
// Store off outliers, and discard data.
// const min = Math.min(...this.statsData[property])
// this.lowOutliers[property].push(min)
// const max = Math.max(...this.statsData[property])
// this.highOutliers[property].push(max)
this.statsData[property] = []
})
},
statsReceived(e) {
this.updateStatsData(e.detail)
this.counter++
if (this.counter === this.data.outputFrequency) {
this.outputData()
this.resetData()
}
},
updateStatsData(detail) {
this.data.properties.forEach((property) => {
let value = detail;
value = value[property];
this.statsData[property].push(value)
})
},
outputData() {
this.data.properties.forEach((property) => {
this.data.outputs.forEach((output) => {
this.outputDetail[property][output] = this.computeOutput(output, this.statsData[property])
})
})
if (this.data.outEvent) {
this.el.emit(this.data.outEvent, this.outputDetail)
}
if (this.data.outputToConsole) {
console.log(this.data.outputToConsole, this.outputDetail)
}
},
computeOutput(outputInstruction, data) {
const outputInstructions = outputInstruction.split("__")
const outputType = outputInstructions[0]
let output
switch (outputType) {
case "mean":
output = data.reduce((a, b) => a + b, 0) / data.length;
break;
case "max":
output = Math.max(...data)
break;
case "min":
output = Math.min(...data)
break;
case "percentile":
const sorted = data.sort((a, b) => a - b)
// decimal percentiles encoded like 99+9 rather than 99.9 due to "." being used as a
// separator for nested properties.
const percentileString = outputInstructions[1].replace("_", ".")
const proportion = +percentileString / 100
// Note that this calculation of the percentile is inaccurate when there is insufficient data
// e.g. for 0.1th or 99.9th percentile when only 100 data points.
// Greater accuracy would require storing off more data (specifically outliers) and folding these
// into the computation.
const position = (data.length - 1) * proportion
const base = Math.floor(position)
const delta = position - base;
if (sorted[base + 1] !== undefined) {
output = sorted[base] + delta * (sorted[base + 1] - sorted[base]);
} else {
output = sorted[base];
}
break;
}
return output.toFixed(2)
}
});