UNPKG

@decidables/prospectable-elements

Version:

prospectable-elements: Web Components for visualizing Cumulative Prospect Theory

530 lines (473 loc) 13.2 kB
import {html, css} from 'lit'; import * as d3 from 'd3'; import * as Plotly from 'plotly.js/lib/core'; import * as PlotlyIsoSurface from 'plotly.js/lib/isosurface'; import * as PlotlyScatter3d from 'plotly.js/lib/scatter3d'; // import Plotly from 'plotly.js-dist'; import CPTMath from '@decidables/prospectable-math'; import {DecidablesMixinResizeable} from '@decidables/decidables-elements'; import ProspectableElement from '../prospectable-element'; import plotlyStyle from './plotly-style.auto'; // Load in the needed trace type Plotly.register([PlotlyIsoSurface, PlotlyScatter3d]); /* DecisionSpace element <decision-space> Attributes: interactive: true/false surface: true/false point: 'all', 'first', 'rest', 'none' updateable: true/false a: numeric [0, 1] l: numeric [0, 100] g: numeric [0, 1] xl: numeric (-infinity, infinity) xw: numeric (-infinity, infinity) pw: numeric [0, 1] xs: numeric (-infinity, infinity) Styles: ?? */ export default class DecisionSpace extends DecidablesMixinResizeable(ProspectableElement) { static get properties() { return { surface: { attribute: 'surface', type: Boolean, reflect: true, }, point: { attribute: 'point', type: String, reflect: true, }, updateable: { attribute: 'updateable', type: Boolean, reflect: true, }, a: { attribute: 'alpha', type: Number, reflect: true, }, l: { attribute: 'lambda', type: Number, reflect: true, }, g: { attribute: 'gamma', type: Number, reflect: true, }, xl: { attribute: 'loss', type: Number, reflect: true, }, xw: { attribute: 'win', type: Number, reflect: true, }, pw: { attribute: 'probability', type: Number, reflect: true, }, xs: { attribute: 'sure', type: Number, reflect: true, }, }; } constructor() { super(); this.firstUpdate = true; this.surface = true; this.points = ['all', 'first', 'rest', 'none']; this.point = 'first'; this.updateable = false; this.a = 0.8; this.l = 1.2; this.g = 0.8; this.xl = 0; this.xw = 20; this.pw = 0.5; this.xs = 10; this.response = ''; this.label = ''; this.choices = [ { name: 'default', xw: this.xw, pw: this.pw, xs: this.xs, response: this.response, label: '', }, ]; // Constants for categorical color codes this.GAMBLE = 0; this.SURE = 1; this.NR = 0.25; this.DEFAULT = 0.75; this.pointList = []; this.range = {}; this.range.xs = {start: 5, stop: 15, step: 0.5}; // Sure Value this.range.xw = {start: 10, stop: 30, step: 1}; // Gamble Win Value this.range.pw = {start: 0, stop: 1, step: 0.05}; // Gamble Win Probability this.decisionSpace = []; this.alignState(); } alignState() { this.choices[0].name = 'default'; this.choices[0].xw = this.xw; this.choices[0].pw = this.pw; this.choices[0].xs = this.xs; this.choices[0].response = this.response; this.choices[0].label = this.label; if (this.updateable) { this.choices.forEach((item) => { item.response = ( (CPTMath.xal2v(item.xw, this.a, this.l) * CPTMath.pg2w(item.pw, this.g)) + (CPTMath.xal2v(this.xl, this.a, this.l) * (1 - CPTMath.pg2w(item.pw, this.g))) ) > CPTMath.xal2v(item.xs, this.a, this.l) ? 'gamble' : 'sure'; }); this.response = this.choices[0].response; } this.pointList = { xw: [], pw: [], xs: [], response: [], label: [], }; this.choices.forEach((item, index) => { if (((index === 0) && (this.point === 'first' || this.point === 'all')) || ((index > 0) && (this.point === 'rest' || this.point === 'all'))) { this.pointList.xw.push(item.xw); this.pointList.pw.push(item.pw); this.pointList.xs.push(item.xs); this.pointList.response.push( (item.response === 'gamble') ? this.GAMBLE : (item.response === 'sure') ? this.SURE : (item.response === 'nr') ? this.NR : this.DEFAULT, ); this.pointList.label.push(item.label); } }); this.decisionSpace = { xs: [], xw: [], pw: [], uDiff: [], }; d3.range(this.range.xs.start, this.range.xs.stop + 0.01, this.range.xs.step) .forEach((xs) => { d3.range(this.range.xw.start, this.range.xw.stop + 0.01, this.range.xw.step) .forEach((xw) => { d3.range(this.range.pw.start, this.range.pw.stop + 0.01, this.range.pw.step) .forEach((pw) => { this.decisionSpace.xs.push(xs); this.decisionSpace.xw.push(xw); this.decisionSpace.pw.push(pw); const uDiff = CPTMath.xal2v(xw, this.a, this.l) * CPTMath.pg2w(pw, this.g) // Win + CPTMath.xal2v(this.xl, this.a, this.l) * (1 - CPTMath.pg2w(pw, this.g)) // Loss - CPTMath.xal2v(xs, this.a, this.l); // Sure this.decisionSpace.uDiff.push(uDiff); }); }); }); } clear() { this.choices = [{}]; this.requestUpdate(); } get(name = 'default') { const choice = this.choices.find((item) => { return (item.name === name); }); return (choice === undefined) ? null : choice; } set(xw, pw, xs, response, name = 'default', label = '') { if (name === 'default') { this.xw = xw; this.pw = pw; this.xs = xs; this.response = response; this.label = label; } const choice = this.choices.find((item) => { return (item.name === name); }); if (choice === undefined) { this.choices.push({ name: name, xw: xw, pw: pw, xs: xs, response: response, label: label, }); } else { choice.xw = xw; choice.pw = pw; choice.xs = xs; choice.response = response; choice.label = label; } this.requestUpdate(); } static get styles() { return [ super.styles, plotlyStyle, css` :host { display: inline-block; width: 28rem; height: 20rem; } .plotly { height: 100%; cursor: grab; } /* Plotly modebar styles */ /* Drawn from: https://github.com/plotly/plotly.js/blob/master/src/components/modebar/modebar.js */ .plotly:hover .modebar .modebar-group { background-color: rgba(255, 255, 255, 0.5); } .modebar-btn .icon path { fill: rgba(68, 68, 68, 0.3); } .modebar-btn:hover .icon path { fill: rgba(68, 68, 68, 0.7); } .modebar-btn.active .icon path { fill: rgba(68, 68, 68, 0.7); } `, ]; } render() { /* eslint-disable-line class-methods-use-this */ return html` <div class="plotly"></div> `; // ${ProspectableElement.svgFilters} } willUpdate() { this.alignState(); } update(changedProperties) { super.update(changedProperties); // Bail out if we can't get the width/height/rem if (Number.isNaN(this.width) || Number.isNaN(this.height) || Number.isNaN(this.rem)) { return; } const colorText = this.getComputedStyleValue('---color-text'); const colorElementBorder = this.getComputedStyleValue('---color-element-border'); const colorElementBackground = this.getComputedStyleValue('---color-element-background'); const colorElementEmphasis = this.getComputedStyleValue('---color-element-emphasis'); const colorWorse = this.getComputedStyleValue('---color-worse'); const colorBetter = this.getComputedStyleValue('---color-better'); const colorNr = this.getComputedStyleValue('---color-nr'); const data = []; if (this.surface) { data.push( { name: 'Decision Boundary', type: 'isosurface', x: this.decisionSpace.xs, y: this.decisionSpace.xw, z: this.decisionSpace.pw, value: this.decisionSpace.uDiff, coloraxis: 'coloraxis', isomin: 0, isomax: 0, opacity: 0.5, }, { name: 'Difference in Subjective Utility', type: 'isosurface', x: this.decisionSpace.xs, y: this.decisionSpace.xw, z: this.decisionSpace.pw, value: this.decisionSpace.uDiff, caps: { x: {show: false}, y: {show: false}, z: {show: false}, }, coloraxis: 'coloraxis', isomin: -30, isomax: 30, showscale: false, slices: { x: {show: true, locations: [this.range.xs.stop]}, y: {show: true, locations: [this.range.xw.stop]}, z: {show: true, locations: [this.range.pw.start]}, }, surface: {show: false}, }, ); } data.push( { name: 'Current Decision', type: 'scatter3d', x: this.pointList.xs, y: this.pointList.xw, z: this.pointList.pw, mode: 'markers', marker: { color: this.pointList.response, coloraxis: 'coloraxis2', line: { color: colorElementEmphasis, width: 2, }, size: 6, }, }, ); const layout = { coloraxis: { cmin: -30, cmax: 30, colorbar: { title: { font: { size: this.rem * 1.125, }, text: 'Difference in Utility (Gamble - Sure)', side: 'right', }, thickness: 16, ypad: 32, }, colorscale: [ [0, 'rgb(35, 35, 104)'], [0.35, 'rgb(69,69,208)'], [0.5, 'rgb(190,190,190)'], [0.65, 'rgb(240,50,230)'], [1, 'rgb(120,25,115)'], ], }, coloraxis2: { cmin: 0, cmax: 1, colorscale: [ [0, colorWorse], [0.01, colorWorse], [0.24, colorNr], [0.26, colorNr], [0.74, colorElementEmphasis], [0.76, colorElementEmphasis], [0.99, colorBetter], [1, colorBetter], ], showscale: false, }, font: { family: '"Source Sans", sans-serif', color: colorText, }, margin: {t: 0, l: 0, b: 0}, scene: { hovermode: false, camera: { center: { x: 0, y: 0.1, z: -0.2, }, eye: { x: -2.5 * 0.8, y: -1 * 0.8, z: 1 * 0.8, }, }, xaxis: { mirror: true, showbackground: true, backgroundcolor: colorElementBackground, showgrid: false, showspikes: false, ticks: 'outside', tickcolor: colorElementBorder, showline: true, linecolor: colorElementBorder, zeroline: false, range: [this.range.xs.start, this.range.xs.stop], title: { text: 'Sure Value', font: { size: this.rem * 1.125, }, }, }, yaxis: { mirror: true, showbackground: true, backgroundcolor: colorElementBackground, showgrid: false, showspikes: false, ticks: 'outside', tickcolor: colorElementBorder, showline: true, linecolor: colorElementBorder, zeroline: false, range: [this.range.xw.start, this.range.xw.stop], title: { text: 'Win Value', font: { size: this.rem * 1.125, }, }, }, zaxis: { mirror: true, showbackground: true, backgroundcolor: colorElementBackground, showgrid: false, showspikes: false, ticks: 'outside', tickcolor: colorElementBorder, showline: true, linecolor: colorElementBorder, zeroline: false, range: [this.range.pw.start, this.range.pw.stop], title: { text: 'Win Probability', font: { size: this.rem * 1.125, }, }, }, }, uirevision: true, }; const config = { displaylogo: false, modeBarButtonsToRemove: [ 'orbitRotation', 'resetCameraDefault3d', 'hoverClosest3d', 'toImage', ], responsive: true, }; Plotly.react(this.shadowRoot.querySelector('.plotly'), data, layout, config); this.firstUpdate = false; } } customElements.define('decision-space', DecisionSpace);