nodegame-widgets
Version:
Collections of useful and reusable javascript / HTML snippets for nodeGame
674 lines (572 loc) • 23.1 kB
JavaScript
/**
* # RiskGauge
* Copyright(c) 2020 Stefano Balietti
* MIT Licensed
*
* Displays an interface to measure risk preferences with different methods
*
* Available methods: Holt_Laury (default), and Bomb.
*
* www.nodegame.org
*/
(function(node) {
"use strict";
node.widgets.register('RiskGauge', RiskGauge);
// ## Meta-data
RiskGauge.version = '0.8.0';
RiskGauge.description = 'Displays an interface to ' +
'measure risk preferences with different methods.';
RiskGauge.title = 'Risk Gauge';
RiskGauge.className = 'riskgauge';
RiskGauge.texts = {
// Holt Laury.
holt_laury_mainText:
'Below you find a series of hypothetical lotteries, each ' +
'contains two lotteries with different probabalities of winning. ' +
'In each row, select the lottery you would rather take part in.',
// Bomb.
// probBomb is passed as input param because it may be hidden.
bomb_mainText: function(widget, probBomb) {
var str;
str = '<p style="margin-bottom: 0.3em">';
str += 'Below there are ' + widget.totBoxes + ' black boxes. ';
str += 'Every box contains a prize of ' +
widget.boxValue + ' ' + widget.currency + ', but ';
if (probBomb === 1) {
str += 'one random box contains a <em>bomb</em>.';
}
else {
if (widget.revealProbBomb) {
str += 'with probability ' + probBomb +
' one random box contains a <em>bomb</em>.';
}
else {
str += 'one random box might contain a <em>bomb</em>.';
}
}
str += ' You must decide how many boxes you want to open.';
str += '</p>';
if (widget.withPrize) {
str += '<p style="margin-bottom: 0.3em">';
str += 'You will receive a reward equal to the ' +
'sum of all the prizes in every opened ' +
'box. However, if you open the box ' +
'with the bomb, you get nothing.</p>'
}
str += '<p style="margin-bottom: 0.5em">';
str += '<strong>How many boxes do you want to open ';
str += 'between 1 and ' + widget.maxBoxes + '?</strong></p>';
return str;
},
bomb_sliderHint:
'Move the slider to choose the number of boxes to open, ' +
'then click "Open Boxes"',
bomb_boxValue: 'Prize per box: ',
bomb_numBoxes: 'Number of boxes: ',
bomb_totalWin: 'Total reward: ',
bomb_openButton: 'Open Boxes',
bomb_warn: 'Open at least one box.',
bomb_won: 'You won! You did not open the box with the bomb.',
bomb_lost: 'You lost! You opened the box with the bomb.'
};
// Backward compatibility.
RiskGauge.texts.mainText = RiskGauge.texts.holt_laury_mainText;
// ## Dependencies
RiskGauge.dependencies = {
JSUS: {}
};
/**
* ## RiskGauge constructor
*
* Creates a new instance of RiskGauge
*
* @see RiskGauge.init
*/
function RiskGauge() {
/**
* ### RiskGauge.methods
*
* List of available methods
*
* Maps names to functions.
*
* Each function is called with `this` instance as context,
* and accepts the `options` parameters passed to constructor.
* Each method must return widget-like gauge object
* implementing functions: append, enable, disable, getValues
* or an error will be thrown.
*/
this.methods = {};
/**
* ## RiskGauge.method
*
* The method used to measure mood
*
* Available methods: 'Holt_Laury', 'Bomb'
*
* Default method is: 'Holt_Laury'
*
* References:
*
* Holt, C. A., & Laury, S. K. (2002).
* Risk aversion and incentive effects.
* American economic review, 92(5), 1644-1655.
*/
this.method = 'Holt_Laury';
/**
* ### RiskGauge.mainText
*
* A text preceeding the SVO gauger
*/
this.mainText = null;
/**
* ## SVOGauge.gauge
*
* The object measuring mood
*
* @see SVOGauge.method
*/
this.gauge = null;
this.addMethod('Holt_Laury', holtLaury);
this.addMethod('Bomb', bomb);
}
// ## RiskGauge methods.
/**
* ### RiskGauge.init
*
* Initializes the widget
*
* @param {object} opts Optional. Configuration options.
*/
RiskGauge.prototype.init = function(opts) {
var gauge, that;
if ('undefined' !== typeof opts.method) {
if ('string' !== typeof opts.method) {
throw new TypeError('RiskGauge.init: method must be string ' +
'or undefined: ' + opts.method);
}
if (!this.methods[opts.method]) {
throw new Error('RiskGauge.init: method is invalid: ' +
opts.method);
}
this.method = opts.method;
}
if (opts.mainText) {
if ('string' !== typeof opts.mainText) {
throw new TypeError('RiskGauge.init: mainText must be string ' +
'or undefined. Found: ' + opts.mainText);
}
this.mainText = opts.mainText;
}
// Call method.
gauge = this.methods[this.method].call(this, opts);
// Add defaults.
that = this;
gauge.isHidden = function() { return that.isHidden(); };
gauge.isCollapsed = function() { return that.isCollapsed(); };
// Check properties.
if (!node.widgets.isWidget(gauge)) {
throw new Error('RiskGauge.init: method ' + this.method +
' created invalid gauge: missing default widget ' +
'methods.')
}
// Approved.
this.gauge = gauge;
this.on('enabled', function() {
if (gauge.enable) gauge.enable();
});
this.on('disabled', function() {
if (gauge.disable) gauge.disable();
});
this.on('highlighted', function() {
if (gauge.highlight) gauge.highlight();
});
this.on('unhighlighted', function() {
if (gauge.unhighlight) gauge.unhighlight();
});
};
RiskGauge.prototype.append = function() {
node.widgets.append(this.gauge, this.bodyDiv, { panel: false });
};
/**
* ## RiskGauge.addMethod
*
* Adds a new method to measure mood
*
* @param {string} name The name of the method
* @param {function} cb The callback implementing it
*/
RiskGauge.prototype.addMethod = function(name, cb) {
if ('string' !== typeof name) {
throw new TypeError('RiskGauge.addMethod: name must be string: ' +
name);
}
if ('function' !== typeof cb) {
throw new TypeError('RiskGauge.addMethod: cb must be function: ' +
cb);
}
if (this.methods[name]) {
throw new Error('RiskGauge.addMethod: name already existing: ' +
name);
}
this.methods[name] = cb;
};
RiskGauge.prototype.getValues = function(opts) {
return this.gauge.getValues(opts);
};
RiskGauge.prototype.setValues = function(opts) {
return this.gauge.setValues(opts);
};
// ## Methods.
// ### Holt and Laury
function makeProbString(p1, v1, p2, v2, opts) {
var of, cur, sep, out;
opts = opts || {};
of = (opts.of || ' chance to win ');
cur = opts.currency || '$';
sep = opts.sep || '<span class="sep">and</span>';
out = p1 + of;
// Place currency sign before or after.
out += opts.currencyAfter ? v1 + cur : cur + v1;
out += sep + p2 + of;
return out + (opts.currencyAfter ? v2 + cur : cur + v2);
}
function holtLaury(options) {
var items, gauge, i, len, j;
var tmp, v1, v2, v3, v4, p1, p2;
tmp = options.values || [ 2, 1.6, 3.85, 0.1 ];
if (options.scale) {
tmp = tmp.map(function(i) { return i * options.scale; });
}
// Make it two decimals.
v1 = tmp[0].toFixed(2);
v2 = tmp[1].toFixed(2);
v3 = tmp[2].toFixed(2);
v4 = tmp[3].toFixed(2);
len = 10;
items = new Array(len);
for (i = 0; i < len ; i++) {
j = i + 1;
p1 = j + '/' + len;
p2 = (len - j) + '/' + len;
items[i] = {
id: 'hl_' + j,
left: j + '. ',
choices: [
makeProbString(p1, v1, p2, v2, options),
makeProbString(p1, v3, p2, v4, options),
]
};
}
gauge = node.widgets.get('ChoiceTableGroup', {
id: options.id || 'holt_laury',
items: items,
mainText: this.mainText || this.getText('holt_laury_mainText'),
title: false,
requiredChoice: true,
storeRef: false
});
return gauge;
}
// ### Bomb Risk
function bomb(opts) {
var that;
that = this;
// Private variables.
// Probability that there is a bomb. Default 1.
var probBomb;
// The index of the box with the bomb (0 to totBoxes), or -1 if no bomb.
var bombBox;
// Div containing info about how many boxes to open, etc.
var infoDiv;
// Paragraph containing the outcome of opening the box or a warning.
var bombResult;
// The Slider widget to select how many boxes to open.
var slider;
// The open box button.
var button;
// Flag that participant did not found the bomb.
var isWinner;
// Holds the final number of boxes opened after clicking the button.
var finalValue;
// Init private variables.
// The height of every box in px (default: 30px in css).
if (opts.boxHeight) {
if ('string' !== typeof opts.boxHeight) {
throw new Error('Bomb.init: boxHeight must be string ' +
'or undefined. Found: ' + opts.boxHeight);
}
W.cssRule('div.riskgauge .bomb-box { height: ' +
opts.boxHeight + '}');
}
if ('undefined' !== typeof opts.probBomb) {
if (false === J.isNumber(opts.probBomb, 0, 1, true, true)) {
throw new Error('Bomb.init: probBomb must be a number ' +
'between 0 and 1 or undefined. Found: ' +
opts.probBomb);
}
probBomb = opts.probBomb;
}
else {
probBomb = 1;
}
// Variable bombBox is init after totBoxes is validated.
// Public variables.
// Store locally because they are overwritten. TODO: check if needed.
this._highlight = this.highlight;
this._unhighlight = this.unhighlight;
// The value of each box. Default 0.01.
if ('undefined' !== typeof opts.boxValue) {
this.boxValue = J.isNumber(opts.boxValue, 0);
if (!this.boxValue) {
throw new TypeError('Bomb.init: boxValue must be an ' +
'a number > 0 or undefined. Found: ' +
opts.boxValue);
}
}
else {
this.boxValue = 0.01;
}
// The currency of the prize. Default: USD.
this.currency = opts.currency || 'USD';
// Flag TRUE if the probability that a bomb exists is revealed.
this.revealProbBomb = 'undefined' === typeof opts.revealProbBomb ?
true : !!opts.revealProbBomb;
// Max number of boxes to open (default 99 if probBomb = 1, else 100).
if ('undefined' !== typeof opts.totBoxes) {
if (!J.isInt(opts.totBoxes, 0, 10000, false, true)) {
throw new TypeError('Bomb.init: maxBoxes must be an ' +
'integer > 0 and <= 10000 or undefined. Found: ' +
opts.totBoxes);
}
this.totBoxes = opts.totBoxes;
}
else {
this.totBoxes = 100;
}
// Max num of boxes to open.
// Default totBoxes -1 if probBomb = 1, else this.totBoxes.
if ('undefined' !== typeof opts.maxBoxes) {
if (!J.isInt(opts.maxBoxes, 0, this.totBoxes)) {
throw new TypeError('Bomb.init: maxBoxes must be a positive' +
' integer <= ' + this.totBoxes +
' or undefined. Found: ' + opts.maxBoxes);
}
this.maxBoxes = opts.maxBoxes;
}
else {
this.maxBoxes = probBomb === 1 ? this.totBoxes - 1 : this.totBoxes;
}
// Number of boxes in each row.
if ('undefined' !== typeof opts.boxesInRow) {
if (!J.isInt(opts.boxesInRow, 0)) {
throw new TypeError('Bomb.init: boxesInRow must be a positive' +
' integer or undefined. Found: ' +
opts.boxesInRow);
}
this.boxesInRow = opts.boxesInRow > this.totBoxes ?
this.totBoxes : opts.boxesInRow;
}
else {
this.boxesInRow = this.totBoxes < 10 ? this.totBoxes : 10;
}
// If TRUE, there is an actual prize for the participant. Default: TRUE.
this.withPrize = 'undefined' === typeof opts.withPrize ?
true : !!opts.withPrize;
// Bomb box.
// Pick bomb box id, if probability permits it, else set to -1.
// Resulting id is between 1 and totBoxes.
bombBox = Math.random() >= probBomb ?
-1 : Math.ceil(Math.random() * this.totBoxes);
// Return widget-like object.
return {
setValues: function(opts) {
slider.setValues(opts);
},
getValues: function(opts) {
var out, values, nb, ic;
opts = opts || {};
values = slider.getValues();
// We use finalValue, because values.value might be manipulated.
if ('undefined' !== typeof finalValue) {
nb = finalValue;
ic = true
}
else {
// TODO: slider.getValues returns non-integers. Check.
nb = parseInt(slider.slider.value, 10);
ic = false;
}
out = {
value: nb,
isCorrect: ic,
totalMove: values.totalMove,
isWinner: isWinner,
time: values.time,
reward: 0
};
if (!out.isCorrect &&
('undefined' === typeof opts.highlight || opts.highlight)) {
slider.highlight();
}
if (isWinner === true) out.reward = finalValue * that.boxValue;
return out;
},
highlight: function() {
slider.highlight();
},
unhighlight: function() {
slider.unhighlight();
},
// slider: slider,
append: function() {
var nRows;
// Main text.
W.add('div', that.bodyDiv, {
innerHTML: that.mainText ||
that.getText('bomb_mainText', probBomb)
});
// Slider.
slider = node.widgets.add('Slider', that.bodyDiv, {
min: 0,
max: that.maxBoxes,
hint: that.getText('bomb_sliderHint'),
title: false,
initialValue: 0,
displayValue: false,
displayNoChange: false,
type: 'flat',
required: true,
panel: false,
// texts: {
// currentValue: that.getText('sliderValue')
// },
onmove: function(value) {
var i, div, c, v;
// TODO: not working.
// if (that.isHighlighted()) that._unhighlight();
that._unhighlight();
if (value > 0) {
button.style.display = '';
button.disabled = false;
bombResult.innerHTML = '';
}
else {
button.style.display = 'none';
bombResult.innerHTML = that.getText('bomb_warn');
button.disabled = true;
}
// Need to do until maxBoxes
// in case we are reducing the value.
for (i = 0; i < that.maxBoxes; i++) {
div = W.gid(getBoxId(i));
if (value > i) div.style.background = '#1be139';
else div.style.background = '#000000';
}
// Update display.
W.gid('bomb_numBoxes').innerText = value;
c = that.currency;
v = that.boxValue;
if (that.withPrize) {
W.gid('bomb_boxValue').innerText = v + c;
W.gid('bomb_totalWin').innerText =
Number((value * v)).toFixed(2) + c;
}
},
storeRef: false,
width: '100%'
});
// Table.
nRows = Math.ceil(that.totBoxes / that.boxesInRow);
W.add('div', that.bodyDiv, {
innerHTML: makeTable(nRows, that.boxesInRow, that.totBoxes)
});
// Info div.
infoDiv = W.add('div', that.bodyDiv, {
className: 'risk-info',
});
W.add('p', infoDiv, {
innerHTML: that.getText('bomb_numBoxes') +
' <span id="bomb_numBoxes">0</span>'
});
if (that.withPrize) {
W.add('p', infoDiv, {
innerHTML: that.getText('bomb_boxValue') +
' <span id="bomb_boxValue">' +
this.boxValue + '</span>'
});
W.add('p', infoDiv, {
innerHTML: that.getText('bomb_totalWin') +
' <span id="bomb_totalWin">0</span>'
});
}
bombResult = W.add('p', infoDiv, { id: 'bomb_result' });
button = W.add('button', that.bodyDiv, {
className: 'btn-danger',
innerHTML: that.getText('bomb_openButton'),
});
// Initially hidden.
button.style.display = 'none';
button.onclick = function() {
var cl;
// Set global variables.
// slider.getValues().value fails (no int numbers).
finalValue = parseInt(slider.slider.value, 10);
// Update table.
if (bombBox > -1) {
// Variable bombBox is between 1 and totBoxes.
// Cells in table are 0-indexed.
W.gid(getBoxId(bombBox-1)).style.background = '#fa0404';
isWinner = finalValue < bombBox;
}
else {
isWinner = true;
}
// Hide slider and button
slider.hide();
W.hide(button);
// Give feedback.
cl = 'bomb_' + (isWinner ? 'won' : 'lost');
bombResult.innerHTML = that.getText(cl);
bombResult.className += (' ' + cl);
};
}
};
}
// ### Helper methods.
// Returns the Bomb Box Id in the HTML from its index.
function getBoxId(i) {
return 'bc_' + i;
}
//
function makeBoxRow(rowId, boxesInRow, colSpan) {
var i, out;
out = '<tr>';
for (i = 0; i < boxesInRow; i++) {
// If there are not enough boxes in this row, do a long colspan.
if (colSpan && i > colSpan) {
out = out + '<td colspan="' + (boxesInRow - colSpan) +
'"></td>';
break;
}
out = out + '<td><div class="bomb-box square" id="' +
getBoxId(i + (boxesInRow * rowId)) + '"></td>';
}
out += '</tr>';
return out;
}
function makeTable(nRows, boxesInRow, totBoxes) {
var rowId, out, boxCount, colSpan;
out = '<table class="bomb-table">';
for (rowId = 0; rowId < nRows; rowId++) {
// Check if the last row has less cells to complete the row.
boxCount = (rowId+1) * boxesInRow;
if (boxCount > totBoxes) {
colSpan = totBoxes - (rowId * boxesInRow) - 1;
}
out = out + makeBoxRow(rowId, boxesInRow, colSpan);
}
out += '</table><br>';
return out;
}
})(node);