@decidables/prospectable-elements
Version:
prospectable-elements: Web Components for visualizing Cumulative Prospect Theory
1,486 lines (1,367 loc) • 45.5 kB
JavaScript
import {css, html, render} from 'lit';
import * as d3 from 'd3';
import CPTMath from '@decidables/prospectable-math';
import {DecidablesMixinResizeable} from '@decidables/decidables-elements';
import ProspectableElement from '../prospectable-element';
/*
CPTValue element
<cpt-value>
*** Add handles to lines?
Attributes:
interactive: true/false
x: numeric (-infinity, infinity)
a: numeric [0, 1]
l: numeric [0, 100]
label: string
Styles:
??
*/
export default class CPTValue extends DecidablesMixinResizeable(ProspectableElement) {
static get properties() {
return {
x: {
attribute: 'value',
type: Number,
reflect: true,
},
a: {
attribute: 'alpha',
type: Number,
reflect: true,
},
l: {
attribute: 'lambda',
type: Number,
reflect: true,
},
label: {
attribute: 'label',
type: String,
reflect: true,
},
v: {
attribute: false,
type: Number,
reflect: false,
},
};
}
constructor() {
super();
this.firstUpdate = true;
this.drag = false;
this.a = CPTMath.a.DEFAULT;
this.l = CPTMath.l.DEFAULT;
this.x = null;
this.label = '';
this.function = 'default';
this.functions = [
{
name: 'default',
a: this.a,
l: this.l,
},
];
this.values = [
{
name: 'default',
x: this.x,
label: this.label,
function: this.function,
},
];
this.xl = null;
this.xw = null;
this.pw = null;
this.xs = null;
this.trialCount = null;
this.response = null;
this.alignState();
}
alignState() {
// Default function
this.functions[0].a = this.a;
this.functions[0].l = this.l;
// Default values
this.values[0].x = this.x;
this.values[0].label = this.label;
this.values[0].function = this.function;
// Update subjective values
this.values.forEach((value) => {
const myFunction = this.functions.find((func) => {
return func.name === value.function;
});
value.v = CPTMath.xal2v(value.x, myFunction.a, myFunction.l);
});
this.v = this.values[0].v;
}
trial(xl, xw, pw, xs, trial, response) {
// Remove the old trial
if (this.trialCount) this.removeValue(`${this.trialCount}-w`);
if (this.trialCount) this.removeValue(`${this.trialCount}-s`);
this.xl = xl;
this.xw = xw;
this.pw = pw;
this.xs = xs;
this.trialCount = trial;
this.response = response;
// Add the new trial
this.setValue(this.xw, `${this.trialCount}-w`, 'g', 'default', true);
this.setValue(this.xs, `${this.trialCount}-s`, 's', 'default', true);
}
// Called to pause trial animations!
pauseTrial() {
const lineNew = d3.select(this.renderRoot).selectAll('.lines[data-animating-ease-time-1]');
lineNew.interrupt('new-1');
lineNew.interrupt('new-2');
lineNew.datum((datum) => {
datum.paused = true;
return datum;
});
}
// Called to resume trial animations!
resumeTrial() {
const lineNew = d3.select(this.renderRoot).selectAll('.lines[data-animating-ease-time-1]');
lineNew.datum((datum) => {
datum.paused = false;
return datum;
});
this.requestUpdate();
}
clearFunctions() {
this.functions.splice(1);
this.requestUpdate();
}
clearValues() {
this.values.splice(1);
this.requestUpdate();
}
clear() {
this.clearFunctions();
this.clearValues();
}
removeFunction(name) {
this.functions = this.functions.filter((func) => {
return (func.name !== name);
});
this.requestUpdate();
}
removeValue(name) {
this.values = this.values.filter((value) => {
return (value.name !== name);
});
this.requestUpdate();
}
remove(name) {
this.removeFunction(name);
this.removeValue(name);
}
getFunction(name = 'default') {
return this.functions.find((func) => {
return (func.name === name);
});
}
getValue(name = 'default') {
return this.values.find((value) => {
return (value.name === name);
});
}
get(name = 'default') {
return {...this.getFunction(name), ...this.getValue(name)};
}
setFunction(a, l, name = 'default') {
if (name === 'default') {
this.a = a;
this.l = l;
}
const myFunction = this.functions.find((func) => {
return (func.name === name);
});
if (myFunction === undefined) {
this.functions.push({
name: name,
a: a,
l: l,
});
} else {
myFunction.a = a;
myFunction.l = l;
}
this.requestUpdate();
}
setValue(x, name = 'default', label = '', func = name, trial = false) {
if (name === 'default') {
this.x = x;
this.label = label;
}
const myValue = this.values.find((value) => {
return (value.name === name);
});
if (myValue === undefined) {
this.values.push({
name: name,
x: x,
label: label,
function: func,
trial: trial,
new: trial,
});
} else {
myValue.x = x;
myValue.label = label;
myValue.function = func;
}
this.requestUpdate();
}
set(x, a, l, name = 'default', label = '', func = name) {
this.setFunction(a, l, func);
this.setValue(x, name, label, func);
}
static get styles() {
return [
super.styles,
css`
:host {
display: inline-block;
width: 20rem;
height: 20rem;
}
.main {
width: 100%;
height: 100%;
}
text {
/* stylelint-disable property-no-vendor-prefix */
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.curve-p.interactive,
.curve-n.interactive {
cursor: nwse-resize;
outline: none;
filter: url("#shadow-2");
}
.curve-p.interactive:hover,
.curve-n.interactive:hover {
filter: url("#shadow-4");
/* HACK: This gets Safari to correctly apply the filter! */
/* https://github.com/emilbjorklund/svg-weirdness/issues/27 */
transform: translateX(0);
}
.curve-p.interactive:active,
.curve-n.interactive:active {
filter: url("#shadow-8");
/* HACK: This gets Safari to correctly apply the filter! */
transform: translateY(0);
}
.curve-p.interactive:focus-visible,
.curve-n.interactive:focus-visible {
filter: url("#shadow-8");
/* HACK: This gets Safari to correctly apply the filter! */
transform: translateZ(0);
}
.point.interactive {
cursor: nesw-resize;
outline: none;
filter: url("#shadow-2");
/* HACK: This gets Safari to correctly apply the filter! */
stroke: #000000;
stroke-opacity: 0;
stroke-width: 0;
}
.point.interactive:hover {
filter: url("#shadow-4");
/* HACK: This gets Safari to correctly apply the filter! */
stroke: #ff0000;
}
.point.interactive:active {
filter: url("#shadow-8");
/* HACK: This gets Safari to correctly apply the filter! */
stroke: #00ff00;
}
.point.interactive:focus-visible {
filter: url("#shadow-8");
/* HACK: This gets Safari to correctly apply the filter! */
stroke: #0000ff;
}
.background {
fill: var(---color-element-background);
stroke: var(---color-element-border);
stroke-width: 1;
shape-rendering: crispEdges;
}
.title-x,
.title-y {
font-weight: 600;
fill: currentColor;
}
.tick {
font-size: 0.75rem;
}
.scale-x path,
.scale-x line,
.scale-y path,
.scale-y line {
stroke: var(---color-element-border);
}
.axis-x,
.axis-y {
stroke: var(---color-element-border);
shape-rendering: crispEdges;
}
.diagonal {
stroke: var(---color-element-border);
stroke-width: 1;
stroke-dasharray: 4;
}
.curve-p,
.curve-n {
fill: none;
stroke: var(---color-element-emphasis);
stroke-width: 2;
}
.line-x,
.line-v {
fill: none;
stroke-width: 2;
}
.line-x {
stroke: var(---color-x);
}
.line-v {
stroke: var(---color-v);
}
.point .circle {
fill: var(---color-element-emphasis);
r: 6px;
}
.point .label {
font-size: 0.75rem;
dominant-baseline: middle;
text-anchor: middle;
fill: var(---color-text-inverse);
}
/* Make larger targets for touch users */
.interactive .touch {
stroke: #000000;
stroke-opacity: 0;
}
(pointer: coarse) {
.interactive .touch {
stroke-width: 12;
stroke-linecap: round;
}
}
`,
];
}
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: 2 * this.rem,
bottom: 3 * this.rem,
left: 3 * this.rem,
right: 2 * this.rem,
};
const height = elementSize - (margin.top + margin.bottom);
const width = elementSize - (margin.left + margin.right);
const transitionDuration = parseInt(this.getComputedStyleValue('---transition-duration'), 10);
const domainScale = 20;
// X Scale
const xScale = d3.scaleLinear()
.domain([-domainScale, domainScale])
.range([0, width]);
this.xScale = xScale;
// Y Scale
const yScale = d3.scaleLinear()
.domain([domainScale, -domainScale])
.range([0, height]);
this.yScale = yScale;
// Drag behaviors
const curvePDrag = d3.drag()
.subject((event, datum) => {
return {
x: event.x,
y: this.yScale(CPTMath.xal2v(this.xScale.invert(event.x), datum.a, datum.l)),
};
})
.on('start', (event) => {
const element = event.currentTarget;
d3.select(element).classed('dragging', true);
})
.on('drag', (event, datum) => {
this.drag = true;
const x = this.xScale.invert(event.x);
const v = this.yScale.invert(event.y);
const a = CPTMath.xlv2a(x, datum.l, v);
// Clamp a to legal values [0, 1]
datum.a = (
Number.isNaN(a)
|| (a < CPTMath.a.MIN)
|| (a > CPTMath.a.MAX)
|| (x < 0)
|| (v < 0)
)
? ((x > v)
? CPTMath.a.MIN
: CPTMath.a.MAX)
: a;
if (datum.name === 'default') {
this.a = datum.a;
}
this.alignState();
this.requestUpdate();
this.dispatchEvent(new CustomEvent('cpt-value-change', {
detail: this.get(datum.name),
bubbles: true,
}));
})
.on('end', (event) => {
const element = event.currentTarget;
d3.select(element).classed('dragging', false);
});
const curveNDrag = d3.drag()
.subject((event, datum) => {
return {
x: event.x,
y: this.yScale(CPTMath.xal2v(this.xScale.invert(event.x), datum.a, datum.l)),
};
})
.on('start', (event) => {
const element = event.currentTarget;
d3.select(element).classed('dragging', true);
})
.on('drag', (event, datum) => {
this.drag = true;
const x = this.xScale.invert(event.x);
const v = this.yScale.invert(event.y);
const l = CPTMath.xav2l(x, datum.a, v);
// Clamp l to legal values [0, ?
datum.l = (
Number.isNaN(l)
|| (l < CPTMath.l.MIN)
|| (l > CPTMath.l.MAX)
|| (x > 0)
|| (v > 0)
)
? ((x > v)
? CPTMath.l.MAX
: CPTMath.l.MIN)
: l;
if (datum.name === 'default') {
this.l = datum.l;
}
this.alignState();
this.requestUpdate();
this.dispatchEvent(new CustomEvent('cpt-value-change', {
detail: this.get(datum.name),
bubbles: true,
}));
})
.on('end', (event) => {
const element = event.currentTarget;
d3.select(element).classed('dragging', false);
});
const pointDrag = d3.drag()
.subject((event, datum) => {
return {
x: this.xScale(datum.x),
y: this.yScale(datum.v),
};
})
.on('start', (event) => {
const element = event.currentTarget;
d3.select(element).classed('dragging', true);
})
.on('drag', (event, datum) => {
this.drag = true;
const x = this.xScale.invert(event.x);
// Clamp x to visible plot
datum.x = (x < -domainScale)
? -domainScale
: ((x > domainScale)
? domainScale
: x);
if (datum.name === 'default') {
this.x = datum.x;
}
this.alignState();
this.requestUpdate();
this.dispatchEvent(new CustomEvent('cpt-value-change', {
detail: {
name: datum.name,
x: datum.x,
v: datum.v,
label: datum.label,
a: this.getFunction(datum.function).a,
l: this.getFunction(datum.function).l,
},
bubbles: true,
}));
})
.on('end', (event) => {
const element = event.currentTarget;
d3.select(element).classed('dragging', false);
});
// Line for value
const line = d3.line()
.x((datum) => { return xScale(datum.x); })
.y((datum) => { return yScale(datum.v); });
// 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}`);
// Plot
// ENTER
const plotEnter = svgEnter.append('g')
.classed('plot', true);
// MERGE
const plotMerge = svgMerge.select('.plot')
.attr('transform', `translate(${margin.left}, ${margin.top})`);
// Clippath
// ENTER
plotEnter.append('clipPath')
.attr('id', 'clip-cpt-value')
.append('rect');
// MERGE
plotMerge.select('clipPath rect')
.attr('height', height + 1)
.attr('width', width + 1);
// Underlayer
// ENTER
const underlayerEnter = plotEnter.append('g')
.classed('underlayer', true);
// MERGE
const underlayerMerge = plotMerge.select('.underlayer');
// Background
// ENTER
underlayerEnter.append('rect')
.classed('background', true);
// MERGE
underlayerMerge.select('.background')
.attr('height', height)
.attr('width', width);
// X Scale
// ENTER
underlayerEnter.append('g')
.classed('scale-x', true);
// MERGE
const scaleXMerge = underlayerMerge.select('.scale-x')
.attr('transform', `translate(0, ${yScale(-domainScale)})`);
const scaleXTransition = scaleXMerge.transition()
.duration(transitionDuration * 2) // Extra long transition!
.ease(d3.easeCubicOut)
.call(d3.axisBottom(xScale))
.attr('font-size', null)
.attr('font-family', null);
scaleXTransition.selectAll('line, path')
.attr('stroke', null);
// X Axis
// ENTER
underlayerEnter.append('line')
.classed('axis-x', true);
// MERGE
underlayerMerge.select('.axis-x').transition()
.duration(transitionDuration)
.ease(d3.easeCubicOut)
.attr('x1', xScale(-domainScale))
.attr('x2', xScale(domainScale))
.attr('y1', yScale(0))
.attr('y2', yScale(0));
// X Axis Title
// ENTER
const titleXEnter = underlayerEnter.append('text')
.classed('title-x', true)
.attr('text-anchor', 'middle');
titleXEnter.append('tspan')
.classed('name', true)
.text('Objective Value (');
titleXEnter.append('tspan')
.classed('math-var x', true)
.text('x');
titleXEnter.append('tspan')
.classed('name', true)
.text(')');
// MERGE
underlayerMerge.select('.title-x')
.attr('transform', `translate(${(width / 2)}, ${(height + (2.25 * this.rem))})`);
// Y Scale
// ENTER
underlayerEnter.append('g')
.classed('scale-y', true);
// MERGE
const scaleYTransition = underlayerMerge.select('.scale-y').transition()
.duration(transitionDuration * 2) // Extra long transition!
.ease(d3.easeCubicOut)
.call(d3.axisLeft(yScale))
.attr('font-size', null)
.attr('font-family', null);
scaleYTransition.selectAll('line, path')
.attr('stroke', null);
// Y Axis
// ENTER
underlayerEnter.append('line')
.classed('axis-y', true);
// MERGE
underlayerMerge.select('.axis-y').transition()
.duration(transitionDuration)
.ease(d3.easeCubicOut)
.attr('x1', xScale(0))
.attr('x2', xScale(0))
.attr('y1', yScale(domainScale))
.attr('y2', yScale(-domainScale));
// Y Axis Title
// ENTER
const titleYEnter = underlayerEnter.append('text')
.classed('title-y', true)
.attr('text-anchor', 'middle');
titleYEnter.append('tspan')
.classed('name', true)
.text('Subjective Value (');
titleYEnter.append('tspan')
.classed('math-var v', true)
.text('v');
titleYEnter.append('tspan')
.classed('name', true)
.text(')');
// MERGE
underlayerMerge.select('.title-y')
.attr('transform', `translate(${-2 * this.rem}, ${(height / 2)})rotate(-90)`);
// No-Subjectivity Line
// ENTER
underlayerEnter.append('line')
.classed('diagonal', true);
// MERGE
underlayerMerge.select('.diagonal').transition()
.duration(transitionDuration * 2) // Extra long transition!
.ease(d3.easeCubicOut)
.attr('x1', xScale(-domainScale))
.attr('y1', yScale(-domainScale))
.attr('x2', xScale(domainScale))
.attr('y2', yScale(domainScale));
// Content
// ENTER
plotEnter.append('g')
.classed('content', true);
// MERGE
const contentMerge = plotMerge.select('.content');
// Indicator lines
// DATA-JOIN
const lineUpdate = contentMerge.selectAll('.lines')
.data(
this.values.filter((value) => { return (value.x != null); }),
(datum) => { return datum.name; },
);
// ENTER
const lineEnter = lineUpdate.enter().append('g')
.classed('lines', true);
// ENTER - All
lineEnter
.each((datum, index, elements) => {
const element = elements[index];
const selection = d3.select(element);
selection.append('line')
.classed('line-x above', true);
selection.append('line')
.classed('line-x below', true);
selection.append('line')
.classed('line-v before', true);
selection.append('line')
.classed('line-v after', true);
});
// ENTER - Animating
lineEnter
.filter((datum) => { return datum.new; })
.attr('data-animating-ease-time-1', 0)
.attr('data-animating-ease-time-2', 0)
.each((datum, index, elements) => {
const element = elements[index];
const selection = d3.select(element);
selection.select('.line-x.above')
.attr('x1', xScale(datum.x))
.attr('x2', xScale(datum.x))
.attr('y1', yScale(domainScale))
.attr('y2', yScale(domainScale));
selection.select('.line-x.below')
.attr('x1', xScale(datum.x))
.attr('x2', xScale(datum.x))
.attr('y1', yScale(-domainScale))
.attr('y2', yScale(-domainScale));
selection.select('.line-v.before')
.attr('x1', xScale(datum.x))
.attr('x2', xScale(datum.x))
.attr('y1', yScale(datum.v))
.attr('y2', yScale(datum.v));
selection.select('.line-v.after')
.attr('x1', xScale(datum.x))
.attr('x2', xScale(datum.x))
.attr('y1', yScale(datum.v))
.attr('y2', yScale(datum.v));
});
// MERGE
const lineMerge = lineEnter.merge(lineUpdate);
// MERGE - Active Animating
const lineMergeActive = lineMerge.filter((datum) => {
return (datum.new && !datum.paused);
});
if (!lineMergeActive.empty()) {
const easeTime1 = lineMergeActive.attr('data-animating-ease-time-1');
const easeTime2 = lineMergeActive.attr('data-animating-ease-time-2');
const scaleIn1 = (time) => {
return d3.scaleLinear().domain([0, 1]).range([easeTime1, 1])(time);
};
const scaleIn1Inverse = (time) => {
return d3.scaleLinear().range([0, 1]).domain([easeTime1, 1])(time);
};
const scaleIn2 = (time) => {
return d3.scaleLinear().domain([0, 1]).range([easeTime2, 1])(time);
};
const scaleIn2Inverse = (time) => {
return d3.scaleLinear().range([0, 1]).domain([easeTime2, 1])(time);
};
const scaleOutGenerator1 = (easeFunction) => {
return (time) => {
return d3.scaleLinear()
.domain([easeFunction(easeTime1), 1]).range([0, 1])(easeFunction(time));
};
};
const scaleOutGenerator2 = (easeFunction) => {
return (time) => {
return d3.scaleLinear()
.domain([easeFunction(easeTime2), 1]).range([0, 1])(easeFunction(time));
};
};
lineMergeActive
.transition('new-1')
.duration(() => {
return Math.floor(transitionDuration * (1 - easeTime1));
})
.ease(scaleIn1)
.attr('data-animating-ease-time-1', 1)
.tween('animating', (datum, index, elements) => {
const element = elements[index];
const selection = d3.select(element);
const interpolateX = d3.interpolate(
(element.x !== undefined) ? element.x : datum.x,
datum.x,
);
const interpolateA = d3.interpolate(
(element.a !== undefined) ? element.a : this.getFunction(datum.function).a,
this.getFunction(datum.function).a,
);
const interpolateL = d3.interpolate(
(element.l !== undefined) ? element.l : this.getFunction(datum.function).l,
this.getFunction(datum.function).l,
);
const interpolateAbove = d3.interpolate(
yScale.invert(selection.select('.line-x.above').attr('y1')),
datum.v,
);
const interpolateBelow = d3.interpolate(
yScale.invert(selection.select('.line-x.below').attr('y1')),
datum.v,
);
return (time) => {
element.x = interpolateX(d3.easeCubicOut(scaleIn1Inverse(time)));
element.a = interpolateA(d3.easeCubicOut(scaleIn1Inverse(time)));
element.l = interpolateL(d3.easeCubicOut(scaleIn1Inverse(time)));
element.v = CPTMath.xal2v(element.x, element.a, element.l);
selection.select('.line-x.above')
.attr('x1', xScale(element.x))
.attr('x2', xScale(element.x))
.attr('y1', yScale(interpolateAbove(scaleOutGenerator1(d3.easeCubicIn)(time))))
.attr('y2', yScale(domainScale));
selection.select('.line-x.below')
.attr('x1', xScale(element.x))
.attr('x2', xScale(element.x))
.attr('y1', yScale(interpolateBelow(scaleOutGenerator1(d3.easeCubicIn)(time))))
.attr('y2', yScale(-domainScale));
};
})
.transition('new-2')
.duration(() => {
return Math.floor(transitionDuration * (1 - easeTime2));
})
.ease(scaleIn2)
.attr('data-animating-ease-time-2', 1)
.tween('animating', (datum, index, elements) => {
const element = elements[index];
const selection = d3.select(element);
const interpolateX = d3.interpolate(
(element.x !== undefined) ? element.x : datum.x,
datum.x,
);
const interpolateA = d3.interpolate(
(element.a !== undefined) ? element.a : this.getFunction(datum.function).a,
this.getFunction(datum.function).a,
);
const interpolateL = d3.interpolate(
(element.l !== undefined) ? element.l : this.getFunction(datum.function).l,
this.getFunction(datum.function).l,
);
const interpolateBefore = d3.interpolate(
xScale.invert(selection.select('.line-v.before').attr('x1')),
-domainScale,
);
const interpolateAfter = d3.interpolate(
xScale.invert(selection.select('.line-v.after').attr('x1')),
domainScale,
);
return (time) => {
element.x = interpolateX(d3.easeCubicOut(scaleIn2Inverse(time)));
element.a = interpolateA(d3.easeCubicOut(scaleIn2Inverse(time)));
element.l = interpolateL(d3.easeCubicOut(scaleIn2Inverse(time)));
element.v = CPTMath.xal2v(element.x, element.a, element.l);
selection.select('.line-v.before')
.attr('x1', xScale(interpolateBefore(scaleOutGenerator2(d3.easeCubicOut)(time))))
.attr('x2', xScale(element.x))
.attr('y1', yScale(element.v))
.attr('y2', yScale(element.v));
selection.select('.line-v.after')
.attr('x1', xScale(interpolateAfter(scaleOutGenerator2(d3.easeCubicOut)(time))))
.attr('x2', xScale(element.x))
.attr('y1', yScale(element.v))
.attr('y2', yScale(element.v));
};
})
.on('end', (datum, index, elements) => {
const element = elements[index];
element.removeAttribute('data-animating-ease-time-1');
element.removeAttribute('data-animating-ease-time-2');
datum.new = false;
this.dispatchEvent(new CustomEvent('prospectable-response', {
detail: {
trial: this.trialCount,
xl: this.xl,
xw: this.xw,
pw: this.pw,
xs: this.xs,
response: this.response,
},
bubbles: true,
}));
});
}
// MERGE - Paused Animating
const lineMergePaused = lineMerge.filter((datum) => {
return (datum.new && datum.paused);
});
if (!lineMergePaused.empty()) {
const easeTime1 = lineMergePaused.attr('data-animating-ease-time-1');
const easeTime2 = lineMergePaused.attr('data-animating-ease-time-2');
lineMergePaused.transition()
.duration(this.drag
? 0
: (this.firstUpdate
? (transitionDuration * 2)
: transitionDuration))
.ease(d3.easeCubicOut)
.tween('paused', (datum, index, elements) => {
const element = elements[index];
const selection = d3.select(element);
const interpolateX = d3.interpolate(
(element.x !== undefined) ? element.x : datum.x,
datum.x,
);
const interpolateA = d3.interpolate(
(element.a !== undefined) ? element.a : this.getFunction(datum.function).a,
this.getFunction(datum.function).a,
);
const interpolateL = d3.interpolate(
(element.l !== undefined) ? element.l : this.getFunction(datum.function).l,
this.getFunction(datum.function).l,
);
const interpolateAbove = d3.interpolate(domainScale, datum.v);
const interpolateBelow = d3.interpolate(-domainScale, datum.v);
const interpolateBefore = d3.interpolate(datum.x, -domainScale);
const interpolateAfter = d3.interpolate(datum.x, domainScale);
return (time) => {
element.x = interpolateX(time);
element.a = interpolateA(time);
element.l = interpolateL(time);
element.v = CPTMath.xal2v(element.x, element.a, element.l);
selection.select('.line-x.above')
.attr('x1', xScale(element.x))
.attr('x2', xScale(element.x))
.attr('y1', yScale(interpolateAbove(d3.easeCubicIn(easeTime1))))
.attr('y2', yScale(domainScale));
selection.select('.line-x.below')
.attr('x1', xScale(element.x))
.attr('x2', xScale(element.x))
.attr('y1', yScale(interpolateBelow(d3.easeCubicIn(easeTime1))))
.attr('y2', yScale(-domainScale));
selection.select('.line-v.before')
.attr('x1', xScale(interpolateBefore(d3.easeCubicOut(easeTime2))))
.attr('x2', xScale(element.x))
.attr('y1', yScale(element.v))
.attr('y2', yScale(element.v));
selection.select('.line-v.after')
.attr('x1', xScale(interpolateAfter(d3.easeCubicOut(easeTime2))))
.attr('x2', xScale(element.x))
.attr('y1', yScale(element.v))
.attr('y2', yScale(element.v));
};
});
}
// MERGE - Non-animating
lineMerge.filter((datum) => { return !datum.new; })
.transition()
.duration(this.drag
? 0
: (this.firstUpdate
? (transitionDuration * 2)
: transitionDuration))
.ease(d3.easeCubicOut)
.tween('non-animating', (datum, index, elements) => {
const element = elements[index];
const selection = d3.select(element);
const interpolateX = d3.interpolate(
(element.x !== undefined) ? element.x : datum.x,
datum.x,
);
const interpolateA = d3.interpolate(
(element.a !== undefined) ? element.a : this.getFunction(datum.function).a,
this.getFunction(datum.function).a,
);
const interpolateL = d3.interpolate(
(element.l !== undefined) ? element.l : this.getFunction(datum.function).l,
this.getFunction(datum.function).l,
);
return (time) => {
element.x = interpolateX(time);
element.a = interpolateA(time);
element.l = interpolateL(time);
element.v = CPTMath.xal2v(element.x, element.a, element.l);
selection.select('.line-x.above')
.attr('x1', xScale(element.x))
.attr('x2', xScale(element.x))
.attr('y1', yScale(element.v))
.attr('y2', yScale(domainScale));
selection.select('.line-x.below')
.attr('x1', xScale(element.x))
.attr('x2', xScale(element.x))
.attr('y1', yScale(element.v))
.attr('y2', yScale(-domainScale));
selection.select('.line-v.before')
.attr('x1', xScale(-domainScale))
.attr('x2', xScale(element.x))
.attr('y1', yScale(element.v))
.attr('y2', yScale(element.v));
selection.select('.line-v.after')
.attr('x1', xScale(domainScale))
.attr('x2', xScale(element.x))
.attr('y1', yScale(element.v))
.attr('y2', yScale(element.v));
};
});
// EXIT
// NOTE: Could add a transition here
lineUpdate.exit().remove();
// Positive Value Curve
// DATA-JOIN
const curvePUpdate = contentMerge.selectAll('.curve-p')
.data(this.functions, (datum) => { return datum.name; });
// ENTER
const curvePEnter = curvePUpdate.enter().append('g')
.classed('curve-p', true)
.attr('clip-path', 'url(#clip-cpt-value)');
curvePEnter.append('path')
.classed('path-p', true);
curvePEnter.append('path')
.classed('path-p touch', true);
// MERGE
const curvePMerge = curvePEnter.merge(curvePUpdate);
if (this.firstUpdate || changedProperties.has('interactive')) {
if (this.interactive) {
curvePMerge
.attr('tabindex', 0)
.classed('interactive', true)
.call(curvePDrag)
.on('keydown', (event, datum) => {
if (['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'].includes(event.key)) {
let a = datum.a; /* eslint-disable-line prefer-destructuring */
switch (event.key) {
case 'ArrowUp':
case 'ArrowLeft':
a += event.shiftKey ? CPTMath.a.STEP : CPTMath.a.JUMP;
break;
case 'ArrowDown':
case 'ArrowRight':
a -= event.shiftKey ? CPTMath.a.STEP : CPTMath.a.JUMP;
break;
default:
// no-op
}
// Clamp a to legal values [0, 1]
a = (a < CPTMath.a.MIN)
? CPTMath.a.MIN
: ((a > CPTMath.a.MAX)
? CPTMath.a.MAX
: a);
if (a !== datum.a) {
datum.a = a;
if (datum.name === 'default') {
this.a = datum.a;
}
this.alignState();
this.requestUpdate();
this.dispatchEvent(new CustomEvent('cpt-value-change', {
detail: this.get(datum.name),
bubbles: true,
}));
}
event.preventDefault();
}
});
} else {
curvePMerge
.attr('tabindex', null)
.classed('interactive', false)
.on('drag', null)
.on('keydown', null);
}
}
curvePMerge.select('.path-p').transition()
.duration(this.drag
? 0
: (this.firstUpdate
? (transitionDuration * 2)
: transitionDuration))
.ease(d3.easeCubicOut)
.attrTween('d', (datum, index, elements) => {
const element = elements[index];
const interpolateA = d3.interpolate(
(element.a !== undefined) ? element.a : datum.a,
datum.a,
);
const interpolateL = d3.interpolate(
(element.l !== undefined) ? element.l : datum.l,
datum.l,
);
return (time) => {
element.a = interpolateA(time);
element.l = interpolateL(time);
const curveP = d3.range(xScale(0), xScale.range()[1] + 1, 1).map((range) => {
return {
x: xScale.invert(range),
v: CPTMath.xal2v(
xScale.invert(range),
element.a,
element.l,
),
};
});
return line(curveP);
};
});
curvePMerge.select('.path-p.touch').transition()
.duration(this.drag
? 0
: (this.firstUpdate
? (transitionDuration * 2)
: transitionDuration))
.ease(d3.easeCubicOut)
.attrTween('d', (datum, index, elements) => {
const element = elements[index];
const interpolateA = d3.interpolate(
(element.a !== undefined) ? element.a : datum.a,
datum.a,
);
const interpolateL = d3.interpolate(
(element.l !== undefined) ? element.l : datum.l,
datum.l,
);
return (time) => {
element.a = interpolateA(time);
element.l = interpolateL(time);
const curveP = d3.range(xScale(0), xScale.range()[1] + 1, 1).map((range) => {
return {
x: xScale.invert(range),
v: CPTMath.xal2v(
xScale.invert(range),
element.a,
element.l,
),
};
});
return line(curveP);
};
});
// EXIT
// NOTE: Could add a transition here
curvePUpdate.exit().remove();
// Negative Value Curve
// DATA-JOIN
const curveNUpdate = contentMerge.selectAll('.curve-n')
.data(this.functions, (datum) => { return datum.name; });
// ENTER
const curveNEnter = curveNUpdate.enter().append('g')
.classed('curve-n', true)
.attr('clip-path', 'url(#clip-cpt-value)');
curveNEnter.append('path')
.classed('path-n', true);
curveNEnter.append('path')
.classed('path-n touch', true);
// MERGE
const curveNMerge = curveNEnter.merge(curveNUpdate);
if (this.firstUpdate || changedProperties.has('interactive')) {
if (this.interactive) {
curveNMerge
.attr('tabindex', 0)
.classed('interactive', true)
.call(curveNDrag)
.on('keydown', (event, datum) => {
if (['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'].includes(event.key)) {
let l = datum.l; /* eslint-disable-line prefer-destructuring */
switch (event.key) {
case 'ArrowUp':
case 'ArrowLeft':
l -= event.shiftKey ? CPTMath.l.STEP : CPTMath.l.JUMP;
break;
case 'ArrowDown':
case 'ArrowRight':
l += event.shiftKey ? CPTMath.l.STEP : CPTMath.l.JUMP;
break;
default:
// no-op
}
// Clamp l to legal values [0, ?
l = (l < CPTMath.l.MIN)
? CPTMath.l.MIN
: ((l > CPTMath.l.MAX)
? CPTMath.l.MAX
: l);
if (l !== datum.l) {
datum.l = l;
if (datum.name === 'default') {
this.l = datum.l;
}
this.alignState();
this.requestUpdate();
this.dispatchEvent(new CustomEvent('cpt-value-change', {
detail: this.get(datum.name),
bubbles: true,
}));
}
event.preventDefault();
}
});
} else {
curveNMerge
.attr('tabindex', null)
.classed('interactive', false)
.on('drag', null)
.on('keydown', null);
}
}
curveNMerge.select('.path-n').transition()
.duration(this.drag
? 0
: (this.firstUpdate
? (transitionDuration * 2)
: transitionDuration))
.ease(d3.easeCubicOut)
.attrTween('d', (datum, index, elements) => {
const element = elements[index];
const interpolateA = d3.interpolate(
(element.a !== undefined) ? element.a : datum.a,
datum.a,
);
const interpolateL = d3.interpolate(
(element.l !== undefined) ? element.l : datum.l,
datum.l,
);
return (time) => {
element.a = interpolateA(time);
element.l = interpolateL(time);
const curveN = d3.range(xScale.range()[0], xScale(0) + 1, 1).map((range) => {
return {
x: xScale.invert(range),
v: CPTMath.xal2v(
xScale.invert(range),
element.a,
element.l,
),
};
});
return line(curveN);
};
});
curveNMerge.select('.path-n.touch').transition()
.duration(this.drag
? 0
: (this.firstUpdate
? (transitionDuration * 2)
: transitionDuration))
.ease(d3.easeCubicOut)
.attrTween('d', (datum, index, elements) => {
const element = elements[index];
const interpolateA = d3.interpolate(
(element.a !== undefined) ? element.a : datum.a,
datum.a,
);
const interpolateL = d3.interpolate(
(element.l !== undefined) ? element.l : datum.l,
datum.l,
);
return (time) => {
element.a = interpolateA(time);
element.l = interpolateL(time);
const curveN = d3.range(xScale.range()[0], xScale(0) + 1, 1).map((range) => {
return {
x: xScale.invert(range),
v: CPTMath.xal2v(
xScale.invert(range),
element.a,
element.l,
),
};
});
return line(curveN);
};
});
// EXIT
// NOTE: Could add a transition here
curveNUpdate.exit().remove();
// Point
// DATA-JOIN
const pointUpdate = contentMerge.selectAll('.point')
.data(
this.values.filter((value) => { return (value.x != null); }),
(datum) => { return datum.name; },
);
// ENTER
const pointEnter = pointUpdate.enter().append('g')
.classed('point', true);
pointEnter.append('circle')
.classed('circle touch', true);
pointEnter.append('text')
.classed('label', true);
// MERGE
const pointMerge = pointEnter.merge(pointUpdate);
pointMerge.select('text')
.text((datum) => { return datum.label; });
// Interactive points
pointMerge.filter((datum) => { return ((this.firstUpdate || changedProperties.has('interactive')) && this.interactive && !datum.trial); })
.attr('tabindex', 0)
.classed('interactive', true)
.call(pointDrag)
.on('keydown', (event, datum) => {
if (['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'].includes(event.key)) {
let x = datum.x; /* eslint-disable-line prefer-destructuring */
switch (event.key) {
case 'ArrowUp':
case 'ArrowRight':
x += event.shiftKey ? 0.2 : 1;
break;
case 'ArrowDown':
case 'ArrowLeft':
x -= event.shiftKey ? 0.2 : 1;
break;
default:
// no-op
}
// Clamp x to visible plot
x = (x < -domainScale)
? -domainScale
: ((x > domainScale)
? domainScale
: x);
if (x !== datum.x) {
datum.x = x;
if (datum.name === 'default') {
this.x = datum.x;
}
this.alignState();
this.requestUpdate();
this.dispatchEvent(new CustomEvent('cpt-value-change', {
detail: {
name: datum.name,
x: datum.x,
v: datum.v,
label: datum.label,
a: this.getFunction(datum.function).a,
l: this.getFunction(datum.function).l,
},
bubbles: true,
}));
}
event.preventDefault();
}
});
// Non-interactive points
pointMerge.filter((datum) => { return (((this.firstUpdate || changedProperties.has('interactive')) && !this.interactive) || datum.trial); })
.attr('tabindex', null)
.classed('interactive', false)
.on('drag', null)
.on('keydown', null);
// All points
pointMerge.transition()
.duration(this.drag
? 0
: (this.firstUpdate
? (transitionDuration * 2)
: transitionDuration))
.ease(d3.easeCubicOut)
.attrTween('transform', (datum, index, elements) => {
const element = elements[index];
const interpolateX = d3.interpolate(
(element.x !== undefined) ? element.x : datum.x,
datum.x,
);
const interpolateA = d3.interpolate(
(element.a !== undefined) ? element.a : this.getFunction(datum.function).a,
this.getFunction(datum.function).a,
);
const interpolateL = d3.interpolate(
(element.l !== undefined) ? element.l : this.getFunction(datum.function).l,
this.getFunction(datum.function).l,
);
return (time) => {
element.x = interpolateX(time);
element.a = interpolateA(time);
element.l = interpolateL(time);
return `translate(
${xScale(element.x)},
${yScale(CPTMath.xal2v(element.x, element.a, element.l))}
)`;
};
});
// EXIT
// NOTE: Could add a transition here
pointUpdate.exit().remove();
this.drag = false;
// this.sdt = false;
this.firstUpdate = false;
}
}
customElements.define('cpt-value', CPTValue);