UNPKG

@decidables/detectable-elements

Version:

detectable-elements: Web Components for visualizing Signal Detection Theory

623 lines (553 loc) 17 kB
import {html, css} from 'lit'; import '@decidables/decidables-elements/spinner'; import DecidablesConverterSet from '@decidables/decidables-elements/converter-set'; import SDTMath from '@decidables/detectable-math'; import DetectableElement from '../detectable-element'; /* DetectableTable element <detectable-table> Attributes: Hit; Miss; FalseAlarm; CorrectRejection; */ export default class DetectableTable extends DetectableElement { static get properties() { return { numeric: { attribute: 'numeric', type: Boolean, reflect: true, }, summary: { attribute: 'summary', converter: DecidablesConverterSet, reflect: true, }, color: { attribute: 'color', type: String, reflect: true, }, h: { attribute: 'hits', type: Number, reflect: true, }, m: { attribute: 'misses', type: Number, reflect: true, }, fa: { attribute: 'false-alarms', type: Number, reflect: true, }, cr: { attribute: 'correct-rejections', type: Number, reflect: true, }, payoff: { attribute: 'payoff', type: Boolean, reflect: true, }, hPayoff: { attribute: 'hit-payoff', type: Number, reflect: true, }, mPayoff: { attribute: 'miss-payoff', type: Number, reflect: true, }, faPayoff: { attribute: 'false-alarm-payoff', type: Number, reflect: true, }, crPayoff: { attribute: 'correct-rejection-payoff', type: Number, reflect: true, }, far: { attribute: false, type: Number, reflect: false, }, hr: { attribute: false, type: Number, reflect: false, }, acc: { attribute: false, type: Number, reflect: false, }, // positive predictive value (https://en.wikipedia.org/wiki/Receiver_operating_characteristic) ppv: { attribute: false, type: Number, reflect: false, }, // false omission rate (https://en.wikipedia.org/wiki/Receiver_operating_characteristic) // Using "fomr" to avoid keyword "for" fomr: { attribute: false, type: Number, reflect: false, }, }; } constructor() { super(); this.numeric = false; this.summaries = ['stimulusRates', 'responseRates', 'accuracy']; this.summary = new Set(); this.colors = ['none', 'accuracy', 'stimulus', 'response', 'outcome', 'all']; this.color = 'all'; this.payoff = false; this.hPayoff = undefined; // Hit payoff this.mPayoff = undefined; // Miss payoff this.crPayoff = undefined; // Correct Rejection payoff this.faPayoff = undefined; // False Alarm payoff this.h = 40; this.m = 60; this.fa = 75; this.cr = 25; this.alignState(); } alignState() { this.hr = SDTMath.hM2Hr(this.h, this.m); this.far = SDTMath.faCr2Far(this.fa, this.cr); this.acc = SDTMath.hMFaCr2Acc(this.h, this.m, this.fa, this.cr); this.ppv = SDTMath.hFa2Ppv(this.h, this.fa); this.fomr = SDTMath.mCr2Fomr(this.m, this.cr); } sendEvent() { this.dispatchEvent(new CustomEvent('detectable-table-change', { detail: { h: this.h, m: this.m, hr: this.hr, fa: this.fa, cr: this.cr, far: this.far, acc: this.acc, ppv: this.ppv, fomr: this.fomr, }, bubbles: true, })); } hInput(e) { this.h = parseInt(e.target.value, 10); this.alignState(); this.sendEvent(); } mInput(e) { this.m = parseInt(e.target.value, 10); this.alignState(); this.sendEvent(); } faInput(e) { this.fa = parseInt(e.target.value, 10); this.alignState(); this.sendEvent(); } crInput(e) { this.cr = parseInt(e.target.value, 10); this.alignState(); this.sendEvent(); } hrInput(e) { const newhr = parseFloat(e.target.value); const present = this.h + this.m; this.h = Math.round(newhr * present); this.m = present - this.h; this.alignState(); this.sendEvent(); } farInput(e) { const newfar = parseFloat(e.target.value); const absent = this.fa + this.cr; this.fa = Math.round(newfar * absent); this.cr = absent - this.fa; this.alignState(); this.sendEvent(); } accInput(e) { const newacc = parseFloat(e.target.value); const present = this.h + this.m; const absent = this.fa + this.cr; const x = (this.hr + this.far - 1) / 2; // Rotate into ACC let newhr = x + newacc; let newfar = 1 + x - newacc; if (newfar > 1) { newfar = 1; newhr = newfar + 2 * newacc - 1; } if (newfar < 0) { newfar = 0; newhr = newfar + 2 * newacc - 1; } if (newhr > 1) { newhr = 1; newfar = newhr - 2 * newacc + 1; } if (newhr < 0) { newhr = 0; newfar = newhr - 2 * newacc + 1; } this.h = Math.round(newhr * present); this.m = present - this.h; this.fa = Math.round(newfar * absent); this.cr = absent - this.fa; this.alignState(); this.sendEvent(); } ppvInput(e) { const newppv = parseFloat(e.target.value); const present = this.h + this.fa; this.h = Math.round(newppv * present); this.fa = present - this.h; this.alignState(); this.sendEvent(); } fomrInput(e) { const newfomr = parseFloat(e.target.value); const present = this.m + this.cr; this.m = Math.round(newfomr * present); this.cr = present - this.m; this.alignState(); this.sendEvent(); } static get styles() { return [ super.styles, css` :host { display: inline-block; } /* Overall element */ table { text-align: center; border-collapse: collapse; border: 0; } /* Headers */ .th-main { padding: 0; font-weight: bold; } .th-sub { padding: 0 0.25rem; font-weight: 600; } .th-left { padding-left: 0; text-align: right; } /* Cells */ .td { width: 10rem; padding: 0.25rem 0.25rem 0.375rem; transition: all var(---transition-duration) ease; } .numeric .td { width: 7rem; } /* Labels */ .payoff { font-weight: 600; line-height: 0.75rem; } /* User interaction <input> */ .td-data decidables-spinner { --decidables-spinner-input-width: 3.5rem; } .td-summary decidables-spinner { --decidables-spinner-input-width: 4.5rem; } /* Color schemes & Table emphasis */ /* (Default) All color scheme */ .h { background: var(---color-h-light); border-top: 2px solid var(---color-element-emphasis); border-left: 2px solid var(---color-element-emphasis); } .m { background: var(---color-m-light); border-top: 2px solid var(---color-element-emphasis); border-right: 2px solid var(---color-element-emphasis); } .fa { background: var(---color-fa-light); border-bottom: 2px solid var(---color-element-emphasis); border-left: 2px solid var(---color-element-emphasis); } .cr { background: var(---color-cr-light); border-right: 2px solid var(---color-element-emphasis); border-bottom: 2px solid var(---color-element-emphasis); } .hr { background: var(---color-hr-light); } .far { background: var(---color-far-light); } .acc { background: var(---color-acc-light); } .ppv { background: var(---color-present-light); } .fomr { background: var(---color-absent-light); } /* Accuracy color scheme */ :host([color="accuracy"]) .h, :host([color="accuracy"]) .cr { background: var(---color-correct-light); } :host([color="accuracy"]) .m, :host([color="accuracy"]) .fa { color: var(---color-text-inverse); background: var(---color-error-light); } :host([color="accuracy"]) .hr, :host([color="accuracy"]) .far, :host([color="accuracy"]) .ppv, :host([color="accuracy"]) .fomr { background: var(---color-element-background); } /* Stimulus color scheme */ :host([color="stimulus"]) .cr, :host([color="stimulus"]) .fa { background: var(---color-far-light); } :host([color="stimulus"]) .m, :host([color="stimulus"]) .h { background: var(---color-hr-light); } :host([color="stimulus"]) .ppv, :host([color="stimulus"]) .fomr, :host([color="stimulus"]) .acc { background: var(---color-element-background); } /* Response color scheme */ :host([color="response"]) .cr, :host([color="response"]) .m { background: var(---color-absent-light); } :host([color="response"]) .fa, :host([color="response"]) .h { background: var(---color-present-light); } :host([color="response"]) .hr, :host([color="response"]) .far, :host([color="response"]) .acc { background: var(---color-element-background); } /* Outcome color scheme */ :host([color="outcome"]) .hr, :host([color="outcome"]) .far, :host([color="outcome"]) .ppv, :host([color="outcome"]) .fomr, :host([color="outcome"]) .acc { background: var(---color-element-background); } /* No color scheme */ :host([color="none"]) .cr, :host([color="none"]) .fa, :host([color="none"]) .m, :host([color="none"]) .h, :host([color="none"]) .hr, :host([color="none"]) .far, :host([color="none"]) .ppv, :host([color="none"]) .fomr, :host([color="none"]) .acc { background: var(---color-element-background); } `, ]; } willUpdate() { this.alignState(); } render() { const payoffFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0, }); const payoffFormat = (number) => { return payoffFormatter.formatToParts(number).map(({type, value}) => { if (type === 'minusSign') { return '−'; } return value; }).reduce((string, part) => { return string + part; }); }; let h; let m; let fa; let cr; let hr; let far; let acc; let ppv; let fomr; if (this.numeric) { h = html` <decidables-spinner ?disabled=${!this.interactive} min="0" .value="${this.h}" @input=${this.hInput.bind(this)}> <span>Hits</span> ${this.payoff ? html`<span class="payoff">${payoffFormat(this.hPayoff)}</span>` : html``} </decidables-spinner> `; m = html` <decidables-spinner ?disabled=${!this.interactive} min="0" .value="${this.m}" @input=${this.mInput.bind(this)}> <span>Misses</span> ${this.payoff ? html`<span class="payoff">${payoffFormat(this.mPayoff)}</span>` : html``} </decidables-spinner> `; fa = html` <decidables-spinner ?disabled=${!this.interactive} min="0" .value="${this.fa}" @input=${this.faInput.bind(this)}> <span>False Alarms</span> ${this.payoff ? html`<span class="payoff">${payoffFormat(this.faPayoff)}</span>` : html``} </decidables-spinner> `; cr = html` <decidables-spinner ?disabled=${!this.interactive} min="0" .value="${this.cr}" @input=${this.crInput.bind(this)}> <span>Correct Rejections</span> ${this.payoff ? html`<span class="payoff">${payoffFormat(this.crPayoff)}</span>` : html``} </decidables-spinner> `; hr = html` <decidables-spinner ?disabled=${!this.interactive} min="0" max="1" step=".001" .value="${+this.hr.toFixed(3)}" @input=${this.hrInput.bind(this)}> <span>Hit Rate</span> </decidables-spinner> `; far = html` <decidables-spinner ?disabled=${!this.interactive} min="0" max="1" step=".001" .value="${+this.far.toFixed(3)}" @input=${this.farInput.bind(this)}> <span>False Alarm Rate</span> </decidables-spinner> `; acc = html` <decidables-spinner ?disabled=${!this.interactive} min="0" max="1" step=".001" .value="${+this.acc.toFixed(3)}" @input=${this.accInput.bind(this)}> <span>Accuracy</span> </decidables-spinner> `; ppv = html` <decidables-spinner ?disabled=${!this.interactive} min="0" max="1" step=".001" .value="${+this.ppv.toFixed(3)}" @input=${this.ppvInput.bind(this)}> <span>Positive Predictive Value</span> </decidables-spinner> `; fomr = html` <decidables-spinner ?disabled=${!this.interactive} min="0" max="1" step=".001" .value="${+this.fomr.toFixed(3)}" @input=${this.fomrInput.bind(this)}> <span>False Omission Rate</span> </decidables-spinner> `; } else { h = html`<span>Hits</span> ${this.payoff ? html`<span class="payoff">${payoffFormat(this.hPayoff)}</span>` : html``}`; m = html`<span>Misses</span> ${this.payoff ? html`<span class="payoff">${payoffFormat(this.mPayoff)}</span>` : html``}`; fa = html`<span>False Alarms</span> ${this.payoff ? html`<span class="payoff">${payoffFormat(this.faPayoff)}</span>` : html``}`; cr = html`<span>Correct Rejections</span> ${this.payoff ? html`<span class="payoff">${payoffFormat(this.crPayoff)}</span>` : html``}`; hr = html`<span>Hit Rate</span>`; far = html`<span>False Alarm Rate</span>`; acc = html`<span>Accuracy</span>`; ppv = html`<span>Positive Predictive Value</span>`; fomr = html`<span>False Omission Rate</span>`; } return html` <table class=${this.numeric ? 'numeric' : ''}> <thead> <tr> <th colspan="2" rowspan="2"></th> <th class="th th-main" colspan="2" scope="col"> Response </th> </tr> <tr> <th class="th th-sub" scope="col"> ‘Present’ </th> <th class="th th-sub" scope="col"> ‘Absent’ </th> </tr> </thead> <tbody> <tr> <th class="th th-main" rowspan="2" scope="row"> Signal </th> <th class="th th-sub th-left" scope="row"> Present </th> <td class="td td-data h"> ${h} </td> <td class="td td-data m"> ${m} </td> ${(this.summary.has('stimulusRates')) ? html` <td class="td td-summary hr"> ${hr} </td>` : html``} </tr> <tr> <th class="th th-sub th-left" scope="row"> Absent </th> <td class="td td-data fa"> ${fa} </td> <td class="td td-data cr"> ${cr} </td> ${(this.summary.has('stimulusRates')) ? html` <td class="td td-summary far"> ${far} </td>` : html``} </tr> ${(this.summary.has('responseRates') || this.summary.has('accuracy')) ? html` <tr> <td colspan="2"></td> ${(this.summary.has('responseRates')) ? html` <td class="td td-summary ppv"> ${ppv} </td> <td class="td td-summary fomr"> ${fomr} </td>` : html` <td colspan="2"></td>`} ${(this.summary.has('accuracy')) ? html` <td class="td td-summary acc" rowspan="2"> ${acc} </td>` : html``} </tr>` : html``} </tbody> </table>`; } } customElements.define('detectable-table', DetectableTable);