@decidables/prospectable-elements
Version:
prospectable-elements: Web Components for visualizing Cumulative Prospect Theory
258 lines (232 loc) • 7.21 kB
JavaScript
import {html, css} from 'lit';
import * as d3 from 'd3';
import ProspectableElement from '../prospectable-element';
import './risky-choice';
/*
RiskyTask element
<risky-task>
Attributes:
Dots; Coherence;
# Direction, Speed, Lifetime
*/
export default class RiskyTask extends ProspectableElement {
static get properties() {
return {
duration: {
attribute: 'duration',
type: Number,
reflect: true,
},
iti: {
attribute: 'iti',
type: Number,
reflect: true,
},
trials: {
attribute: 'trials',
type: Number,
reflect: true,
},
running: {
attribute: 'running',
type: Boolean,
reflect: true,
},
state: {
attribute: false,
type: String,
reflect: false,
},
};
}
constructor() {
super();
// Attributes
this.duration = 2000; // Duration of stimulus in milliseconds
this.iti = 2000; // Duration of inter-trial interval in milliseconds
this.trials = 5; // Number of trials per block
this.running = false; // Currently executing block of trials
// Properties
this.states = ['resetted', 'iti', 'stimulus', 'ended']; // Possible states of task
this.state = 'resetted'; // Current state of task
// Decision parameters
this.range = {};
this.range.xl = {start: 0, stop: 0, step: 1}; // Gamble Loss Value
this.range.xw = {start: 10, stop: 30, step: 1}; // Gamble Win Value
this.range.pw = {start: 0.1, stop: 0.9, step: 0.1}; // Gamble Win Probability
this.range.xs = {start: 5, stop: 15, step: 1}; // Sure Value
this.range.xl.values = d3.range(
this.range.xl.start,
this.range.xl.stop + 0.01,
this.range.xl.step,
);
this.range.xw.values = d3.range(
this.range.xw.start,
this.range.xw.stop + 0.01,
this.range.xw.step,
);
this.range.pw.values = d3.range(
this.range.pw.start,
this.range.pw.stop + 0.01,
this.range.pw.step,
);
this.range.xs.values = d3.range(
this.range.xs.start,
this.range.xs.stop + 0.01,
this.range.xs.step,
);
// Private
this.firstUpdate = true;
this.xl = 0;
this.xw = 0;
this.pw = 0;
this.xs = 0;
this.trial = 0; // Count of current trial
this.baseTime = 0; // Real time, in milliseconds, that the current block started
this.pauseTime = 0; // Real time, in milliseconds, that block was paused at
this.startTime = 0; // Virtual time, in milliseconds, that current stage of trial started
this.lastTime = 0; // Virtual time, in milliseconds, of the most recent frame
this.runner = undefined; // D3 Interval for frame timing
}
static get styles() {
return [
super.styles,
css`
:host {
display: inline-block;
}
`,
];
}
render() {
return html`
<div class="holder">
<risky-choice state="${(this.state === 'stimulus') ? 'choice' : (this.state === 'iti') ? 'fixation' : 'blank'}" probability="${this.pw}" win="${this.xw}" loss="${this.xl}" sure="${this.xs}"></risky-choice>
</div>`;
}
update(changedProperties) {
super.update(changedProperties);
// Start or stop trial block
if (this.firstUpdate || changedProperties.has('running')) {
if (this.running) {
// (Re)Start
if (this.pauseTime) {
// Shift timeline forward as if paused time never happened
this.baseTime += (d3.now() - this.pauseTime);
this.pauseTime = 0;
}
this.runner = d3.interval(this.run.bind(this), 20); // FIXME??
} else if (this.runner !== undefined) {
// Pause
this.runner.stop();
this.pauseTime = d3.now();
}
}
this.firstUpdate = false;
}
reset() {
this.runner.stop();
this.running = false;
this.trial = 0;
this.state = 'resetted';
this.xl = 0;
this.xw = 0;
this.pw = 0;
this.xs = 0;
this.baseTime = 0;
this.pauseTime = 0;
this.startTime = 0;
this.lastTime = 0;
}
run(/* elapsed */) {
const realTime = d3.now();
const currentTime = (this.baseTime) ? (realTime - this.baseTime) : 0;
const elapsedTime = (this.baseTime) ? (currentTime - this.startTime) : 0;
this.lastTime = currentTime;
if (this.state === 'resetted') {
// Start block with an ITI
this.state = 'iti';
this.baseTime = realTime;
this.startTime = 0;
this.dispatchEvent(new CustomEvent('risky-block-start', {
detail: {
trials: this.trials,
},
bubbles: true,
}));
} else if ((this.state === 'iti') && (elapsedTime >= this.iti)) {
// Start new trial with a stimulus
this.trial += 1;
this.state = 'stimulus';
this.startTime = currentTime;
// Determine trial
this.xl = this.range.xl.values[Math.floor(Math.random() * this.range.xl.values.length)];
this.xw = this.range.xw.values[Math.floor(Math.random() * this.range.xw.values.length)];
this.pw = this.range.pw.values[Math.floor(Math.random() * this.range.pw.values.length)];
this.xs = this.range.xs.values[Math.floor(Math.random() * this.range.xs.values.length)];
this.vDiff = ((this.xw * this.pw) + (this.xl * (1 - this.pw))) - this.xs;
this.gamblePayoff = (Math.random() < this.pw) ? this.xw : this.xl;
this.surePayoff = this.xs;
this.better = (this.vDiff > 0)
? 'gamble'
: (this.vDiff < 0)
? 'sure'
: 'equal';
this.dispatchEvent(new CustomEvent('risky-trial-start', {
detail: {
trials: this.trials,
duration: this.duration,
iti: this.iti,
trial: this.trial,
xl: this.xl,
xw: this.xw,
pw: this.pw,
xs: this.xs,
better: this.better,
gamblePayoff: this.gamblePayoff,
surePayoff: this.surePayoff,
},
bubbles: true,
}));
} else if ((this.state === 'stimulus') && (elapsedTime >= this.duration)) {
// Stimulus is over, end of trial
this.dispatchEvent(new CustomEvent('risky-trial-end', {
detail: {
trials: this.trials,
duration: this.duration,
iti: this.iti,
trial: this.trial,
xl: this.xl,
xw: this.xw,
pw: this.pw,
xs: this.xs,
better: this.better,
gamblePayoff: this.gamblePayoff,
surePayoff: this.surePayoff,
},
bubbles: true,
}));
if (this.trial >= this.trials) {
// End of block
this.runner.stop();
this.running = false;
this.state = 'ended';
this.baseTime = 0;
this.pauseTime = 0;
this.startTime = 0;
this.lastTime = 0;
this.dispatchEvent(new CustomEvent('risky-block-end', {
detail: {
trials: this.trial,
},
bubbles: true,
}));
} else {
// ITI
this.state = 'iti';
this.startTime = currentTime;
}
}
}
}
customElements.define('risky-task', RiskyTask);