UNPKG

@decidables/detectable-elements

Version:

detectable-elements: Web Components for visualizing Signal Detection Theory

1,617 lines (1,497 loc) 57.1 kB
import {html, css} from 'lit'; import * as d3 from 'd3'; import jStat from 'jstat'; import {DecidablesMixinResizeable} from '@decidables/decidables-elements'; import SDTMath from '@decidables/detectable-math'; import DetectableElement from '../detectable-element'; /* SDTModel element <sdt-model> Attributes: d'; C; FAR; HR; zFAR; zHR; draggable: d'; C; highlight: H; M; CR; FA; Styles: ?? */ export default class SDTModel extends DecidablesMixinResizeable(DetectableElement) { static get properties() { return { color: { attribute: 'color', type: String, reflect: true, }, distributions: { attribute: 'distributions', type: Boolean, reflect: true, }, threshold: { attribute: 'threshold', type: Boolean, reflect: true, }, unequal: { attribute: 'unequal', type: Boolean, reflect: true, }, sensitivity: { attribute: 'sensitivity', type: Boolean, reflect: true, }, bias: { attribute: 'bias', type: Boolean, reflect: true, }, variance: { attribute: 'variance', type: Boolean, reflect: true, }, histogram: { attribute: 'histogram', type: Boolean, reflect: true, }, d: { attribute: 'd', type: Number, reflect: true, }, c: { attribute: 'c', type: Number, reflect: true, }, s: { attribute: 's', type: Number, reflect: true, }, far: { attribute: false, type: Number, reflect: false, }, hr: { attribute: false, type: Number, reflect: false, }, binWidth: { attribute: false, type: Number, reflect: false, }, trials: { attribute: false, type: Array, reflect: false, }, }; } constructor() { super(); // Attributes this.colors = ['all', 'outcome', 'response', 'stimulus', 'none']; // Allowable values of 'color' this.color = 'all'; // How to color distributions and trials this.distributions = false; // Show distributions? this.threshold = false; // Show threshold? this.unequal = false; // Allow unequal variance? this.sensitivity = false; // Show d'? this.bias = false; // Show c? this.variance = false; // Show variance? this.histogram = false; // Show histogram? this.d = SDTMath.d.DEFAULT; // Sensitivity this.c = SDTMath.c.DEFAULT; // Bias this.s = SDTMath.s.DEFAULT; // Variance // Properties this.binWidth = 0.25; // Histogram bin width in units of evidence this.signals = ['present', 'absent']; // Allowable values of trial.signal this.responses = ['present', 'absent']; // Allowable values of trial.response this.trials = []; // Array of simulated trials // Private this.muN = NaN; // Mean of noise distribution this.muS = NaN; // Mean of signal distribution this.l = NaN; // lambda (threshold location) this.hS = NaN; // Height of signal distribution this.binRange = [-3.0, 3.0]; // Range of histogram this.h = 0; // Hits this.m = 0; // Misses this.fa = 0; // False alarms this.cr = 0; // Correct rejections this.firstUpdate = true; // Are we waiting for the first update? this.drag = false; // Are we currently dragging? this.alignState(); } reset() { this.trials = []; this.h = 0; this.m = 0; this.fa = 0; this.cr = 0; } trial(trialNumber, signal, duration, wait, iti) { const trial = {}; trial.new = true; trial.paused = false; trial.trial = trialNumber; trial.signal = signal; trial.duration = duration; trial.wait = wait; trial.iti = iti; trial.evidence = jStat.normal.sample(0, 1); this.alignTrial(trial); this.trials.push(trial); this.requestUpdate(); } alignTrial(trial) { if (trial.signal === 'present') { trial.trueEvidence = trial.evidence * this.s + this.muS; trial.response = (trial.trueEvidence > this.l) ? 'present' : 'absent'; trial.outcome = (trial.response === 'present') ? 'h' : 'm'; } else { // trial.signal == 'absent' trial.trueEvidence = trial.evidence + this.muN; trial.response = (trial.trueEvidence > this.l) ? 'present' : 'absent'; trial.outcome = (trial.response === 'present') ? 'fa' : 'cr'; } if (!trial.new) this[trial.outcome] += 1; return trial; } alignState() { this.far = SDTMath.dC2Far(this.d, this.c, this.s); this.hr = SDTMath.dC2Hr(this.d, this.c, this.s); this.muN = SDTMath.d2MuN(this.d, this.s); this.muS = SDTMath.d2MuS(this.d, this.s); this.l = SDTMath.c2L(this.c, this.s); this.hS = SDTMath.s2H(this.s); this.h = 0; this.m = 0; this.fa = 0; this.cr = 0; for (let i = 0; i < this.trials.length; i += 1) { this.alignTrial(this.trials[i]); } } static get styles() { return [ super.styles, css` :host { display: inline-block; width: 27rem; height: 15rem; } .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; } .tick { font-size: 0.75rem; } .axis-x path, .axis-x line, .axis-y path, .axis-y line, .axis-y2 path, .axis-y2 line { stroke: var(---color-element-border); } .noise.interactive, .signal.interactive, .threshold.interactive { cursor: ew-resize; filter: url("#shadow-2"); outline: none; } .signal.unequal { cursor: ns-resize; filter: url("#shadow-2"); outline: none; } .signal.interactive.unequal { cursor: move; } .noise.interactive:hover, .signal.interactive:hover, .signal.unequal:hover, .threshold.interactive:hover { filter: url("#shadow-4"); /* HACK: This gets Safari to correctly apply the filter! */ transform: translateX(0); } .noise.interactive:active, .signal.interactive:active, .signal.unequal:active, .threshold.interactive:active { filter: url("#shadow-8"); /* HACK: This gets Safari to correctly apply the filter! */ transform: translateY(0); } :host(.keyboard) .noise.interactive:focus, :host(.keyboard) .signal.interactive:focus, :host(.keyboard) .signal.unequal:focus, :host(.keyboard) .threshold.interactive:focus { filter: url("#shadow-8"); /* HACK: This gets Safari to correctly apply the filter! */ transform: translateZ(0); } .underlayer .background { fill: var(---color-element-background); stroke: none; } .overlayer .background { fill: none; stroke: var(---color-element-border); stroke-width: 1; shape-rendering: crispEdges; } .title-x, .title-y, .title-y2 { font-weight: 600; fill: currentColor; } .curve-cr, .curve-fa, .curve-m, .curve-h { fill-opacity: 0.5; stroke: none; transition: fill var(---transition-duration) ease; } .curve-cr { fill: var(---color-cr); } .curve-fa { fill: var(---color-fa); } .curve-m { fill: var(---color-m); } .curve-h { fill: var(---color-h); } :host([color="accuracy"]) .curve-h, :host([color="accuracy"]) .curve-cr { fill: var(---color-correct); } :host([color="accuracy"]) .curve-m, :host([color="accuracy"]) .curve-fa { fill: var(---color-error); } :host([color="stimulus"]) .curve-cr, :host([color="stimulus"]) .curve-fa { fill: var(---color-far); } :host([color="stimulus"]) .curve-m, :host([color="stimulus"]) .curve-h { fill: var(---color-hr); } :host([color="response"]) .curve-cr, :host([color="response"]) .curve-m { fill: var(---color-absent); } :host([color="response"]) .curve-fa, :host([color="response"]) .curve-h { fill: var(---color-present); } :host([color="none"]) .curve-cr, :host([color="none"]) .curve-fa, :host([color="none"]) .curve-m, :host([color="none"]) .curve-h { fill: var(---color-element-enabled); } .curve-noise, .curve-signal { fill: none; stroke: var(---color-element-emphasis); stroke-width: 2; } .measure-d, .measure-c, .measure-s { pointer-events: none; } .threshold .line { stroke: var(---color-element-emphasis); stroke-width: 2; } .threshold .handle { fill: var(---color-element-emphasis); r: 6px; } .measure-d .line, .measure-d .cap-left, .measure-d .cap-right { stroke: var(---color-d); stroke-width: 2; shape-rendering: crispEdges; } .measure-d .label { font-size: 0.75rem; text-anchor: start; fill: currentColor; } .measure-c .line, .measure-c .cap-zero { stroke: var(---color-c); stroke-width: 2; shape-rendering: crispEdges; } .measure-c .label { font-size: 0.75rem; fill: currentColor; } .measure-s .line, .measure-s .cap-left, .measure-s .cap-right { stroke: var(---color-s); stroke-width: 2; shape-rendering: crispEdges; } .measure-s .label { font-size: 0.75rem; text-anchor: middle; fill: currentColor; } /* Make a larger target for touch users */ .interactive .touch { stroke: #000000; stroke-opacity: 0; } @media (pointer: coarse) { .interactive .touch { stroke-linecap: round; stroke-width: 12; } } `, ]; } render() { /* eslint-disable-line class-methods-use-this */ return html` ${DetectableElement.svgFilters} `; } sendEvent() { this.dispatchEvent(new CustomEvent('sdt-model-change', { detail: { d: this.d, c: this.c, s: this.s, far: this.far, hr: this.hr, h: this.h, m: this.m, fa: this.fa, cr: this.cr, }, bubbles: true, })); } willUpdate() { this.alignState(); } update(changedProperties) { super.update(changedProperties); // Bail out if we can't get the width/height if (Number.isNaN(this.width) || Number.isNaN(this.height) || Number.isNaN(this.rem)) { return; } const hostWidth = this.width; const hostHeight = this.height; const hostAspectRatio = hostWidth / hostHeight; const elementAspectRatio = 1.8; let elementWidth; let elementHeight; if (hostAspectRatio > elementAspectRatio) { elementHeight = hostHeight; elementWidth = elementHeight * elementAspectRatio; } else { elementWidth = hostWidth; elementHeight = elementWidth / elementAspectRatio; } const margin = { top: 2 * this.rem, bottom: 3 * this.rem, left: 3 * this.rem, right: ((this.histogram && this.distributions) ? 3 : 0.75) * this.rem, }; const height = elementHeight - (margin.top + margin.bottom); const width = elementWidth - (margin.left + margin.right); const transitionDuration = parseInt(this.getComputedStyleValue('---transition-duration'), 10); // X Scale const xScale = d3.scaleLinear() .domain([-3, 3]) // Evidence // FIX - no hardcoding .range([0, width]); // Y Scale const yScale = d3.scaleLinear() .domain([0.5, 0]) // Probability // FIX - no hardcoding .range([0, height]); // 2nd Y Scale const strokeWidth = 3; // FIX - no hardcoding const binWidth = xScale(this.binWidth) - xScale(0); const y2Scale = d3.scaleLinear() .domain([height / binWidth, 0]) // Number of Stimuli .range([0, height]); // Threshold Drag behavior const dragThreshold = d3.drag() .subject(() => { return {x: xScale(this.l), y: 0}; }) .on('start', (event) => { const element = event.currentTarget; d3.select(element).classed('dragging', true); }) .on('drag', (event) => { this.drag = true; const l = xScale.invert(event.x); const c = SDTMath.l2C(l, this.s); this.c = (c < SDTMath.c.MIN) ? SDTMath.c.MIN : (c > SDTMath.c.MAX) ? SDTMath.c.MAX : c; this.alignState(); this.sendEvent(); }) .on('end', (event) => { const element = event.currentTarget; d3.select(element).classed('dragging', false); }); // Noise Curve Drag behavior const dragNoise = d3.drag() .subject(() => { return {x: xScale(this.muN), y: 0}; }) .on('start', (event) => { const element = event.currentTarget; d3.select(element).classed('dragging', true); }) .on('drag', (event) => { this.drag = true; const muN = xScale.invert(event.x); const d = SDTMath.muN2D(muN, this.s); this.d = (d < SDTMath.d.MIN) ? SDTMath.d.MIN : (d > SDTMath.d.MAX) ? SDTMath.d.MAX : d; this.alignState(); this.sendEvent(); }) .on('end', (event) => { const element = event.currentTarget; d3.select(element).classed('dragging', false); }); // Signal+Noise Curve Drag behavior const dragSignal = d3.drag() .subject(() => { return {x: xScale(this.muS), y: yScale(this.hS)}; }) .on('start', (event, datum) => { const element = event.currentTarget; d3.select(element).classed('dragging', true); datum.startX = event.x; datum.startY = event.y; datum.startHS = this.hS; datum.startMuS = this.muS; }) .on('drag', (event, datum) => { this.drag = true; let muS = this.muS; /* eslint-disable-line prefer-destructuring */ if (this.interactive) { muS = xScale.invert(event.x); } let hS = this.hS; /* eslint-disable-line prefer-destructuring */ if (this.unequal) { hS = yScale.invert(event.y); } if (this.interactive && this.unequal) { // Use shift key as modifier for single dimension if (event.sourceEvent.shiftKey) { if (Math.abs(event.x - datum.startX) > Math.abs(event.y - datum.startY)) { hS = datum.startHS; } else { muS = datum.startMuS; } } } if (this.unequal) { const s = SDTMath.h2S(hS); this.s = (s < SDTMath.s.MIN) ? SDTMath.s.MIN : (s > SDTMath.s.MAX) ? SDTMath.s.MAX : s; const c = SDTMath.l2C(this.l, this.s); this.c = (c < SDTMath.c.MIN) ? SDTMath.c.MIN : (c > SDTMath.c.MAX) ? SDTMath.c.MAX : c; } const d = SDTMath.muS2D(muS, this.s); this.d = (d < SDTMath.d.MIN) ? SDTMath.d.MIN : (d > SDTMath.d.MAX) ? SDTMath.d.MAX : d; this.alignState(); this.sendEvent(); }) .on('end', (event) => { const element = event.currentTarget; d3.select(element).classed('dragging', false); }); // Line for Evidence/Probability Space const line = d3.line() .x((datum) => { return xScale(datum.e); }) .y((datum) => { return yScale(datum.p); }); // 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); // MERGE const svgMerge = svgEnter.merge(svgUpdate) .attr('viewBox', `0 0 ${elementWidth} ${elementHeight}`); // Plot // ENTER const plotEnter = svgEnter.append('g') .classed('plot', true); // MERGE const plotMerge = svgMerge.select('.plot') .attr('transform', `translate(${margin.left}, ${margin.top})`); // 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 Axis // ENTER underlayerEnter.append('g') .classed('axis-x', true); // MERGE const axisXMerge = underlayerMerge.select('.axis-x') .attr('transform', `translate(0, ${height})`) .call(d3.axisBottom(xScale)) .attr('font-size', null) .attr('font-family', null); axisXMerge.selectAll('line, path') .attr('stroke', null); // X Axis Title // ENTER underlayerEnter.append('text') .classed('title-x', true) .attr('text-anchor', 'middle') .text('Evidence'); // MERGE underlayerMerge.select('.title-x') .attr('transform', `translate(${width / 2}, ${height + (2.25 * this.rem)})`); // Y Axis // DATA-JOIN const axisYUpdate = underlayerMerge.selectAll('.axis-y') .data(this.distributions ? [{}] : []); // ENTER const axisYEnter = axisYUpdate.enter().append('g') .classed('axis-y', true); // MERGE const axisYMerge = axisYEnter.merge(axisYUpdate) .call(d3.axisLeft(yScale).ticks(5)) .attr('font-size', null) .attr('font-family', null); axisYMerge.selectAll('line, path') .attr('stroke', null); // EXIT axisYUpdate.exit().remove(); // Y Axis Title // DATA-JOIN const titleYUpdate = underlayerMerge.selectAll('.title-y') .data(this.distributions ? [{}] : []); // ENTER const titleYEnter = titleYUpdate.enter().append('text') .classed('title-y', true) .attr('text-anchor', 'middle') .text('Probability'); // MERGE titleYEnter.merge(titleYUpdate) .attr('transform', `translate(${-2 * this.rem}, ${height / 2})rotate(-90)`); // EXIT titleYUpdate.exit().remove(); // 2nd Y Axis // DATA-JOIN const axisY2Update = underlayerMerge.selectAll('.axis-y2') .data(this.histogram ? [{}] : []); // ENTER const axisY2Enter = axisY2Update.enter().append('g') .classed('axis-y2', true); // MERGE const axisY2Merge = axisY2Enter.merge(axisY2Update) .attr('transform', this.distributions ? `translate(${width}, 0)` : '') .call(this.distributions ? d3.axisRight(y2Scale).ticks(10) : d3.axisLeft(y2Scale).ticks(10)) .attr('font-size', null) .attr('font-family', null); axisY2Merge.selectAll('line, path') .attr('stroke', null); // EXIT axisY2Update.exit().remove(); // 2nd Y Axis Title // DATA-JOIN const titleY2Update = underlayerMerge.selectAll('.title-y2') .data(this.histogram ? [{}] : []); // ENTER const titleY2Enter = titleY2Update.enter().append('text') .classed('title-y2', true) .attr('text-anchor', 'middle') .text('Count'); // MERGE titleY2Enter.merge(titleY2Update) .attr('transform', this.distributions ? `translate(${width + (1.5 * this.rem)}, ${height / 2})rotate(90)` : `translate(${-1.5 * this.rem}, ${height / 2})rotate(-90)`); // EXIT titleY2Update.exit().remove(); // Plot Content plotEnter.append('g') .classed('content', true); // MERGE const contentMerge = plotMerge.select('.content'); // Noise & Signal + Noise Distributions // DATA-JOIN const signalNoiseUpdate = contentMerge.selectAll('.signal-noise') .data(this.distributions ? [{}] : []); // ENTER const signalNoiseEnter = signalNoiseUpdate.enter().append('g') .classed('signal-noise', true); // MERGE const signalNoiseMerge = signalNoiseEnter.merge(signalNoiseUpdate); // EXIT signalNoiseUpdate.exit().remove(); // Noise Distribution // ENTER const noiseEnter = signalNoiseEnter.append('g') .classed('noise', true); // MERGE const noiseMerge = signalNoiseMerge.selectAll('.noise') .attr('tabindex', this.interactive ? 0 : null) .classed('interactive', this.interactive) .on('keydown', this.interactive ? (event) => { if (['ArrowRight', 'ArrowLeft'].includes(event.key)) { let muN = this.muN; /* eslint-disable-line prefer-destructuring */ switch (event.key) { case 'ArrowRight': muN += event.shiftKey ? 0.01 : 0.1; break; case 'ArrowLeft': muN -= event.shiftKey ? 0.01 : 0.1; break; default: } const d = SDTMath.muN2D(muN, this.s); this.d = (d < SDTMath.d.MIN) ? SDTMath.d.MIN : (d > SDTMath.d.MAX) ? SDTMath.d.MAX : d; this.alignState(); this.sendEvent(); event.preventDefault(); } } : null); if ( this.firstUpdate || changedProperties.has('interactive') ) { if (this.interactive) { noiseMerge.call(dragNoise); } else { noiseMerge.on('.drag', null); } } // CR Curve // ENTER noiseEnter.append('path') .classed('curve-cr', true); // MERGE noiseMerge.select('.curve-cr').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('d', (datum, index, elements) => { const element = elements[index]; const interpolateD = d3.interpolate( (element.d !== undefined) ? element.d : this.d, this.d, ); const interpolateC = d3.interpolate( (element.c !== undefined) ? element.c : this.c, this.c, ); const interpolateS = d3.interpolate( (element.s !== undefined) ? element.s : this.s, this.s, ); return (time) => { element.d = interpolateD(time); element.c = interpolateC(time); element.s = interpolateS(time); const correctRejections = d3.range( xScale.domain()[0], SDTMath.c2L(element.c, element.s), 0.05, ).map((e) => { return { e: e, p: jStat.normal.pdf(e, SDTMath.d2MuN(element.d, element.s), 1), }; }); correctRejections.push({ e: SDTMath.c2L(element.c, element.s), p: jStat.normal.pdf( SDTMath.c2L(element.c, element.s), SDTMath.d2MuN(element.d, element.s), 1, ), }); correctRejections.push({ e: SDTMath.c2L(element.c, element.s), p: 0, }); correctRejections.push({ e: xScale.domain()[0], p: 0, }); return line(correctRejections); }; }); // FA Curve // ENTER noiseEnter.append('path') .classed('curve-fa', true); // MERGE noiseMerge.select('.curve-fa').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('d', (datum, index, elements) => { const element = elements[index]; const interpolateD = d3.interpolate( (element.d !== undefined) ? element.d : this.d, this.d, ); const interpolateC = d3.interpolate( (element.c !== undefined) ? element.c : this.c, this.c, ); const interpolateS = d3.interpolate( (element.s !== undefined) ? element.s : this.s, this.s, ); return (time) => { element.d = interpolateD(time); element.c = interpolateC(time); element.s = interpolateS(time); const falseAlarms = d3.range( SDTMath.c2L(element.c, element.s), xScale.domain()[1], 0.05, ).map((e) => { return { e: e, p: jStat.normal.pdf(e, SDTMath.d2MuN(element.d, element.s), 1), }; }); falseAlarms.push({ e: xScale.domain()[1], p: jStat.normal.pdf(xScale.domain()[1], SDTMath.d2MuN(element.d, element.s), 1), }); falseAlarms.push({ e: xScale.domain()[1], p: 0, }); falseAlarms.push({ e: SDTMath.c2L(element.c, element.s), p: 0, }); return line(falseAlarms); }; }); // Noise Curve // ENTER noiseEnter.append('path') .classed('curve-noise', true); // MERGE noiseMerge.select('.curve-noise').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('d', (datum, index, elements) => { const element = elements[index]; const interpolateD = d3.interpolate( (element.d !== undefined) ? element.d : this.d, this.d, ); const interpolateS = d3.interpolate( (element.s !== undefined) ? element.s : this.s, this.s, ); return (time) => { element.d = interpolateD(time); element.s = interpolateS(time); const noise = d3.range( xScale.domain()[0], xScale.domain()[1], 0.05, ).map((e) => { return { e: e, p: jStat.normal.pdf(e, SDTMath.d2MuN(element.d, element.s), 1), }; }); noise.push({ e: xScale.domain()[1], p: jStat.normal.pdf(xScale.domain()[1], SDTMath.d2MuN(element.d, element.s), 1), }); return line(noise); }; }); // Signal + Noise Distribution // ENTER const signalEnter = signalNoiseEnter.append('g') .classed('signal', true); // MERGE const signalMerge = signalNoiseMerge.selectAll('.signal') .attr('tabindex', (this.interactive || this.unequal) ? 0 : null) .classed('interactive', this.interactive) .classed('unequal', this.unequal) .on('keydown.sensitivity', this.interactive ? (event) => { if (['ArrowRight', 'ArrowLeft'].includes(event.key)) { let muS = this.muS; /* eslint-disable-line prefer-destructuring */ switch (event.key) { case 'ArrowRight': muS += event.shiftKey ? 0.01 : 0.1; break; case 'ArrowLeft': muS -= event.shiftKey ? 0.01 : 0.1; break; default: } const d = SDTMath.muS2D(muS, this.s); this.d = (d < SDTMath.d.MIN) ? SDTMath.d.MIN : (d > SDTMath.d.MAX) ? SDTMath.d.MAX : d; this.alignState(); this.sendEvent(); event.preventDefault(); } } : null) .on('keydown.variance', this.unequal ? (event) => { if (['ArrowUp', 'ArrowDown'].includes(event.key)) { let hS = this.hS; /* eslint-disable-line prefer-destructuring */ switch (event.key) { case 'ArrowUp': hS += event.shiftKey ? 0.002 : 0.02; break; case 'ArrowDown': hS -= event.shiftKey ? 0.002 : 0.02; break; default: } hS = (hS < 0) ? 0 : hS; const s = SDTMath.h2S(hS); this.s = (s < SDTMath.s.MIN) ? SDTMath.s.MIN : (s > SDTMath.s.MAX) ? SDTMath.s.MAX : s; this.d = SDTMath.muN2D(this.muN, this.s); this.c = SDTMath.l2C(this.l, this.s); this.alignState(); this.sendEvent(); event.preventDefault(); } } : null); if ( this.firstUpdate || changedProperties.has('interactive') || changedProperties.has('unequal') ) { if (this.interactive || this.unequal) { signalMerge.call(dragSignal); } else { signalMerge.on('.drag', null); } } // M Curve // ENTER signalEnter.append('path') .classed('curve-m', true); // MERGE signalMerge.select('.curve-m').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('d', (datum, index, elements) => { const element = elements[index]; const interpolateD = d3.interpolate( (element.d !== undefined) ? element.d : this.d, this.d, ); const interpolateC = d3.interpolate( (element.c !== undefined) ? element.c : this.c, this.c, ); const interpolateS = d3.interpolate( (element.s !== undefined) ? element.s : this.s, this.s, ); return (time) => { element.d = interpolateD(time); element.c = interpolateC(time); element.s = interpolateS(time); const misses = d3.range( xScale.domain()[0], SDTMath.c2L(element.c, element.s), 0.05, ).map((e) => { return { e: e, p: jStat.normal.pdf(e, SDTMath.d2MuS(element.d, element.s), element.s), }; }); misses.push({ e: SDTMath.c2L(element.c, element.s), p: jStat.normal.pdf( SDTMath.c2L(element.c, element.s), SDTMath.d2MuS(element.d, element.s), element.s, ), }); misses.push({ e: SDTMath.c2L(element.c, element.s), p: 0, }); misses.push({ e: xScale.domain()[0], p: 0, }); return line(misses); }; }); // H Curve // ENTER signalEnter.append('path') .classed('curve-h', true); // MERGE signalMerge.select('.curve-h').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('d', (datum, index, elements) => { const element = elements[index]; const interpolateD = d3.interpolate( (element.d !== undefined) ? element.d : this.d, this.d, ); const interpolateC = d3.interpolate( (element.c !== undefined) ? element.c : this.c, this.c, ); const interpolateS = d3.interpolate( (element.s !== undefined) ? element.s : this.s, this.s, ); return (time) => { element.d = interpolateD(time); element.c = interpolateC(time); element.s = interpolateS(time); const hits = d3.range( SDTMath.c2L(element.c, element.s), xScale.domain()[1], 0.05, ).map((e) => { return { e: e, p: jStat.normal.pdf(e, SDTMath.d2MuS(element.d, element.s), element.s), }; }); hits.push({ e: xScale.domain()[1], p: jStat.normal.pdf( xScale.domain()[1], SDTMath.d2MuS(element.d, element.s), element.s, ), }); hits.push({ e: xScale.domain()[1], p: 0, }); hits.push({ e: SDTMath.c2L(element.c, element.s), p: 0, }); return line(hits); }; }); // Signal Curve // ENTER signalEnter.append('path') .classed('curve-signal', true); // MERGE signalMerge.select('.curve-signal').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('d', (datum, index, elements) => { const element = elements[index]; const interpolateD = d3.interpolate( (element.d !== undefined) ? element.d : this.d, this.d, ); const interpolateS = d3.interpolate( (element.s !== undefined) ? element.s : this.s, this.s, ); return (time) => { element.d = interpolateD(time); element.s = interpolateS(time); const signal = d3.range( xScale.domain()[0], xScale.domain()[1], 0.05, ).map((e) => { return { e: e, p: jStat.normal.pdf(e, SDTMath.d2MuS(element.d, element.s), element.s), }; }); signal.push({ e: xScale.domain()[1], p: jStat.normal.pdf( xScale.domain()[1], SDTMath.d2MuS(element.d, element.s), element.s, ), }); return line(signal); }; }); // d' Measure // DATA-JOIN const dUpdate = contentMerge.selectAll('.measure-d') .data(this.sensitivity ? [{}] : []); // ENTER const dEnter = dUpdate.enter().append('g') .classed('measure-d', true); dEnter.append('line') .classed('line', true); dEnter.append('line') .classed('cap-left', true); dEnter.append('line') .classed('cap-right', true); const dLabel = dEnter.append('text') .classed('label', true); dLabel.append('tspan') .classed('d math-var', true) .text('d′'); dLabel.append('tspan') .classed('equals', true) .text(' = '); dLabel.append('tspan') .classed('value', true); // MERGE const dMerge = dEnter.merge(dUpdate); dMerge.select('.line').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x1', xScale(this.muN)) .attr('y1', yScale(0.43)) // FIX - no hardcoding .attr('x2', xScale(this.muS)) .attr('y2', yScale(0.43)); // FIX - no hardcoding dMerge.select('.cap-left').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x1', xScale(this.muN)) .attr('y1', yScale(0.43) + 5) // FIX - no hardcoding .attr('x2', xScale(this.muN)) .attr('y2', yScale(0.43) - 5); // FIX - no hardcoding dMerge.select('.cap-right').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x1', xScale(this.muS)) .attr('y1', yScale(0.43) + 5) // FIX - no hardcoding .attr('x2', xScale(this.muS)) .attr('y2', yScale(0.43) - 5); // FIX - no hardcoding const dLabelTransition = dMerge.select('.label').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x', xScale((this.muN > this.muS) ? this.muN : this.muS) + 5) .attr('y', yScale(0.43) + 3); // FIX - no hardcoding dLabelTransition.select('.value') .tween('text', (datum, index, elements) => { const element = elements[index]; const interpolateD = d3.interpolate( (element.d !== undefined) ? element.d : this.d, this.d, ); return (time) => { element.d = interpolateD(time); d3.select(element).text(d3.format('.3')(element.d)); }; }); // EXIT dUpdate.exit().remove(); // c Measure // DATA-JOIN const cUpdate = contentMerge.selectAll('.measure-c') .data(this.bias ? [{}] : []); // ENTER const cEnter = cUpdate.enter().append('g') .classed('measure-c', true); cEnter.append('line') .classed('line', true); cEnter.append('line') .classed('cap-zero', true); const cLabel = cEnter.append('text') .classed('label', true); cLabel.append('tspan') .classed('c math-var', true) .text('c'); cLabel.append('tspan') .classed('equals', true) .text(' = '); cLabel.append('tspan') .classed('value', true); // MERGE const cMerge = cEnter.merge(cUpdate); cMerge.select('.line').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x1', xScale(this.l)) .attr('y1', yScale(0.47)) // FIX - no hardcoding .attr('x2', xScale(0)) .attr('y2', yScale(0.47)); // FIX - no hardcoding cMerge.select('.cap-zero').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x1', xScale(0)) .attr('y1', yScale(0.47) + 5) // FIX - no hardcoding .attr('x2', xScale(0)) .attr('y2', yScale(0.47) - 5); // FIX - no hardcoding const cLabelTransition = cMerge.select('.label').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x', xScale(0) + ((this.l < 0) ? 5 : -5)) .attr('y', yScale(0.47) + 3) // FIX - no hardcoding .attr('text-anchor', (this.c < 0) ? 'start' : 'end'); cLabelTransition.select('.value') .tween('text', (datum, index, elements) => { const element = elements[index]; const interpolateC = d3.interpolate( (element.c !== undefined) ? element.c : this.c, this.c, ); return (time) => { element.c = interpolateC(time); d3.select(element).text(d3.format('.3')(element.c)); }; }); // EXIT cUpdate.exit().remove(); // s Measure // DATA-JOIN const sUpdate = contentMerge.selectAll('.measure-s') .data(this.variance ? [{}] : []); // ENTER const sEnter = sUpdate.enter().append('g') .classed('measure-s', true); sEnter.append('line') .classed('line', true); sEnter.append('line') .classed('cap-left', true); sEnter.append('line') .classed('cap-right', true); const sLabel = sEnter.append('text') .classed('label', true); sLabel.append('tspan') .classed('s math-var', true) .text('σ'); sLabel.append('tspan') .classed('equals', true) .text(' = '); sLabel.append('tspan') .classed('value', true); // MERGE const sMerge = sEnter.merge(sUpdate); sMerge.select('.line').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x1', xScale(this.muS - this.s)) .attr('y1', yScale(jStat.normal.pdf(this.s, 0, this.s)) + (10 / this.s)) // FIX - no hardcoding .attr('x2', xScale(this.muS + this.s)) .attr('y2', yScale(jStat.normal.pdf(this.s, 0, this.s)) + (10 / this.s)); // FIX - no hardcoding sMerge.select('.cap-left').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x1', xScale(this.muS - this.s)) .attr('y1', yScale(jStat.normal.pdf(this.s, 0, this.s)) + (10 / this.s) + 5) // FIX - no hardcoding .attr('x2', xScale(this.muS - this.s)) .attr('y2', yScale(jStat.normal.pdf(this.s, 0, this.s)) + (10 / this.s) - 5); // FIX - no hardcoding sMerge.select('.cap-right').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x1', xScale(this.muS + this.s)) .attr('y1', yScale(jStat.normal.pdf(this.s, 0, this.s)) + (10 / this.s) + 5) // FIX - no hardcoding .attr('x2', xScale(this.muS + this.s)) .attr('y2', yScale(jStat.normal.pdf(this.s, 0, this.s)) + (10 / this.s) - 5); // FIX - no hardcoding const sLabelTransition = sMerge.select('.label').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x', xScale(this.muS)) .attr('y', yScale(jStat.normal.pdf(this.s, 0, this.s)) + (10 / this.s) - 3); // FIX - no hardcoding sLabelTransition.select('.value') .tween('text', (datum, index, elements) => { const element = elements[index]; const interpolateS = d3.interpolate( (element.s !== undefined) ? element.s : this.s, this.s, ); return (time) => { element.s = interpolateS(time); d3.select(element).text(d3.format('.3')(element.s)); }; }); // EXIT sUpdate.exit().remove(); // Threshold Line // DATA-JOIN const thresholdUpdate = contentMerge.selectAll('.threshold') .data(this.threshold ? [{}] : []); // ENTER const thresholdEnter = thresholdUpdate.enter().append('g') .classed('threshold', true); thresholdEnter.append('line') .classed('line', true); thresholdEnter.append('line') .classed('line touch', true); thresholdEnter.append('circle') .classed('handle touch', true); // MERGE const thresholdMerge = thresholdEnter.merge(thresholdUpdate) .attr('tabindex', this.interactive ? 0 : null) .classed('interactive', this.interactive); if ( this.firstUpdate || changedProperties.has('interactive') ) { if (this.interactive) { thresholdMerge .call(dragThreshold) .on('keydown', (event) => { if (['ArrowRight', 'ArrowLeft'].includes(event.key)) { let l = this.l; /* eslint-disable-line prefer-destructuring */ switch (event.key) { case 'ArrowRight': l += event.shiftKey ? 0.01 : 0.1; break; case 'ArrowLeft': l -= event.shiftKey ? 0.01 : 0.1; break; default: } const c = SDTMath.l2C(l, this.s); this.c = (c < SDTMath.c.MIN) ? SDTMath.c.MIN : (c > SDTMath.c.MAX) ? SDTMath.c.MAX : c; this.alignState(); this.sendEvent(); event.preventDefault(); } }); } else { thresholdMerge .on('drag', null) .on('keydown', null); } } thresholdMerge.select('.line').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x1', xScale(this.l)) .attr('y1', yScale(0)) .attr('x2', xScale(this.l)) .attr('y2', yScale(0.54)); thresholdMerge.select('.line.touch').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('x1', xScale(this.l)) .attr('y1', yScale(0)) .attr('x2', xScale(this.l)) .attr('y2', yScale(0.54)); thresholdMerge.select('.handle').transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('cx', xScale(this.l)) .attr('cy', yScale(0.54)); // EXIT thresholdUpdate.exit().remove(); // Histogram // DATA-JOIN const histogramUpdate = contentMerge.selectAll('.histogram') .data(this.histogram ? [{}] : []); // ENTER const histogramEnter = histogramUpdate.enter().append('g') .classed('histogram', true); // MERGE const histogramMerge = histogramEnter.merge(histogramUpdate); // EXIT histogramUpdate.exit().remove(); // Trials if (this.histogram) { const histogram = d3.histogram() .value((datum) => { return datum.trueEvidence; }) .domain(xScale.domain()) .thresholds(d3.range(this.binRange[0], this.binRange[1], this.binWidth)); const hist = histogram(this.trials); let binCountLeft = -1; let binCountRight = -1; for (let i = 0; i < hist.length; i += 1) { for (let j = 0; j < hist[i].length; j += 1) { hist[i][j].binValue = hist[i].x0; hist[i][j].binCount = j; if (i === 0) binCountLeft = j; if (i === hist.length - 1) binCountRight = j; } } // Put out-of-range values in extreme left/right bins for (let i = 0; i < this.trials.length; i += 1) { if (this.trials[i].trueEvidence < this.binRange[0]) { binCountLeft += 1; this.trials[i].binCount = binCountLeft; this.trials[i].binValue = hist[0].x0; } if (this.trials[i].trueEvidence > this.binRange[1]) { binCountRight += 1; this.trials[i].binCount = binCountRight; this.trials[i].binValue = hist[hist.length - 1].x0; } } // DATA-JOIN const trialUpdate = histogramMerge.selectAll('.trial') .data(this.trials, (datum) => { return datum.trial; }); // ENTER const trialEnter = trialUpdate.enter().append('rect') .attr('stroke-width', strokeWidth) .attr('data-new-trial-ease-time', 0) // use 'data-trial-enter' .attr('stroke', this.getComputedStyleValue('---color-acc')) .attr('fill', this.getComputedStyleValue('---color-acc-light')); // MERGE const trialMerge = trialEnter.merge(trialUpdate) .attr('class', (datum) => { return `trial ${datum.outcome}`; }) .attr('width', binWidth - strokeWidth) .attr('height', binWidth - strokeWidth); // MERGE - Active New Trials const trialMergeNewActive = trialMerge.filter((datum) => { return (datum.new && !datum.paused); }); if (!trialMergeNewActive.empty()) { const easeTime = trialMergeNewActive.attr('data-new-trial-ease-time'); const scaleIn = (time) => { return d3.scaleLinear().domain([0, 1]).range([easeTime, 1])(time); }; const scaleOutGenerator = (easeFunction) => { return (time) => { return d3.scaleLinear() .domain([easeFunction(easeTime), 1]).range([0, 1])(easeFunction(time)); }; }; trialMergeNewActive.transition('new') .duration((datum) => { return Math.floor((datum.duration * 0.75 + datum.wait * 0.25) * (1 - easeTime)); }) .ease(scaleIn) .attr('data-new-trial-ease-time', 1) .attrTween('stroke', (datum, index, elements) => { const element = elements[index]; const interpolator = d3.interpolateRgb( element.getAttribute('stroke'), (this.color === 'stimulus') ? (datum.signal === 'present') ? this.getComputedStyleValue('---color-hr') : this.getComputedStyleValue('---color-far') : (this.color === 'response') ? this.getComputedStyleValue(`---color-${datum.response}`) : (this.color === 'outcome') || (this.color === 'all') ? this.getComputedStyleValue(`---color-${datum.outcome}`) : this.getComputedStyleValue('---color-acc'), ); return (time) => { return interpolator(scaleOutGenerator(d3.easeCubicIn)(time)); }; }) .attrTween('fill', (datum, index, elements) => { const element = elements[index]; const interpolator = d3.interpolateRgb( element.getAttribute('fill'), (this.color === 'stimulus') ? (datum.signal === 'present') ? this.getComputedStyleValue('---color-hr-light') : this.getComputedStyleValue('---color-far-light') : (this.color === 'response') ? this.getComputedStyleValue(`---color-${datum.response}-light`) : (this.color === 'outcome') || (this.color === 'all') ? this.getComputedStyleValue(`---color-${datum.outcome}-light`) : this.getComputedStyleValue('---color-acc-light'), ); return (time) => { return interpolator(scaleOutGenerator(d3.easeCubicIn)(time)); }; }) .attrTween('x', (datum, index, elements) => { const element = elements[index]; const interpolator = d3.interpolate( element.getAttribute('x'), xScale(datum.binValue) + (strokeWidth / 2), ); return (time) => { return interpolator(scaleOutGenerator(d3.easeCubicOut)(time)); }; }) .attrTween('y', (datum, index, elements) => { const element = elements[index]; const interpolator = d3.interpolate( element.getAttribute('y'), yScale(0) + (strokeWidth / 2) - ((datum.binCount + 1) * binWidth), ); return (time) => { return interpolator(scaleOutGenerator(d3.easeCubicIn)(time)); }; }) .on('end', (datum, index, elements) => { const element = elements[index];