@decidables/prospectable-elements
Version:
prospectable-elements: Web Components for visualizing Cumulative Prospect Theory
390 lines (339 loc) • 12.2 kB
JavaScript
import {css, html, render} from 'lit';
import * as d3 from 'd3';
import {DecidablesMixinResizeable} from '@decidables/decidables-elements';
import ProspectableElement from '../prospectable-element';
/*
RiskyOption element
<risky-option>
Attributes:
Win, Loss, Probability
*/
export default class RiskyOption extends DecidablesMixinResizeable(ProspectableElement) {
static get styles() {
return [
super.styles,
css`
:host {
--decidables-spinner-font-size: 1.75rem;
--decidables-spinner-input-width: 4rem;
--decidables-spinner-prefix: "$";
position: relative;
display: inline-block;
width: 10rem;
height: 10rem;
}
.main {
width: 100%;
height: 100%;
overflow: visible;
}
.outline {
fill: var(---color-element-background);
stroke: var(---color-element-emphasis);
stroke-width: 2;
}
.arc {
stroke: var(---color-element-emphasis);
stroke-width: 2;
}
.arc.interactive {
cursor: ns-resize;
outline: none;
filter: url("#shadow-2");
}
.arc.interactive:hover {
filter: url("#shadow-4");
}
.arc.interactive:active {
filter: url("#shadow-8");
}
.arc.interactive:focus-visible {
filter: url("#shadow-8");
}
.arc.win {
fill: var(---color-better-light);
}
.arc.loss {
fill: var(---color-worse-light);
}
.arc.sure {
fill: var(---color-even-light);
}
.label.static {
font-size: 1.75rem;
dominant-baseline: middle;
user-select: none;
text-anchor: middle;
}
.label.interactive {
position: absolute;
width: var(--decidables-spinner-input-width);
height: calc(var(--decidables-spinner-font-size) * 1.5);
overflow: visible;
}
.label.interactive.win decidables-spinner {
background-color: var(---color-better-light);
}
.label.interactive.loss decidables-spinner {
background-color: var(---color-worse-light);
}
.label.interactive.sure decidables-spinner {
background-color: var(---color-even-light);
}
`,
];
}
render() { /* eslint-disable-line class-methods-use-this */
return html`
<slot></slot>
`;
}
connectedCallback() {
super.connectedCallback();
// Detect and update on changes in children
this.mutationObserver = new MutationObserver((mutations) => {
if (mutations.some((mutation) => {
return ((mutation.type === 'childList') || ((mutation.type === 'attributes') && (mutation.target !== this)));
})) {
this.requestUpdate();
}
});
this.mutationObserver.observe(this, {subtree: true, childList: true, attributes: true});
}
disconnectedCallback() {
this.mutationObserver.disconnect();
super.disconnectedCallback();
}
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 elementWidth = this.width;
const elementHeight = this.height;
const elementSize = Math.min(elementWidth, elementHeight);
const margin = {
top: 0, // 0.25 * this.rem,
bottom: 0, // 0.25 * this.rem,
left: 0, // 0.25 * this.rem,
right: 0, // 0.25 * this.rem,
};
const height = elementSize - (margin.top + margin.bottom);
const width = elementSize - (margin.left + margin.right);
// Get outcomes from slots!
const riskyOutcomes = this.querySelectorAll('risky-outcome');
const pCorrection = riskyOutcomes.length ? -riskyOutcomes[0].p : 0;
const arcs = d3.pie()
.startAngle((pCorrection * Math.PI) - Math.PI)
.endAngle((pCorrection * Math.PI) + Math.PI)
.sortValues(null) // Use inserted order!
.value((datum) => { return datum.p; })(riskyOutcomes);
const arcsStatic = arcs.filter(
(arc) => { return !arc.data.interactive; },
);
const arcsInteractive = arcs.filter(
(arc) => { return arc.data.interactive; },
);
// Define drag behavior for arcs
function fixAngle(angle) {
const modAngle = (angle + (2 * Math.PI)) % (2 * Math.PI);
const newAngle = (modAngle > Math.PI)
? (modAngle - (2 * Math.PI))
: ((modAngle < -Math.PI)
? (modAngle + (2 * Math.PI))
: modAngle);
return newAngle;
}
const arcDrag = d3.drag()
.subject((event, datum) => {
const arcAngle = fixAngle((datum.endAngle + datum.startAngle) / 2);
const dragAngle = fixAngle(Math.atan2(event.y, event.x) + (Math.PI / 2));
riskyOutcomes.forEach((item) => {
item.startP = item.p;
});
return {
arcAngle: arcAngle,
startAngle: fixAngle(dragAngle - arcAngle),
};
})
.on('start', (event, datum) => {
if (!datum.data.interactive) return;
const element = event.currentTarget;
d3.select(element).classed('dragging', true);
})
.on('drag', (event, datum) => {
if (!datum.data.interactive) return;
const angle = fixAngle(Math.atan2(event.y, event.x) + (Math.PI / 2));
const currentAngle = fixAngle(angle - event.subject.arcAngle);
const changeAngle = fixAngle((event.subject.startAngle > 0)
? (currentAngle - event.subject.startAngle)
: (event.subject.startAngle - currentAngle));
const changeP = changeAngle / Math.PI;
const proposedP = datum.data.startP + changeP;
const newP = (proposedP > 0.99)
? 0.99
: ((proposedP < 0.01)
? 0.01
: proposedP);
riskyOutcomes.forEach((item) => {
item.p = (item === datum.data)
? newP
: (item.startP / (1 - datum.data.startP)) * (1 - newP);
});
this.dispatchEvent(new CustomEvent('risky-outcome-change', {
detail: {
x: datum.data.x,
p: datum.data.p,
name: datum.data.name,
},
bubbles: true,
}));
// console.log(`x: ${event.x}, y: ${event.y}`);
// console.log(`change: ${changeAngle}, changeP: ${changeP}`);
})
.on('end', (event, datum) => {
if (!datum.data.interactive) return;
const element = event.currentTarget;
d3.select(element).classed('dragging', false);
});
// Svg
// DATA-JOIN
const svgUpdate = d3.select(this.renderRoot).selectAll('.main')
.data([{
width: this.width,
height: this.height,
rem: this.rem,
}]);
// ENTER
const svgEnter = svgUpdate.enter().append('svg')
.classed('main', true)
.each((datum, index, nodes) => {
// Filters for shadows
render(ProspectableElement.svgFilters, nodes[index]);
});
// MERGE
const svgMerge = svgEnter.merge(svgUpdate)
.attr('viewBox', `0 0 ${elementSize} ${elementSize}`);
// Pie
// ENTER
const pieEnter = svgEnter.append('g')
.classed('pie', true);
// MERGE
const pieMerge = svgMerge.select('.pie')
.attr('transform', `translate(${width / 2}, ${height / 2})`);
// Outline
// ENTER
pieEnter.append('circle')
.classed('outline', true);
// MERGE
pieMerge.select('.outline')
.attr('r', Math.min(width, height) / 2 - 1);
// Arcs
// DATA-JOIN
const arcUpdate = pieMerge.selectAll('.arc')
.data(arcs);
// ENTER
const arcEnter = arcUpdate.enter().append('path')
.call(arcDrag)
.on('keydown', (event, datum) => {
if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
const startP = datum.data.p;
let proposedP = datum.data.p; /* eslint-disable-line prefer-destructuring */
switch (event.key) {
case 'ArrowUp':
proposedP -= event.shiftKey ? 0.01 : 0.05;
break;
case 'ArrowDown':
proposedP += event.shiftKey ? 0.01 : 0.05;
break;
default:
// no-op
}
const newP = (proposedP > 0.99)
? 0.99
: ((proposedP < 0.01)
? 0.01
: proposedP);
riskyOutcomes.forEach((item) => {
item.p = (item === datum.data)
? newP
: (item.p / (1 - startP)) * (1 - newP);
});
this.dispatchEvent(new CustomEvent('risky-outcome-change', {
detail: {
x: datum.data.x,
p: datum.data.p,
name: datum.data.name,
},
bubbles: true,
}));
event.preventDefault();
}
});
// MERGE
arcEnter.merge(arcUpdate)
.attr('tabindex', (datum) => { return (arcsInteractive.includes(datum) && (arcs.length > 1)) ? 0 : null; })
.attr('class', (datum) => { return `arc ${datum.data.name}`; })
.classed('interactive', (datum) => { return (arcsInteractive.includes(datum) && (arcs.length > 1)); })
.attr('d', d3.arc()
.innerRadius(0)
.outerRadius(Math.min(width, height) / 2 - 1));
// EXIT
arcUpdate.exit().remove();
// Labels
// DATA-JOIN
const labelStaticUpdate = pieMerge.selectAll('.label.static')
.data(arcsStatic);
const labelInteractiveUpdate = d3.select(this.renderRoot).selectAll('.label.interactive')
.data(arcsInteractive);
// ENTER
const labelStaticEnter = labelStaticUpdate.enter().append('text');
const labelInteractiveEnter = labelInteractiveUpdate.enter().append('xhtml:div');
labelInteractiveEnter.append('decidables-spinner')
.on('input', (event, datum) => {
datum.data.x = parseFloat(event.target.value);
this.dispatchEvent(new CustomEvent('risky-outcome-change', {
detail: {
x: datum.data.x,
p: datum.data.p,
name: datum.data.name,
},
bubbles: true,
}));
});
// MERGE
labelStaticEnter.merge(labelStaticUpdate)
.attr('class', (datum) => { return `label static ${datum.data.name}`; })
.attr('transform', (datum) => {
if (arcs.length === 1) {
return 'translate(0, 0)';
}
const radius = (Math.min(width, height) / 2) * 0.6;
const arcLabel = d3.arc().innerRadius(radius).outerRadius(radius);
return `translate(${arcLabel.centroid(datum)})`;
})
.text((datum) => { return `$${datum.data.x.toFixed(0)}`; });
const labelInteractiveMerge = labelInteractiveEnter.merge(labelInteractiveUpdate)
.attr('class', (datum) => { return `label interactive ${datum.data.name}`; })
.attr('style', (datum) => {
const inputWidth = parseFloat(this.getComputedStyleValue('--decidables-spinner-input-width'));
const fontSize = parseFloat(this.getComputedStyleValue('--decidables-spinner-font-size'));
const rem = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('font-size'), 10);
const x = (width / 2) + (inputWidth * rem) / -2;
const y = (height / 2) + (fontSize * rem * 1.5) / -2;
if (arcs.length === 1) {
return `left: ${x}px; top: ${y}px;`;
}
const radius = (Math.min(width, height) / 2) * 0.6;
const arcLabel = d3.arc().innerRadius(radius).outerRadius(radius);
return `left: ${arcLabel.centroid(datum)[0] + x}px; top: ${arcLabel.centroid(datum)[1] + y}px;`;
});
labelInteractiveMerge.select('decidables-spinner')
.attr('value', (datum) => { return `${datum.data.x.toFixed(0)}`; });
// EXIT
labelStaticUpdate.exit().remove();
labelInteractiveUpdate.exit().remove();
}
}
customElements.define('risky-option', RiskyOption);