Add generic application statistics overlay (#168)

* Add stat module initial example

* Add stats module help overlay overlap handling

* Add generic graph canvas image builder

* Add more generic graphing module

* Clean up the code

* Some fixes

* Change graphing styles

* Clean some stats module code

* Move to RAF rendering to burn your CPU

* Add not standardized memory stats module

* Fix initial stats visibility flag handle
This commit is contained in:
sergystepanov 2020-04-26 17:19:00 +03:00 committed by GitHub
parent 23c2f5b4ee
commit e05ae221e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 437 additions and 10 deletions

54
web/css/main.css vendored
View file

@ -381,6 +381,7 @@ body {
}
#bottom-screen {
position: absolute;
/* popups under the screen fix */
z-index: -1;
}
@ -391,6 +392,7 @@ body {
height: 100%;
display: none;
background-color: #222222;
position: absolute;
}
@ -598,3 +600,55 @@ body {
touch-action: manipulation;
}
#stats-overlay {
position: absolute;
z-index: 200;
backface-visibility: hidden;
display: flex;
flex-direction: column;
justify-content: space-around;
top: 1.1em;
right: 1.1em;
color: #fff;
background: #000;
opacity: .765;
padding: .5em 1em .1em 1em;
font-family: monospace;
font-size: 40%;
width: 70px;
visibility: hidden;
}
#stats-overlay > div {
display: flex;
flex-flow: wrap;
justify-content: space-between;
margin-bottom: 1em;
}
#stats-overlay>div>div {
display: inline-block;
font-weight: 500;
width: 4em;
}
#stats-overlay .graph {
width: 100%;
/* artifacts with pixelated option */
/*image-rendering: pixelated;*/
image-rendering: optimizeSpeed;
}
.no-select {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}

8
web/game.html vendored
View file

@ -15,7 +15,7 @@
<meta property="og:author" content="giongto35 trichimtrich" />
<link href="/static/css/font-awesome.css?2" rel="stylesheet">
<link href="/static/css/main.css?3" rel="stylesheet">
<link href="/static/css/main.css?4" rel="stylesheet">
</head>
<body>
@ -31,6 +31,7 @@
</div>
<div id="bottom-screen">
<div id="stats-overlay" class="no-select" hidden></div>
<!--NOTE: New browser doesn't allow unmuted video player. So we muted here.
There is still audio because current audio flow is not from media but it is manually encoded (technical webRTC challenge). Later, when we can integrate audio to media, we can face the issue with mute again .
https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
@ -87,18 +88,19 @@
<script src="/static/js/lib/jquery-3.4.1.min.js"></script>
<script src="/static/js/log.js?v=3"></script>
<script src="/static/js/env.js?v=4"></script>
<script src="/static/js/env.js?v=5"></script>
<script src="/static/js/event/event.js?v=4"></script>
<script src="/static/js/input/keys.js?v=3"></script>
<script src="/static/js/input/input.js?v=3"></script>
<script src="/static/js/gameList.js?v=3"></script>
<script src="/static/js/room.js?v=3"></script>
<script src="/static/js/stats/stats.js?v=1"></script>
<script src="/static/js/controller.js?v=4"></script>
<script src="/static/js/input/keyboard.js?v=3"></script>
<script src="/static/js/input/touch.js?v=3"></script>
<script src="/static/js/input/joystick.js?v=3"></script>
<script src="/static/js/network/ajax.js?v=3"></script>
<script src="/static/js/network/socket.js?v=3"></script>
<script src="/static/js/network/socket.js?v=4"></script>
<script src="/static/js/network/rtcp.js?v=3"></script>
<script src="/static/js/init.js?v=3"></script>

22
web/js/controller.js vendored
View file

@ -67,7 +67,7 @@
// undo the state when release the button
prevState: null,
// use function () if you need "this"
show: function (show) {
show: function (show, event) {
if (this.shown === show) return;
// hack
@ -88,6 +88,8 @@
} else {
setState(this.prevState);
}
if (event) event.pub(HELP_OVERLAY_TOGGLED, {shown: show});
}
};
@ -170,7 +172,9 @@
keyButtons[data.key].addClass('pressed');
}
if (KEY.HELP === data.key) helpScreen.show(true);
if (KEY.HELP === data.key) {
helpScreen.show(true, event);
}
state.keyPress(data.key);
};
@ -182,7 +186,9 @@
keyButtons[data.key].removeClass('pressed');
}
if (KEY.HELP === data.key) helpScreen.show(false);
if (KEY.HELP === data.key) {
helpScreen.show(false, event);
}
// maybe move it somewhere
if (!interacted) {
@ -267,6 +273,9 @@
case KEY.SAVE:
popup('Lets play to save game!');
break;
case KEY.STATS:
event.pub(STATS_TOGGLE);
break;
}
},
menuReady: () => {
@ -325,6 +334,10 @@
location.reload();
break;
case KEY.STATS:
event.pub(STATS_TOGGLE);
break;
}
},
@ -370,5 +383,4 @@
// initial app state
setState(app.state.eden);
})($, document, event, env, gameList, input, KEY, log, room);
})($, document, event, env, gameList, input, KEY, log, room, stats);

View file

@ -56,6 +56,8 @@ const event = (() => {
// events
const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested';
const PING_REQUEST = 'pingRequest';
const PING_RESPONSE = 'pingResponse';
const GAME_ROOM_AVAILABLE = 'gameRoomAvailable';
const GAME_SAVED = 'gameSaved';
@ -79,3 +81,6 @@ const MENU_RELEASED = 'menuReleased';
const KEY_PRESSED = 'keyPressed';
const KEY_RELEASED = 'keyReleased';
const KEY_STATE_UPDATED = 'keyStateUpdated';
const STATS_TOGGLE = 'statsToggle';
const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled';

View file

@ -29,6 +29,7 @@ const keyboard = (() => {
52: KEY.PAD4, // 4
70: KEY.FULL, // f
72: KEY.HELP, // h
220: KEY.STATS, // backslash
};
const onKey = (code, callback) => {

View file

@ -22,5 +22,6 @@ const KEY = (() => {
PAD2: 'pad2',
PAD3: 'pad3',
PAD4: 'pad4',
STATS: 'stats',
}
})();

View file

@ -55,7 +55,7 @@ const socket = (() => {
event.pub(MEDIA_STREAM_READY);
break;
case 'heartbeat':
// reserved
event.pub(PING_RESPONSE);
break;
case 'start':
event.pub(GAME_ROOM_AVAILABLE, {roomId: data.room_id});
@ -78,7 +78,11 @@ const socket = (() => {
};
// TODO: format the package with time
const ping = () => send({"id": "heartbeat", "data": Date.now().toString()});
const ping = () => {
const time = Date.now();
send({"id": "heartbeat", "data": time.toString()});
event.pub(PING_REQUEST, {time: time});
}
const send = (data) => conn.send(JSON.stringify(data));
const latency = (workers, packetId) => send({
"id": "checkLatency",

348
web/js/stats/stats.js vendored Normal file
View file

@ -0,0 +1,348 @@
/**
* App statistics module.
*
* Events:
* <- STATS_TOGGLE
* <- HELP_OVERLAY_TOGGLED
*
* @version 1
*/
const stats = (() => {
const modules = [];
let tempHide = false;
// internal rendering stuff
const drawFps = 32;
let time = 0;
let active = false;
// !to add connection drop notice
// UI
const statsOverlayEl = document.getElementById('stats-overlay');
/**
* The graph element.
*
* @param parent
* @param opts
*/
const graph = (parent, opts = {
historySize: 60,
width: 60 * 2 + 4,
height: 20,
pad: 4,
scale: 1,
style: {
barColor: '#9bd914',
barFallColor: '#c12604'
}
}) => {
const _canvas = document.createElement('canvas');
const _context = _canvas.getContext('2d');
let data = [];
_canvas.setAttribute('class', 'graph');
_canvas.width = opts.width * opts.scale;
_canvas.height = opts.height * opts.scale;
_context.scale(opts.scale, opts.scale);
_context.imageSmoothingEnabled = false;
_context.fillStyle = opts.fillStyle;
if (parent) parent.append(_canvas);
// bar size
const barWidth = Math.round(_canvas.width / opts.scale / opts.historySize);
const barHeight = Math.round(_canvas.height / opts.scale);
let maxN = 0,
minN = 0;
const max = () => maxN
const get = () => _canvas
const add = (value) => {
if (data.length > opts.historySize) data.shift();
data.push(value);
render();
}
/**
* Draws a bar graph on the canvas.
*/
const render = () => {
// 0,0 w,0 0,0 w,0 0,0 w,0
// +-------+ +-------+ +---------+
// | | |+---+ | |+---+ |
// | | |||||| | ||||||+---+
// | | |||||| | |||||||||||
// +-------+ +----+--+ +---------+
// 0,h w,h 0,h w,h 0,h w,h
// [] [3] [3, 2]
//
// O(N+N) :( can be O(1) without visual scale
_context.clearRect(0, 0, _canvas.width, _canvas.height);
maxN = data[0] || 1;
minN = 0;
for (let k = 1; k < data.length; k++) {
if (data[k] > maxN) maxN = data[k];
if (data[k] < minN) minN = data[k];
}
for (let j = 0; j < data.length; j++) {
let x = j * barWidth,
y = (barHeight - opts.pad * 2) * (data[j] - minN) / (maxN - minN) + opts.pad;
const color = j > 0 && data[j] > data[j - 1] ? opts.style.barFallColor : opts.style.barColor;
drawRect(x, barHeight - Math.round(y), barWidth, barHeight, color);
}
}
const drawRect = (x, y, w, h, color = opts.style.barColor) => {
_context.fillStyle = color;
_context.fillRect(x, y, w, h);
}
return {add, get, max, render}
}
/**
* Get cached module UI.
*
* HTML:
* <div><div>LABEL</div><span>VALUE</span>[<span><canvas/><span>]</div>
*
* Returns exposed ui sub-tree and the _value as only changing node.
*
* @param label The name of the stat to show.
* @param withGraph True if to draw a graph.
* @param postfix The name of dimension of the stat.
* @returns {{el: HTMLDivElement, update: function}}
*/
const moduleUi = (label = '', withGraph = false, postfix = 'ms') => {
const ui = document.createElement('div'),
_label = document.createElement('div'),
_value = document.createElement('span');
ui.append(_label, _value);
let postfix_ = postfix;
let _graph;
if (withGraph) {
const _container = document.createElement('span');
ui.append(_container);
_graph = graph(_container);
}
_label.innerHTML = label;
const withPostfix = (value) => postfix_ = value;
const update = (value) => {
if (_graph) _graph.add(value);
// 203 (333) ms
_value.textContent = `${value < 1 ? '<1' : value} ${_graph ? `(${_graph.max()}) ` : ''}${postfix_}`;
}
return {el: ui, update, withPostfix}
}
/**
* Latency stats submodule.
*
* Accumulates the simple rolling delta mean value
* between a server request and a following server response values.
*
* window
* _____________
* | |
* [1, 1, 3, 4, 1, 4, 3, 1, 2, 1, 1, 1, 2, ... n]
* |
* stats_snapshot_period
* mean = round(next - mean / length % window)
*
* Events:
* <- PING_RESPONSE
* <- PING_REQUEST
*
* ?Interface:
* HTMLElement get()
* void enable()
* void disable()
* void render()
*
* @version 1
*/
const latency = (() => {
let listeners = [];
let mean = 0;
let length = 0;
let previous = Date.now();
const window = 5;
// UI
const ui = moduleUi('Ping', true);
const onPingRequest = (data) => previous = data.time;
const onPingResponse = () => {
length++;
const delta = Date.now() - previous;
mean += Math.round((delta - mean) / length);
if (length % window === 0) {
length = 1;
mean = delta;
}
}
const enable = () => {
listeners.push(
event.sub(PING_RESPONSE, onPingResponse),
event.sub(PING_REQUEST, onPingRequest)
);
}
const disable = () => {
while (listeners.length) listeners.shift().unsub();
}
const render = () => ui.update(mean);
const get = () => ui.el;
return {get, enable, disable, render}
})(event, moduleUi);
/**
* User agent memory stats.
*
* ?Interface:
* HTMLElement get()
* void enable()
* void disable()
* void render()
*
* @version 1
*/
const clientMemory = (() => {
let active = false;
const ui = moduleUi('Memory', false, 'B');
if (window.performance && !performance.memory)
performance.memory = {usedJSHeapSize: 0, totalJSHeapSize: 0};
const convert = (() => {
const measures = ['B', 'KB', 'MB', 'GB'];
const toSize = (bytes, fractions = 2) => {
if (bytes === 0) return 0;
const precision = Math.pow(10, fractions);
const i = Math.floor(Math.log(bytes) / Math.log(1000));
// hack
ui.withPostfix(measures[i]);
return Math.round(bytes * precision / Math.pow(1000, i)) / precision;
}
return {toSize}
})();
const get = () => ui.el;
const enable = () => {
active = true;
render();
}
const disable = () => active = false;
const render = () => {
if (!active) return;
const m = performance.memory.usedJSHeapSize;
ui.update(m > 0 ? convert.toSize(m) : 'N/A');
}
return {get, enable, disable, render}
})(moduleUi, performance, window);
const enable = () => {
active = true;
modules.forEach(m => m.enable());
render();
draw();
_show();
};
function draw(timestamp) {
if (!active) return;
const time_ = time + 1000 / drawFps;
if (timestamp > time_) {
time = timestamp;
render();
}
requestAnimationFrame(draw);
}
const disable = () => {
active = false;
modules.forEach(m => m.disable());
_hide();
}
const _show = () => statsOverlayEl.style.visibility = 'visible';
const _hide = () => statsOverlayEl.style.visibility = 'hidden';
const onToggle = () => active ? disable() : enable();
/**
* Handles help overlay toggle event.
* Workaround for a not normal app layout layering.
*
* !to make it more declarative
* !to remove when app layering is fixed
*
* @param {Object} overlay Overlay data.
* @param {boolean} overlay.shown A flag if the overlay is being currently showed.
*/
const onHelpOverlayToggle = (overlay) => {
if (statsOverlayEl.style.visibility === 'visible' && overlay.shown && !tempHide) {
_hide();
tempHide = true;
} else {
if (tempHide) {
_show();
tempHide = false;
}
}
}
const render = () => modules.forEach(m => m.render());
// add submodules
modules.push(
latency,
clientMemory
);
modules.forEach(m => statsOverlayEl.append(m.get()));
event.sub(STATS_TOGGLE, onToggle);
event.sub(HELP_OVERLAY_TOGGLED, onHelpOverlayToggle)
return {enable, disable}
})(document, event, log, window);