@decidables/prospectable-elements
Version:
prospectable-elements: Web Components for visualizing Cumulative Prospect Theory
1,293 lines (1,181 loc) • 41.5 kB
JavaScript
import {css, html} from 'lit';
import * as d3 from 'd3';
import * as d33d from 'd3-3d';
import color from 'color';
import CPTMath from '@decidables/prospectable-math';
import {DecidablesMixinResizeable} from '@decidables/decidables-elements';
import ProspectableElement from '../prospectable-element';
/*
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 = CPTMath.a.DEFAULT;
this.l = CPTMath.l.DEFAULT;
this.g = CPTMath.g.DEFAULT;
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: '',
},
];
this.range = {
xs: {start: 5, stop: 15, step: 0.5}, // Sure Value
xw: {start: 10, stop: 30, step: 1}, // Gamble Win Value
pw: {start: 0, stop: 1, step: 0.05}, // Gamble Win Probability
uDiff: {start: -20, stop: 20}, // Difference in Utility (Gamble - Sure)
};
this.boundary = [];
this.mapXY = [];
this.mapXZ = [];
this.mapYZ = [];
this.rotationX = 0;
this.rotationY = 0;
this.mx = 0;
this.my = 0;
this.mouseX = 0;
this.mouseY = 0;
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;
}
const pg2wSafe = (p, g) => {
const w = CPTMath.pg2w(p, g);
return Number.isNaN(w) ? p : w;
};
const diff = (xw, xl, pw, xs, a, l, g) => {
return CPTMath.xal2v(xw, a, l) * pg2wSafe(pw, g) // Win
+ CPTMath.xal2v(xl, a, l) * (1 - pg2wSafe(pw, g)) // Loss
- CPTMath.xal2v(xs, a, l); // Sure
};
// For each combination of xs and xw, find the xp using bisection method
this.boundary = d3.range(this.range.xs.start, this.range.xs.stop + 0.01, this.range.xs.step)
.flatMap((xs) => {
return d3.range(this.range.xw.start, this.range.xw.stop + 0.01, this.range.xw.step)
.map((xw) => {
let lowP = this.range.pw.start;
let highP = 10; // this.range.pw.stop;
let midP = (lowP + highP) / 2;
const lowDiff = diff(xw, this.xl, lowP, xs, this.a, this.l, this.g);
const highDiff = diff(xw, this.xl, highP, xs, this.a, this.l, this.g);
let midDiff;
if (lowDiff > 0) {
midP = -Infinity;
} else if (highDiff < 0) {
midP = Infinity;
} else {
d3.range(0, 15, 1)
.forEach(() => {
midDiff = diff(xw, this.xl, midP, xs, this.a, this.l, this.g);
if (midDiff < 0) {
lowP = midP;
} else {
highP = midP;
}
midP = (lowP + highP) / 2;
});
}
return {xw, xs, pw: midP};
});
});
const pwIn = (point) => {
return (point?.pw >= this.range.pw.start) && (point?.pw <= this.range.pw.stop);
};
// Interpolation where map goes off the plot
this.boundary = this.boundary.map((point, index, map) => {
// pw is in bounds
if (pwIn(point)) {
return point;
}
// sizes
const columns = d3
.range(this.range.xw.start, this.range.xw.stop + 0.01, this.range.xw.step).length;
const rows = d3
.range(this.range.xs.start, this.range.xs.stop + 0.01, this.range.xs.step).length;
// neighbours
const left = ((index % columns) === 0) ? null : map[index - 1];
const right = ((index % columns) === (columns - 1)) ? null : map[index + 1];
const top = (Math.trunc(index / columns) === 0) ? null : map[index - columns];
const bottom = (Math.trunc(index / columns) === (rows - 1)) ? null : map[index + columns];
const leftIn = pwIn(left) ? 1 : 0;
const rightIn = pwIn(right) ? 1 : 0;
const topIn = pwIn(top) ? 1 : 0;
const bottomIn = pwIn(bottom) ? 1 : 0;
const totalIn = leftIn + rightIn + topIn + bottomIn;
// consider neighbors
if (
(totalIn === 0)
|| ((totalIn === 2) && ((leftIn + rightIn) !== 1))
|| (totalIn === 3)
|| (totalIn === 4)
) {
return point;
}
// otherwise, let's interpolate!
const newPoint = {
pw: (point.pw < this.range.pw.start) ? this.range.pw.start : this.range.pw.stop,
xw: point.xw,
xs: point.xs,
};
let other;
if (totalIn === 1) {
other = leftIn ? left : rightIn ? right : topIn ? top : bottom;
} else {
const other1 = leftIn ? left : right;
const other2 = topIn ? top : bottom;
other = {
xw: (other1.xw + other2.xw) / 2,
xs: (other1.xs + other2.xs) / 2,
pw: (other1.pw + other2.pw) / 2,
};
}
const ratio = (newPoint.pw - other.pw) / (point.pw - other.pw);
newPoint.xw = other.xw + (point.xw - other.xw) * ratio;
newPoint.xs = other.xs + (point.xs - other.xs) * ratio;
return newPoint;
});
const xwConst = this.range.xw.stop;
this.mapXY = d3.range(this.range.xs.start, this.range.xs.stop + 0.01, this.range.xs.step)
.flatMap((xs) => {
return d3.range(this.range.pw.start, this.range.pw.stop + 0.01, this.range.pw.step)
.map((pw) => {
const uDiff = diff(xwConst, this.xl, pw, xs, this.a, this.l, this.g);
return {
xw: xwConst,
xs,
pw,
uDiff,
};
});
});
const pwConst = this.range.pw.start;
this.mapXZ = d3.range(this.range.xs.start, this.range.xs.stop + 0.01, this.range.xs.step)
.flatMap((xs) => {
return d3.range(this.range.xw.start, this.range.xw.stop + 0.01, this.range.xw.step)
.map((xw) => {
const uDiff = diff(xw, this.xl, pwConst, xs, this.a, this.l, this.g);
return {
xw,
xs,
pw: pwConst,
uDiff,
};
});
});
const xsConst = this.range.xs.stop;
this.mapYZ = d3.range(this.range.pw.start, this.range.pw.stop + 0.01, this.range.pw.step)
.flatMap((pw) => {
return d3.range(this.range.xw.start, this.range.xw.stop + 0.01, this.range.xw.step)
.map((xw) => {
const uDiff = diff(xw, this.xl, pw, xsConst, this.a, this.l, this.g);
return {
xw,
xs: xsConst,
pw,
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,
css`
:host {
display: inline-block;
width: 28rem;
height: 20rem;
}
.main {
width: 100%;
height: 100%;
cursor: grab;
}
text {
/* stylelint-disable property-no-vendor-prefix */
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
fill: var(---color-text);
}
.axis {
stroke: var(---color-element-border);
stroke-width: 1;
}
.title textPath {
font-weight: 600;
alignment-baseline: middle;
text-anchor: middle;
}
.title tspan {
alignment-baseline: middle;
}
.title .subscript {
font-size: 66.667%;
alignment-baseline: initial;
baseline-shift: sub;
}
.tick {
stroke: var(---color-element-border);
stroke-width: 1;
}
.label textPath {
font-size: 0.75rem;
alignment-baseline: middle;
text-anchor: end;
}
.label-x textPath {
text-anchor: start;
}
.point {
fill: var(---color-element-background);
stroke: var(---color-element-emphasis);
stroke-width: 1px;
r: 6px;
}
.point.sure {
fill: var(---color-better);
}
.point.gamble {
fill: var(---color-worse);
}
.point.nr {
fill: var(---color-nr);
}
.boundary {
fill-opacity: 0.7;
stroke-opacity: 1;
stroke-width: 0.5px;
}
.map {
stroke-width: 1px;
}
.legend .title {
font-weight: 600;
alignment-baseline: middle;
text-anchor: middle;
}
.legend .tick text {
font-size: 0.75rem;
font-weight: 400;
stroke: none;
}
`,
];
}
render() { /* eslint-disable-line class-methods-use-this */
return html`
`;
}
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 elementWidth = this.width;
const elementHeight = this.height;
const elementSize = Math.min(elementWidth, elementHeight);
const margin = {
top: this.rem * 3,
bottom: this.rem * 5,
left: this.rem * 2,
right: this.rem * 6,
};
const height = elementSize - (margin.top + margin.bottom);
const width = elementSize - (margin.left + margin.right);
// const transitionDuration = parseInt(
// this.getComputedStyleValue('---transition-duration'),
// 10,
// );
// Scales
const xScale = d3.scaleLinear()
.domain([this.range.xs.start, this.range.xs.stop])
.range([0, width]);
const yScale = d3.scaleLinear()
.domain([this.range.pw.start, this.range.pw.stop])
.range([0, -height]);
const zScale = d3.scaleLinear()
.domain([this.range.xw.start, this.range.xw.stop])
.range([0, -height]);
const colorElementBackground = color(this.getComputedStyleValue('---color-element-background')).hex();
const colorBetterDark = this.getComputedStyleValue('---color-better-dark');
const colorBetter = this.getComputedStyleValue('---color-better');
const colorNr = this.getComputedStyleValue('---color-nr');
const colorWorse = this.getComputedStyleValue('---color-worse');
const colorWorseDark = this.getComputedStyleValue('---color-worse-dark');
const colorScale = d3.scaleDiverging()
.domain([this.range.uDiff.start, 0, this.range.uDiff.stop])
.clamp(true)
.interpolator(
d3.piecewise([colorBetterDark, colorBetter, colorNr, colorWorse, colorWorseDark]),
);
const legendScale = d3.scaleLinear()
.domain([this.range.uDiff.start, this.range.uDiff.stop])
.range([0, -elementHeight + this.rem * 4]);
// 3D Shapes
const startOrigin = {x: margin.left, y: elementSize - margin.bottom};
const startScale = 1;
const startRotationCenter = {
x: xScale((this.range.xs.start + this.range.xs.stop) / 2),
y: yScale((this.range.pw.start + this.range.pw.stop) / 2),
z: zScale((this.range.xw.start + this.range.xw.stop) / 2),
};
const startRotationX = (-0.85 * Math.PI) / 8;
const startRotationY = (3 * Math.PI) / 8;
const startRotationZ = 0.0000001; // Avoid d3-3d bug
const lineStrips3D = d33d.lineStrips3D()
.origin(startOrigin)
.scale(startScale)
.rotationCenter(startRotationCenter)
.rotateX(startRotationX + this.rotationX)
.rotateY(startRotationY + this.rotationY)
.rotateZ(startRotationZ);
const points3d = d33d.points3D()
.origin(startOrigin)
.scale(startScale)
.rotationCenter(startRotationCenter)
.rotateX(startRotationX + this.rotationX)
.rotateY(startRotationY + this.rotationY)
.rotateZ(startRotationZ);
const grid3d = d33d.gridPlanes3D()
.origin(startOrigin)
.scale(startScale)
.rotationCenter(startRotationCenter)
.rotateX(startRotationX + this.rotationX)
.rotateY(startRotationY + this.rotationY)
.rotateZ(startRotationZ);
// SVG Drag behaviors
const svgDrag = d3.drag()
.on('start', (event) => {
this.mx = event.x;
this.my = event.y;
})
.on('drag', (event) => {
this.rotationY = (event.x - this.mx + this.mouseX) * (Math.PI / 230);
this.rotationX = (event.y - this.my + this.mouseY) * (Math.PI / 230) * -1;
this.requestUpdate();
})
.on('end', (event) => {
this.mouseX = event.x - this.mx + this.mouseX;
this.mouseY = event.y - this.my + this.mouseY;
});
// 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)
.call(svgDrag);
// MERGE
const svgMerge = svgEnter.merge(svgUpdate)
.attr('viewBox', `0 0 ${elementSize} ${elementSize}`);
// Gradient Def
const gradientEnter = svgEnter.append('defs').append('linearGradient')
.attr('id', 'gradient-legend')
// .attr('color-interpolation', 'linearRGB')
.attr('x1', 0)
.attr('x2', 0)
.attr('y1', 1)
.attr('y2', 0);
gradientEnter.append('stop')
.attr('offset', '0%')
.attr('stop-color', colorBetterDark);
gradientEnter.append('stop')
.attr('offset', '25%')
.attr('stop-color', colorBetter);
gradientEnter.append('stop')
.attr('offset', '50%')
.attr('stop-color', colorNr);
gradientEnter.append('stop')
.attr('offset', '75%')
.attr('stop-color', colorWorse);
gradientEnter.append('stop')
.attr('offset', '100%')
.attr('stop-color', colorWorseDark);
// Axis & Title Data
const xAxis = [[
{title: 'Sure Value (<tspan class="math-var">x<tspan class="subscript">sure</tspan></tspan>)', id: 'max', x: xScale.range()[1]},
{id: 'min', x: xScale.range()[0]},
]];
const yAxis = [[
{title: 'Win Probability (<tspan class="math-var">p<tspan class="subscript">win</tspan></tspan>)', id: 'max', y: yScale.range()[1]},
{id: 'min', y: yScale.range()[0]},
]];
const zAxis = [[
{title: 'Win Value (<tspan class="math-var">x<tspan class="subscript">win</tspan></tspan>)', id: 'max', z: zScale.range()[1]},
{id: 'min', z: zScale.range()[0]},
]];
// Axes
// DATA-JOIN
const axisXUpdate = svgMerge.selectAll('.axis-x')
.data(
lineStrips3D
.x((datum) => { return datum.x; })
.y(() => { return yScale.range()[0]; })
.z(() => { return zScale.range()[0]; })
.data(xAxis),
);
const axisYUpdate = svgMerge.selectAll('.axis-y')
.data(
lineStrips3D
.x(() => { return xScale.range()[0]; })
.y((datum) => { return datum.y; })
.z(() => { return zScale.range()[1]; })
.data(yAxis),
);
const axisZUpdate = svgMerge.selectAll('.axis-z')
.data(
lineStrips3D
.x(() => { return xScale.range()[0]; })
.y(() => { return yScale.range()[0]; })
.z((datum) => { return datum.z; })
.data(zAxis),
);
// ENTER
const axisXEnter = axisXUpdate.enter().append('path')
.attr('class', 'd3-3d axis axis-x');
const axisYEnter = axisYUpdate.enter().append('path')
.attr('class', 'd3-3d axis axis-y');
const axisZEnter = axisZUpdate.enter().append('path')
.attr('class', 'd3-3d axis axis-z');
// MERGE
const axisXMerge = axisXEnter.merge(axisXUpdate)
.attr('d', lineStrips3D.draw);
const axisYMerge = axisYEnter.merge(axisYUpdate)
.attr('d', lineStrips3D.draw);
const axisZMerge = axisZEnter.merge(axisZUpdate)
.attr('d', lineStrips3D.draw);
// EXIT
axisXMerge.exit().remove();
axisYMerge.exit().remove();
axisZMerge.exit().remove();
// Axis Titles
// DATA-JOIN
const titlePathXUpdate = svgMerge.selectAll('.title-path-x')
.data(
lineStrips3D
.x((datum) => {
return datum.id === 'min' ? datum.x - this.rem * 20 : datum.x + this.rem * 20;
})
.y(() => { return yScale.range()[0] + this.rem * 1.75; })
.z(() => { return zScale.range()[0] + this.rem * 1.75; })
.data(xAxis),
);
const titlePathYUpdate = svgMerge.selectAll('.title-path-y')
.data(
lineStrips3D
.x(() => { return xScale.range()[0] - this.rem * 1.75; })
.y((datum) => {
return datum.id === 'min' ? datum.y + this.rem * 20 : datum.y - this.rem * 20;
})
.z(() => { return zScale.range()[1] - this.rem * 1.75; })
.data(yAxis),
);
const titlePathZUpdate = svgMerge.selectAll('.title-path-z')
.data(
lineStrips3D
.x(() => { return xScale.range()[0] - this.rem * 1.75; })
.y(() => { return yScale.range()[0] + this.rem * 1.75; })
.z((datum) => {
return datum.id === 'min' ? datum.z - this.rem * 20 : datum.z + this.rem * 20;
})
.data(zAxis),
);
const titleXUpdate = svgMerge.selectAll('.title-x')
.data(
lineStrips3D
.x((datum) => {
return datum.id === 'min' ? datum.x - this.rem * 20 : datum.x + this.rem * 20;
})
.y(() => { return yScale.range()[0] + this.rem * 1.75; })
.z(() => { return zScale.range()[0] + this.rem * 1.75; })
.data(xAxis),
(datum) => { return datum[0].title; },
);
const titleYUpdate = svgMerge.selectAll('.title-y')
.data(
lineStrips3D
.x(() => { return xScale.range()[0] - this.rem * 1.75; })
.y((datum) => {
return datum.id === 'min' ? datum.y + this.rem * 20 : datum.y - this.rem * 20;
})
.z(() => { return zScale.range()[1] - this.rem * 1.75; })
.data(yAxis),
(datum) => { return datum[0].title; },
);
const titleZUpdate = svgMerge.selectAll('.title-z')
.data(
lineStrips3D
.x(() => { return xScale.range()[0] - this.rem * 1.75; })
.y(() => { return yScale.range()[0] + this.rem * 1.75; })
.z((datum) => {
return datum.id === 'min' ? datum.z - this.rem * 20 : datum.z + this.rem * 20;
})
.data(zAxis),
(datum) => { return datum[0].title; },
);
// ENTER
const titlePathXEnter = titlePathXUpdate.enter().append('path')
.attr('class', 'd3-3d title-path title-path-x')
.attr('id', 'title-x');
const titlePathYEnter = titlePathYUpdate.enter().append('path')
.attr('class', 'd3-3d title-path title-path-y')
.attr('id', 'title-y');
const titlePathZEnter = titlePathZUpdate.enter().append('path')
.attr('class', 'd3-3d title-path title-path-z')
.attr('id', 'title-z');
const titleXEnter = titleXUpdate.enter().append('text')
.attr('class', 'd3-3d title title-x');
titleXEnter
.append('textPath')
.attr('href', '#title-x')
.attr('startOffset', '50%');
const titleYEnter = titleYUpdate.enter().append('text')
.attr('class', 'd3-3d title title-y');
titleYEnter
.append('textPath')
.attr('href', '#title-y')
.attr('startOffset', '50%');
const titleZEnter = titleZUpdate.enter().append('text')
.attr('class', 'd3-3d title title-z');
titleZEnter
.append('textPath')
.attr('href', '#title-z')
.attr('startOffset', '50%');
// MERGE
const titlePathXMerge = titlePathXEnter.merge(titlePathXUpdate)
.attr('d', lineStrips3D.draw);
const titlePathYMerge = titlePathYEnter.merge(titlePathYUpdate)
.attr('d', lineStrips3D.draw);
const titlePathZMerge = titlePathZEnter.merge(titlePathZUpdate)
.attr('d', lineStrips3D.draw);
const titleXMerge = titleXEnter.merge(titleXUpdate)
.select('textPath')
.html((datum) => { return datum[0].title; });
const titleYMerge = titleYEnter.merge(titleYUpdate)
.select('textPath')
.html((datum) => { return datum[0].title; });
const titleZMerge = titleZEnter.merge(titleZUpdate)
.select('textPath')
.html((datum) => { return datum[0].title; });
// EXIT
titlePathXMerge.exit().remove();
titlePathYMerge.exit().remove();
titlePathZMerge.exit().remove();
titleXMerge.exit().remove();
titleYMerge.exit().remove();
titleZMerge.exit().remove();
// Axis Tick & Label Data
const tickCount = 5;
const xTicks = xScale.ticks(tickCount).map((tick) => {
return [
{id: 'min', label: xScale.tickFormat()(tick), x: xScale(tick)},
{id: 'max', x: xScale(tick)},
];
});
const yTicks = yScale.ticks(tickCount).map((tick) => {
return [
{id: 'min', label: yScale.tickFormat()(tick), y: yScale(tick)},
{id: 'max', y: yScale(tick)},
];
});
const zTicks = zScale.ticks(tickCount).map((tick) => {
return [
{id: 'max', label: zScale.tickFormat()(tick), z: zScale(tick)},
{id: 'min', z: zScale(tick)},
];
});
// Axis Ticks
// DATA-JOIN
const ticksXUpdate = svgMerge.selectAll('.tick-x')
.data(
lineStrips3D
.x((datum) => { return datum.x; })
.y((datum) => {
return datum.id === 'min' ? yScale.range()[0] : yScale.range()[0] + this.rem * 0.35;
})
.z((datum) => {
return datum.id === 'min' ? zScale.range()[0] : zScale.range()[0] + this.rem * 0.35;
})
.data(xTicks),
);
const ticksYUpdate = svgMerge.selectAll('.tick-y')
.data(
lineStrips3D
.x((datum) => {
return datum.id === 'min' ? xScale.range()[0] : xScale.range()[0] - this.rem * 0.35;
})
.y((datum) => { return datum.y; })
.z((datum) => {
return datum.id === 'min' ? zScale.range()[1] : zScale.range()[1] - this.rem * 0.35;
})
.data(yTicks),
);
const ticksZUpdate = svgMerge.selectAll('.tick-z')
.data(
lineStrips3D
.x((datum) => {
return datum.id === 'min' ? xScale.range()[0] : xScale.range()[0] - this.rem * 0.35;
})
.y((datum) => {
return datum.id === 'min' ? yScale.range()[0] : yScale.range()[0] + this.rem * 0.35;
})
.z((datum) => { return datum.z; })
.data(zTicks),
);
// ENTER
const ticksXEnter = ticksXUpdate.enter().append('path')
.attr('class', 'd3-3d tick tick-x');
const ticksYEnter = ticksYUpdate.enter().append('path')
.attr('class', 'd3-3d tick tick-y');
const ticksZEnter = ticksZUpdate.enter().append('path')
.attr('class', 'd3-3d tick tick-z');
// MERGE
const ticksXMerge = ticksXEnter.merge(ticksXUpdate)
.attr('d', lineStrips3D.draw);
const ticksYMerge = ticksYEnter.merge(ticksYUpdate)
.attr('d', lineStrips3D.draw);
const ticksZMerge = ticksZEnter.merge(ticksZUpdate)
.attr('d', lineStrips3D.draw);
// EXIT
ticksXMerge.exit().remove();
ticksYMerge.exit().remove();
ticksZMerge.exit().remove();
// Axis Tick Labels
// DATA-JOIN
const labelPathsXUpdate = svgMerge.selectAll('.label-path-x')
.data(
lineStrips3D
.x((datum) => { return datum.x; })
.y((datum) => {
return datum.id === 'min'
? yScale.range()[0] + this.rem * 4
: yScale.range()[0] + this.rem * 0.5;
})
.z((datum) => {
return datum.id === 'min'
? zScale.range()[0] + this.rem * 4
: zScale.range()[0] + this.rem * 0.5;
})
.data(xTicks),
(datum) => { return datum[0].label; },
);
const labelPathsYUpdate = svgMerge.selectAll('.label-path-y')
.data(
lineStrips3D
.x((datum) => {
return datum.id === 'min'
? xScale.range()[0] - this.rem * 0.5
: xScale.range()[0] - this.rem * 4;
})
.y((datum) => { return datum.y; })
.z((datum) => {
return datum.id === 'min'
? zScale.range()[1] - this.rem * 0.5
: zScale.range()[1] - this.rem * 4;
})
.data(yTicks),
(datum) => { return datum[0].label; },
);
const labelPathsZUpdate = svgMerge.selectAll('.label-path-z')
.data(
lineStrips3D
.x((datum) => {
return datum.id === 'min'
? xScale.range()[0] - this.rem * 4
: xScale.range()[0] - this.rem * 0.5;
})
.y((datum) => {
return datum.id === 'min'
? yScale.range()[0] + this.rem * 4
: yScale.range()[0] + this.rem * 0.5;
})
.z((datum) => { return datum.z; })
.data(zTicks),
(datum) => { return datum[0].label; },
);
const labelsXUpdate = svgMerge.selectAll('.label-x')
.data(
lineStrips3D
.x((datum) => { return datum.x; })
.y((datum) => {
return datum.id === 'min'
? yScale.range()[0] + this.rem * 4
: yScale.range()[0] + this.rem * 0.5;
})
.z((datum) => {
return datum.id === 'min'
? zScale.range()[0] + this.rem * 4
: zScale.range()[0] + this.rem * 0.5;
})
.data(xTicks),
(datum) => { return datum[0].label; },
);
const labelsYUpdate = svgMerge.selectAll('.label-y')
.data(
lineStrips3D
.x((datum) => {
return datum.id === 'min'
? xScale.range()[0] - this.rem * 0.5
: xScale.range()[0] - this.rem * 4;
})
.y((datum) => { return datum.y; })
.z((datum) => {
return datum.id === 'min'
? zScale.range()[1] - this.rem * 0.5
: zScale.range()[1] - this.rem * 4;
})
.data(yTicks),
(datum) => { return datum[0].label; },
);
const labelsZUpdate = svgMerge.selectAll('.label-z')
.data(
lineStrips3D
.x((datum) => {
return datum.id === 'min'
? xScale.range()[0] - this.rem * 4
: xScale.range()[0] - this.rem * 0.5;
})
.y((datum) => {
return datum.id === 'min'
? yScale.range()[0] + this.rem * 4
: yScale.range()[0] + this.rem * 0.5;
})
.z((datum) => { return datum.z; })
.data(zTicks),
(datum) => { return datum[0].label; },
);
// ENTER
const labelPathsXEnter = labelPathsXUpdate.enter().append('path')
.attr('class', 'd3-3d label-path label-path-x')
.attr('id', (datum, index) => { return `label-x-${index}`; });
const labelPathsYEnter = labelPathsYUpdate.enter().append('path')
.attr('class', 'd3-3d label-path label-path-y')
.attr('id', (datum, index) => { return `label-y-${index}`; });
const labelPathsZEnter = labelPathsZUpdate.enter().append('path')
.attr('class', 'd3-3d label-path label-path-z')
.attr('id', (datum, index) => { return `label-z-${index}`; });
const labelsXEnter = labelsXUpdate.enter().append('text')
.attr('class', 'd3-3d label label-x');
labelsXEnter
.append('textPath')
.attr('href', (datum, index) => { return `#label-x-${index}`; })
.attr('startOffset', '0%');
const labelsYEnter = labelsYUpdate.enter().append('text')
.attr('class', 'd3-3d label label-y');
labelsYEnter
.append('textPath')
.attr('href', (datum, index) => { return `#label-y-${index}`; })
.attr('startOffset', '100%');
const labelsZEnter = labelsZUpdate.enter().append('text')
.attr('class', 'd3-3d label label-z');
labelsZEnter
.append('textPath')
.attr('href', (datum, index) => { return `#label-z-${index}`; })
.attr('startOffset', '100%');
// MERGE
const labelPathsXMerge = labelPathsXEnter.merge(labelPathsXUpdate)
.attr('d', lineStrips3D.draw);
const labelPathsYMerge = labelPathsYEnter.merge(labelPathsYUpdate)
.attr('d', lineStrips3D.draw);
const labelPathsZMerge = labelPathsZEnter.merge(labelPathsZUpdate)
.attr('d', lineStrips3D.draw);
const labelsXMerge = labelsXEnter.merge(labelsXUpdate)
.select('textPath')
.text((datum) => { return datum[0].label; });
const labelsYMerge = labelsYEnter.merge(labelsYUpdate)
.select('textPath')
.text((datum) => { return datum[0].label; });
const labelsZMerge = labelsZEnter.merge(labelsZUpdate)
.select('textPath')
.text((datum) => { return datum[0].label; });
// EXIT
labelPathsXMerge.exit().remove();
labelPathsYMerge.exit().remove();
labelPathsZMerge.exit().remove();
labelsXMerge.exit().remove();
labelsYMerge.exit().remove();
labelsZMerge.exit().remove();
// Points
// DATA-JOIN
const pointsUpdate = svgMerge.selectAll('.point')
.data(
points3d
.x((datum) => { return xScale(datum.xs); })
.y((datum) => { return yScale(datum.pw); })
.z((datum) => { return zScale(datum.xw); })
.data(
this.choices.slice(
this.point === 'rest' ? 1 : 0,
this.point === 'first' ? 1 : undefined,
),
),
(datum) => { return datum.name; },
);
// ENTER
const pointsEnter = pointsUpdate.enter().append('circle')
.attr('class', 'd3-3d point');
// MERGE
pointsEnter.merge(pointsUpdate)
.attr('class', (datum) => { return `d3-3d point ${datum.response}`; })
.attr('cx', (datum) => { return datum.projected.x; })
.attr('cy', (datum) => { return datum.projected.y; });
// EXIT
pointsUpdate.exit().remove();
// Lighting!
// a, b: point
// return: vector
function points2vector(a, b) {
return {
x: b.x - a.x,
y: b.y - a.y,
z: b.z - a.z,
};
}
// a: vector
// return: scalar
function magnitude(a) {
return Math.sqrt((a.x * a.x) + (a.y * a.y) + (a.z * a.z));
}
// a, b: vector
// return: scalar
function dotProduct(a, b) {
return (a.x * b.x) + (a.y * b.y) + (a.z * b.z);
}
// a, b: vector
// return: vector
function crossProduct(a, b) {
return {
x: (a.y * b.z) - (a.z * b.y),
y: (a.z * b.x) - (a.x * b.z),
z: (a.x * b.y) - (a.y * b.x),
};
}
// a, b, c: point
// return: vector
function points2surfaceNormal(a, b, c) {
return crossProduct(points2vector(a, b), points2vector(a, c));
}
// a, b: vector
// return: cosine angle
function cosineAngle(a, b) {
return dotProduct(a, b) / (magnitude(a) * magnitude(b));
}
const lightSource = {x: -0.5, y: 1, z: -1};
// Decision Boundary
// DATA-JOIN
const boundaryUpdate = svgMerge.selectAll('.boundary')
.data(
this.surface
? grid3d
.rows(
d3.range(this.range.xw.start, this.range.xw.stop + 0.01, this.range.xw.step).length,
)
.x((datum) => { return xScale(datum.xs); })
.y((datum) => { return yScale(datum.pw); })
.z((datum) => { return zScale(datum.xw); })
.data(this.boundary)
.filter((datum) => {
return (
(datum[0].pw >= this.range.pw.start && datum[0].pw <= this.range.pw.stop)
&& (datum[1].pw >= this.range.pw.start && datum[1].pw <= this.range.pw.stop)
&& (datum[2].pw >= this.range.pw.start && datum[2].pw <= this.range.pw.stop)
&& (datum[3].pw >= this.range.pw.start && datum[3].pw <= this.range.pw.stop)
);
})
: [],
);
// ENTER
const boundaryEnter = boundaryUpdate.enter().append('path')
.attr('class', 'd3-3d boundary');
// MERGE
boundaryEnter.merge(boundaryUpdate)
.attr('d', grid3d.draw)
.each((datum) => {
const surface = datum.ccw
? points2surfaceNormal(datum[0].rotated, datum[1].rotated, datum[2].rotated)
: points2surfaceNormal(datum[2].rotated, datum[1].rotated, datum[0].rotated);
datum.ratio = cosineAngle(surface, lightSource) - 0.5;
datum.color = d3.color(colorElementBackground).brighter(datum.ratio);
})
.attr('fill', (datum) => { return datum.color; })
.attr('stroke', (datum) => { return datum.color; });
// EXIT
boundaryUpdate.exit().remove();
// Decision Maps
// DATA-JOIN
const mapXYUpdate = svgMerge.selectAll('.map-xy')
.data(
grid3d
.rows(d3.range(this.range.pw.start, this.range.pw.stop + 0.01, this.range.pw.step).length)
.x((datum) => { return xScale(datum.xs); })
.y((datum) => { return yScale(datum.pw); })
.z((datum) => { return zScale(datum.xw); })
.data(this.mapXY),
);
const mapXZUpdate = svgMerge.selectAll('.map-xz')
.data(
grid3d
.rows(d3.range(this.range.xw.start, this.range.xw.stop + 0.01, this.range.xw.step).length)
.x((datum) => { return xScale(datum.xs); })
.y((datum) => { return yScale(datum.pw); })
.z((datum) => { return zScale(datum.xw); })
.data(this.mapXZ),
);
const mapYZUpdate = svgMerge.selectAll('.map-yz')
.data(
grid3d
.rows(d3.range(this.range.xw.start, this.range.xw.stop + 0.01, this.range.xw.step).length)
.x((datum) => { return xScale(datum.xs); })
.y((datum) => { return yScale(datum.pw); })
.z((datum) => { return zScale(datum.xw); })
.data(this.mapYZ),
);
// ENTER
const mapXYEnter = mapXYUpdate.enter().append('path')
.attr('class', 'd3-3d map map-xy');
const mapXZEnter = mapXZUpdate.enter().append('path')
.attr('class', 'd3-3d map map-xz');
const mapYZEnter = mapYZUpdate.enter().append('path')
.attr('class', 'd3-3d map map-yz');
// MERGE
mapXYEnter.merge(mapXYUpdate)
.attr('d', grid3d.draw)
.each((datum) => {
const surface = datum.ccw
? points2surfaceNormal(datum[0].rotated, datum[1].rotated, datum[2].rotated)
: points2surfaceNormal(datum[2].rotated, datum[1].rotated, datum[0].rotated);
datum.ratio = cosineAngle(surface, lightSource) - 0.5;
datum.color = d3.color(colorScale(datum[0].uDiff)).brighter(datum.ratio);
})
.attr('fill', (datum) => { return datum.color; })
.attr('stroke', (datum) => { return datum.color; });
mapXZEnter.merge(mapXZUpdate)
.attr('d', grid3d.draw)
.each((datum) => {
const surface = datum.ccw
? points2surfaceNormal(datum[0].rotated, datum[1].rotated, datum[2].rotated)
: points2surfaceNormal(datum[2].rotated, datum[1].rotated, datum[0].rotated);
datum.ratio = cosineAngle(surface, lightSource) - 0.5;
datum.color = d3.color(colorScale(datum[0].uDiff)).brighter(datum.ratio);
})
.attr('fill', (datum) => { return datum.color; })
.attr('stroke', (datum) => { return datum.color; });
mapYZEnter.merge(mapYZUpdate)
.attr('d', grid3d.draw)
.each((datum) => {
const surface = datum.ccw
? points2surfaceNormal(datum[0].rotated, datum[1].rotated, datum[2].rotated)
: points2surfaceNormal(datum[2].rotated, datum[1].rotated, datum[0].rotated);
datum.ratio = cosineAngle(surface, lightSource) - 0.5;
datum.color = d3.color(colorScale(datum[0].uDiff)).brighter(datum.ratio);
})
.attr('fill', (datum) => { return datum.color; })
.attr('stroke', (datum) => { return datum.color; });
// EXIT
mapXYUpdate.exit().remove();
mapXZUpdate.exit().remove();
mapYZUpdate.exit().remove();
// Depth sorting
d3.select(this.renderRoot).selectAll('.d3-3d').sort(d33d.sort);
// Color Legend
// DATA-JOIN
const legendUpdate = svgMerge.selectAll('.legend')
.data([{
x: elementSize + this.rem * 2,
y: elementSize - this.rem * 2,
rem: this.rem,
}]);
// ENTER
const legendEnter = legendUpdate.enter().append('g')
.attr('class', 'legend');
// MERGE
const legendMerge = legendEnter.merge(legendUpdate)
.attr('transform', (datum) => { return `translate(${datum.x} ${datum.y})`; });
// EXIT
legendUpdate.exit().remove();
// Color Legend Axis
// ENTER
legendEnter.append('g')
.attr('class', 'axis axis-legend');
// MERGE
legendMerge.select('.axis-legend')
.call(d3.axisLeft(legendScale).ticks(7))
.attr('font-size', null)
.attr('font-family', null);
// Color Legend Title
// ENTER
legendEnter.append('text')
.attr('class', 'title title-legend')
.html('Difference in Utility (<tspan class="math-var">U<tspan class="subscript">gamble</tspan></tspan> − <tspan class="math-var">U<tspan class="subscript">sure</tspan></tspan>)');
// MERGE
legendMerge.select('.title-legend')
.attr(
'transform',
`translate(${-this.rem * 2.5},${(legendScale(this.range.uDiff.start) + legendScale(this.range.uDiff.stop)) / 2})rotate(-90)`,
);
// Color Legend Bar
// ENTER
legendEnter.append('rect')
.attr('class', 'bar bar-legend')
.attr('fill', 'url("#gradient-legend")');
// MERGE
legendMerge.select('.bar-legend')
.attr('x', 0)
.attr('y', legendScale(this.range.uDiff.stop))
.attr('width', this.rem)
.attr('height', legendScale(this.range.uDiff.start) - legendScale(this.range.uDiff.stop));
this.firstUpdate = false;
}
}
customElements.define('decision-space', DecisionSpace);