UNPKG

@decidables/detectable-elements

Version:

detectable-elements: Web Components for visualizing Signal Detection Theory

1,182 lines (1,085 loc) 37.3 kB
import {html, css} from 'lit'; import * as d3 from 'd3'; import {DecidablesMixinResizeable} from '@decidables/decidables-elements'; import SDTMath from '@decidables/detectable-math'; import DetectableElement from '../detectable-element'; /* ROCSpace element <roc-space> Attributes: FAR; HR; d'; C; zFAR; zHR draggable: yes/no scale: FAR/HR; zFAR/zHR; d'/C grid: FAR/HR; zFAR/zHR; d'/C isos: d'; C; FAR; HR Styles: ?? */ export default class ROCSpace extends DecidablesMixinResizeable(DetectableElement) { static get properties() { return { contour: { attribute: 'contour', type: String, reflect: true, }, point: { attribute: 'point', type: String, reflect: true, }, isoD: { attribute: 'iso-d', type: String, reflect: true, }, isoC: { attribute: 'iso-c', type: String, reflect: true, }, zRoc: { attribute: 'z-roc', type: Boolean, reflect: true, }, far: { attribute: 'far', type: Number, reflect: true, }, hr: { attribute: 'hr', type: Number, reflect: true, }, d: { attribute: false, type: Number, reflect: false, }, c: { attribute: false, type: Number, reflect: false, }, s: { attribute: false, type: Number, reflect: false, }, }; } constructor() { super(); this.firstUpdate = true; this.drag = false; this.sdt = false; this.contours = ['sensitivity', 'bias', 'accuracy']; this.contour = undefined; this.points = ['all', 'first', 'rest', 'none']; this.point = 'all'; this.isoDs = ['all', 'first', 'rest', 'none']; this.isoD = 'first'; this.isoCs = ['all', 'first', 'rest', 'none']; this.isoC = 'first'; this.zRoc = false; this.far = 0.25; this.hr = 0.75; this.s = SDTMath.s.DEFAULT; this.label = ''; this.locations = [ { name: 'default', far: this.far, hr: this.hr, s: this.s, label: '', }, ]; this.pointArray = []; this.isoDArray = []; this.isoCArray = []; this.alignState(); } alignState() { this.locations[0].hr = this.hr; this.locations[0].far = this.far; this.locations[0].s = this.s; this.locations[0].label = this.label; this.d = SDTMath.hrFar2D(this.hr, this.far, this.s); this.c = SDTMath.hrFar2C(this.hr, this.far, this.s); this.pointArray = []; this.isoDArray = []; this.isoCArray = []; this.locations.forEach((item, index) => { item.d = SDTMath.hrFar2D(item.hr, item.far, item.s); item.c = SDTMath.hrFar2C(item.hr, item.far, item.s); if ((index === 0) && (this.point === 'first' || this.point === 'all')) { this.pointArray.push(item); } else if ((index > 0) && (this.point === 'rest' || this.point === 'all')) { this.pointArray.push(item); } if ((index === 0) && (this.isoD === 'first' || this.isoD === 'all')) { this.isoDArray.push(item); } else if ((index > 0) && (this.isoD === 'rest' || this.isoD === 'all')) { this.isoDArray.push(item); } if ((index === 0) && (this.isoC === 'first' || this.isoC === 'all')) { this.isoCArray.push(item); } else if ((index > 0) && (this.isoC === 'rest' || this.isoC === 'all')) { this.isoCArray.push(item); } }); } set(hr, far, name = 'default', label = '', s = 1) { if (name === 'default') { this.hr = hr; this.far = far; this.s = s; this.label = label; } const location = this.locations.find((item) => { return (item.name === name); }); if (location === undefined) { this.locations.push({ name: name, far: far, hr: hr, s: s, label: label, }); } else { location.hr = hr; location.far = far; location.s = s; location.label = label; } this.requestUpdate(); } setWithSDT(d, c, name = 'default', label = '', s = 1) { if (name === 'default') { this.hr = SDTMath.dC2Hr(d, c, s); this.far = SDTMath.dC2Far(d, c, s); this.s = s; this.label = label; } const location = this.locations.find((item) => { return (item.name === name); }); if (location === undefined) { this.locations.push({ name: name, far: SDTMath.dC2Far(d, c, s), hr: SDTMath.dC2Hr(d, c, s), s: s, label: label, }); } else { location.hr = SDTMath.dC2Hr(d, c, s); location.far = SDTMath.dC2Far(d, c, s); location.s = s; location.label = label; } this.sdt = true; this.requestUpdate(); } static get styles() { return [ super.styles, css` :host { display: inline-block; width: 20rem; height: 20rem; } .main { width: 100%; height: 100%; } .plot-contour, .legend-contour .contour { stroke: var(---color-background); stroke-width: 0.5; } text { /* stylelint-disable property-no-vendor-prefix */ -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } .point.interactive { cursor: move; filter: url("#shadow-2"); outline: none; /* HACK: This gets Safari to correctly apply the filter! */ /* https://github.com/emilbjorklund/svg-weirdness/issues/27 */ 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; } :host(.keyboard) .point.interactive:focus { 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, .title-contour { font-weight: 600; fill: currentColor; } .tick { font-size: 0.75rem; } .axis-x path, .axis-x line, .axis-y path, .axis-y line { stroke: var(---color-element-border); } .axis-contour .domain { stroke: none; } .diagonal { stroke: var(---color-element-border); stroke-dasharray: 4; stroke-width: 1; } .curve-iso-d { fill: none; stroke: var(---color-d); stroke-width: 2; } .curve-iso-c { fill: none; stroke: var(---color-c); stroke-width: 2; } .point .circle { fill: var(---color-element-emphasis); r: 6px; } .point .label { font-size: 0.75rem; dominant-baseline: central; text-anchor: middle; fill: var(---color-text-inverse); } /* 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} `; } 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); // X Scale const xScale = d3.scaleLinear() .domain(this.zRoc ? [-3, 3] : [0, 1]) // zFAR or FAR .range([0, width]); this.xScale = xScale; // Y Scale const yScale = d3.scaleLinear() .domain(this.zRoc ? [3, -3] : [1, 0]) // zHR or HR .range([0, height]); this.yScale = yScale; // Drag behavior const drag = d3.drag() .subject((event, datum) => { return { x: this.xScale(this.zRoc ? SDTMath.far2Zfar(datum.far) : datum.far), y: this.yScale(this.zRoc ? SDTMath.hr2Zhr(datum.hr) : datum.hr), }; }) .on('start', (event) => { const element = event.currentTarget; d3.select(element).classed('dragging', true); }) .on('drag', (event, datum) => { this.drag = true; const far = this.zRoc ? SDTMath.zfar2Far(this.xScale.invert(event.x)) : this.xScale.invert(event.x); const hr = this.zRoc ? SDTMath.zhr2Hr(this.yScale.invert(event.y)) : this.yScale.invert(event.y); // Clamp FAR and HR to ROC Space datum.far = (far < 0.001) ? 0.001 : ((far > 0.999) ? 0.999 : far); datum.hr = (hr <= 0.001) ? 0.001 : (hr >= 0.999) ? 0.999 : hr; // console.log(`roc-space.drag: far = ${datum.far}, hr = ${datum.hr}`); if (datum.name === 'default') { this.far = datum.far; this.hr = datum.hr; } this.alignState(); this.requestUpdate(); this.dispatchEvent(new CustomEvent('roc-point-change', { detail: { name: datum.name, far: datum.far, hr: datum.hr, d: datum.d, c: datum.c, s: datum.s, label: datum.label, }, bubbles: true, })); }) .on('end', (event) => { const element = event.currentTarget; d3.select(element).classed('dragging', false); }); // Line for FAR/HR Space const line = d3.line() .x((datum) => { return xScale(this.zRoc ? SDTMath.far2Zfar(datum.far) : datum.far); }) .y((datum) => { return yScale(this.zRoc ? SDTMath.hr2Zhr(datum.hr) : datum.hr); }); // 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 ${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-roc-space') .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); // Contour Plotting // Handles: Bias, Sensitivity, & Accuracy if ( this.firstUpdate || changedProperties.has('contour') || changedProperties.has('zRoc') || changedProperties.has('width') || changedProperties.has('height') || changedProperties.has('rem') || changedProperties.has('s') ) { if (this.contour !== undefined) { // Contour Plot const n = 100; // Resolution const contourValues = []; for (let j = 0.5, k = 0; j < n; j += 1) { for (let i = 0.5; i < n; i += 1, k += 1) { const hr = this.zRoc ? SDTMath.zhr2Hr(((i / n) * 6) - 3) : i / n; const far = this.zRoc ? SDTMath.zfar2Far(((1 - j / n) * 6) - 3) : (1 - j / n); contourValues[k] = (this.contour === 'bias') ? SDTMath.hrFar2C(hr, far, this.s) : (this.contour === 'sensitivity') ? SDTMath.hrFar2D(hr, far, this.s) : (this.contour === 'accuracy') ? SDTMath.hrFar2Acc(hr, far) : null; } } const contourThresholds = (this.contour === 'bias') ? d3.range(-3, 3, 0.25) : (this.contour === 'sensitivity') ? d3.range(-6, 6, 0.5) : (this.contour === 'accuracy') ? d3.range(0, 1, 0.05) : null; const contours = d3.contours() .size([n, n]) .thresholds(contourThresholds); const contourColorStart = this.getComputedStyleValue((this.contour === 'bias') ? '---color-element-background' : (this.contour === 'sensitivity') ? '---color-d' : (this.contour === 'accuracy') ? '---color-acc-dark' : null); const contourColorEnd = this.getComputedStyleValue((this.contour === 'bias') ? '---color-c' : (this.contour === 'sensitivity') ? '---color-element-background' : (this.contour === 'accuracy') ? '---color-element-background' : null); const contourColor = d3.scaleLinear() .domain(d3.extent(contourThresholds)) .interpolate(() => { return d3.interpolateRgb(contourColorStart, contourColorEnd); }); // DATA-JOIN const contourPlotUpdate = underlayerMerge.selectAll('.plot-contour') .data([this.contour]); // ENTER const contourPlotEnter = contourPlotUpdate.enter().append('g') .classed('plot-contour', true); // MERGE const contourPlotMerge = contourPlotEnter.merge(contourPlotUpdate); // Contour Plot Contours // DATA-JOIN const contoursUpdate = contourPlotMerge.selectAll('.contour') .data(contours(contourValues)); // ENTER const contoursEnter = contoursUpdate.enter().append('path') .classed('contour', true); // MERGE contoursEnter.merge(contoursUpdate).transition() .duration(transitionDuration * 2) // Extra long transition! .ease(d3.easeCubicOut) .attr('d', d3.geoPath(d3.geoIdentity().scale(width / n))) // ???? .attr('fill', (datum) => { return contourColor(datum.value); }); // EXIT contoursUpdate.exit().remove(); // Contour Title // DATA-JOIN const contourTitleUpdate = underlayerMerge.selectAll('.title-contour') .data([this.contour]); // ENTER const contourTitleEnter = contourTitleUpdate.enter().append('text') .classed('title-contour', true) .attr('text-anchor', 'middle'); // MERGE contourTitleEnter.merge(contourTitleUpdate) .classed('math-var', (this.contour === 'bias') || (this.contour === 'sensitivity')) .attr('transform', (this.contour === 'bias') ? `translate(${(width + (1.25 * this.rem))}, ${this.rem})` : (this.contour === 'sensitivity') ? `translate(${(width + (1.25 * this.rem))}, ${this.rem})` : (this.contour === 'accuracy') ? `translate(${(width + (1.125 * this.rem))}, ${this.rem})` : null) .text((this.contour === 'bias') ? 'c' : (this.contour === 'sensitivity') ? 'd′' : (this.contour === 'accuracy') ? 'Acc' : null); // Contour Legend const l = 100; const contourLegendValues = []; // new Array(4 * l); for (let i = 0.5, k = 0; i < l; i += 1, k += 4) { contourLegendValues[k] = (this.contour === 'bias') ? -(((i / n) * 6) - 3) : (this.contour === 'sensitivity') ? ((i / n) * 12) - 6 : (this.contour === 'accuracy') ? (i / n) : null; contourLegendValues[k + 1] = contourLegendValues[k]; contourLegendValues[k + 2] = contourLegendValues[k]; contourLegendValues[k + 3] = contourLegendValues[k]; } const legendContours = d3.contours() .size([4, l]) .thresholds(contourThresholds); const legendScale = d3.scaleLinear() .domain((this.contour === 'bias') ? [3, -3] : (this.contour === 'sensitivity') ? [6, -6] : (this.contour === 'accuracy') ? [1, 0] : null) .range([0, (10 * this.rem)]); // DATA-JOIN const contourLegendUpdate = underlayerMerge.selectAll('.legend-contour') .data([this.contour]); // ENTER const contourLegendEnter = contourLegendUpdate.enter().append('g') .classed('legend-contour', true); // MERGE const contourLegendMerge = contourLegendEnter.merge(contourLegendUpdate) .attr('transform', (this.contour === 'bias') ? `translate(${(width + (1.25 * this.rem))}, ${(1.5 * this.rem)})` : (this.contour === 'sensitivity') ? `translate(${(width + (1.25 * this.rem))}, ${(1.5 * this.rem)})` : (this.contour === 'accuracy') ? `translate(${(width + (1.5 * this.rem))}, ${(1.5 * this.rem)})` : null); // EXIT contourLegendUpdate.exit().remove(); // Contour Legend Axis // ENTER contourLegendEnter.append('g') .classed('axis-contour', true); // MERGE contourLegendMerge.select('.axis-contour') .call(d3.axisLeft(legendScale).ticks(7).tickSize(0)) .attr('font-size', null) .attr('font-family', null); // Contour Legend Contours // DATA-JOIN const legendContoursUpdate = contourLegendMerge.selectAll('.contour') .data(legendContours(contourLegendValues)); // ENTER const legendContoursEnter = legendContoursUpdate.enter().append('path') .classed('contour', true); // MERGE legendContoursEnter.merge(legendContoursUpdate) .attr('d', d3.geoPath(d3.geoIdentity().scale((10 * this.rem) / l))) // ???? .attr('fill', (datum) => { return contourColor(datum.value); }); // EXIT legendContoursUpdate.exit().remove(); } else { // Contour Plot // DATA-JOIN const contourPlotUpdate = underlayerMerge.selectAll('.plot-contour') .data([]); // EXIT contourPlotUpdate.exit().remove(); // Contour Title // DATA-JOIN const contourTitleUpdate = underlayerMerge.selectAll('.title-contour') .data([]); // EXIT contourTitleUpdate.exit().remove(); // Contour Legend // DATA-JOIN const contourLegendUpdate = underlayerMerge.selectAll('.legend-contour') .data([]); // EXIT contourLegendUpdate.exit().remove(); } } // X Axis // ENTER underlayerEnter.append('g') .classed('axis-x', true); // MERGE const axisXMerge = underlayerMerge.select('.axis-x') .attr('transform', `translate(0, ${height})`); const axisXTransition = axisXMerge.transition() .duration(transitionDuration * 2) // Extra long transition! .ease(d3.easeCubicOut) .call(d3.axisBottom(xScale)) .attr('font-size', null) .attr('font-family', null); axisXTransition.selectAll('line, path') .attr('stroke', null); // X Axis Title // ENTER const titleXEnter = underlayerEnter.append('text') .classed('title-x', true) .attr('text-anchor', 'middle'); titleXEnter.append('tspan') .classed('z math-var', true); titleXEnter.append('tspan') .classed('name', true); // MERGE const titleXMerge = underlayerMerge.select('.title-x') .attr('transform', `translate(${(width / 2)}, ${(height + (2.25 * this.rem))})`); titleXMerge.select('tspan.z') .text(this.zRoc ? 'z' : ''); titleXMerge.select('tspan.name') .text(this.zRoc ? '(False Alarm Rate)' : 'False Alarm Rate'); // Y Axis // ENTER underlayerEnter.append('g') .classed('axis-y', true); // MERGE const axisYTransition = underlayerMerge.select('.axis-y').transition() .duration(transitionDuration * 2) // Extra long transition! .ease(d3.easeCubicOut) .call(d3.axisLeft(yScale)) .attr('font-size', null) .attr('font-family', null); axisYTransition.selectAll('line, path') .attr('stroke', null); // Y Axis Title // ENTER const titleYEnter = underlayerEnter.append('text') .classed('title-y', true) .attr('text-anchor', 'middle'); titleYEnter.append('tspan') .classed('z math-var', true); titleYEnter.append('tspan') .classed('name', true); // MERGE const titleYMerge = underlayerMerge.select('.title-y') .attr('transform', `translate(${-2 * this.rem}, ${(height / 2)})rotate(-90)`); titleYMerge.select('tspan.z') .text(this.zRoc ? 'z' : ''); titleYMerge.select('tspan.name') .text(this.zRoc ? '(Hit Rate)' : 'Hit Rate'); // No-Information Line // ENTER underlayerEnter.append('line') .classed('diagonal', true); // MERGE underlayerMerge.select('.diagonal') .attr('x1', this.zRoc ? xScale(-3) : xScale(0)) .attr('y1', this.zRoc ? yScale(-3) : yScale(0)) .attr('x2', this.zRoc ? xScale(3) : xScale(1)) .attr('y2', this.zRoc ? yScale(3) : yScale(1)); // Content // ENTER plotEnter.append('g') .classed('content', true); // MERGE const contentMerge = plotMerge.select('.content'); // Iso-sensitivity Curve // DATA-JOIN const isoDUpdate = contentMerge.selectAll('.curve-iso-d') .data(this.isoDArray, (datum) => { return datum.name; }); // ENTER const isoDEnter = isoDUpdate.enter().append('path') .classed('curve-iso-d', true) .attr('clip-path', 'url(#clip-roc-space)'); // MERGE const isoDMerge = isoDEnter.merge(isoDUpdate); if (this.firstUpdate || changedProperties.has('zRoc')) { isoDMerge.transition() .duration(this.drag ? 0 : (transitionDuration * 2)) // Extra long transition! .ease(d3.easeCubicOut) .attr('d', (datum) => { return line(d3.range(xScale.range()[0], xScale.range()[1] + 1, 1).map((x) => { return { far: (this.zRoc ? SDTMath.zfar2Far(xScale.invert(x)) : xScale.invert(x)), hr: (this.zRoc ? SDTMath.dFar2Hr(datum.d, SDTMath.zfar2Far(xScale.invert(x)), datum.s) : SDTMath.dFar2Hr(datum.d, xScale.invert(x), datum.s)), }; })); }); } else if (this.sdt) { isoDMerge.transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('d', (datum, index, elements) => { const element = elements[index]; element.hr = undefined; element.far = undefined; const interpolateD = d3.interpolate( (element.d !== undefined) ? element.d : datum.d, datum.d, ); const interpolateS = d3.interpolate( (element.s !== undefined) ? element.s : datum.s, datum.s, ); return (time) => { element.d = interpolateD(time); element.s = interpolateS(time); const isoD = d3.range(xScale.range()[0], xScale.range()[1] + 1, 1).map((x) => { return { far: (this.zRoc ? SDTMath.zfar2Far(xScale.invert(x)) : xScale.invert(x)), hr: (this.zRoc ? SDTMath.dFar2Hr(element.d, SDTMath.zfar2Far(xScale.invert(x)), element.s) : SDTMath.dFar2Hr(element.d, xScale.invert(x), element.s)), }; }); return line(isoD); }; }); } else { isoDMerge.transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('d', (datum, index, elements) => { const element = elements[index]; element.d = undefined; element.s = undefined; const interpolateHr = d3.interpolate( (element.hr !== undefined) ? element.hr : datum.hr, datum.hr, ); const interpolateFar = d3.interpolate( (element.far !== undefined) ? element.far : datum.far, datum.far, ); return (time) => { element.hr = interpolateHr(time); element.far = interpolateFar(time); const isoD = d3.range(xScale.range()[0], xScale.range()[1] + 1, 1).map((x) => { return { far: (this.zRoc ? SDTMath.zfar2Far(xScale.invert(x)) : xScale.invert(x)), hr: (this.zRoc ? SDTMath.dFar2Hr( SDTMath.hrFar2D(element.hr, element.far, datum.s), SDTMath.zfar2Far(xScale.invert(x)), datum.s, ) : SDTMath.dFar2Hr( SDTMath.hrFar2D(element.hr, element.far, datum.s), xScale.invert(x), datum.s, ) ), }; }); return line(isoD); }; }); } // EXIT // NOTE: Could add a transition here isoDUpdate.exit().remove(); // Iso-bias Curve // DATA-JOIN const isoCUpdate = contentMerge.selectAll('.curve-iso-c') .data(this.isoCArray, (datum) => { return datum.name; }); // ENTER const isoCEnter = isoCUpdate.enter().append('path') .classed('curve-iso-c', true) .attr('clip-path', 'url(#clip-roc-space)'); // MERGE const isoCMerge = isoCEnter.merge(isoCUpdate); if (this.firstUpdate || changedProperties.has('zRoc')) { isoCMerge.transition() .duration(this.drag ? 0 : (transitionDuration * 2)) // Extra long transition! .ease(d3.easeCubicOut) .attr('d', (datum) => { return line(d3.range(xScale.range()[0], xScale.range()[1] + 1, 1).map((x) => { return { far: (this.zRoc ? SDTMath.zfar2Far(xScale.invert(x)) : xScale.invert(x)), hr: (this.zRoc ? SDTMath.cFar2Hr(datum.c, SDTMath.zfar2Far(xScale.invert(x)), datum.s) : SDTMath.cFar2Hr(datum.c, xScale.invert(x), datum.s)), }; })); }); } else if (this.sdt) { isoCMerge.transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('d', (datum, index, elements) => { const element = elements[index]; element.hr = undefined; element.far = undefined; const interpolateC = d3.interpolate( (element.c !== undefined) ? element.c : datum.c, datum.c, ); const interpolateS = d3.interpolate( (element.s !== undefined) ? element.s : datum.s, datum.s, ); return (time) => { element.c = interpolateC(time); element.s = interpolateS(time); const isoC = d3.range(xScale.range()[0], xScale.range()[1] + 1, 1).map((x) => { return { far: (this.zRoc ? SDTMath.zfar2Far(xScale.invert(x)) : xScale.invert(x)), hr: (this.zRoc ? SDTMath.cFar2Hr(element.c, SDTMath.zfar2Far(xScale.invert(x)), element.s) : SDTMath.cFar2Hr(element.c, xScale.invert(x), element.s)), }; }); return line(isoC); }; }); } else { isoCMerge.transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('d', (datum, index, elements) => { const element = elements[index]; element.c = undefined; element.s = undefined; const interpolateHr = d3.interpolate( (element.hr !== undefined) ? element.hr : datum.hr, datum.hr, ); const interpolateFar = d3.interpolate( (element.far !== undefined) ? element.far : datum.far, datum.far, ); return (time) => { element.hr = interpolateHr(time); element.far = interpolateFar(time); const isoC = d3.range(xScale.range()[0], xScale.range()[1] + 1, 1).map((x) => { return { far: (this.zRoc ? SDTMath.zfar2Far(xScale.invert(x)) : xScale.invert(x)), hr: (this.zRoc ? SDTMath.cFar2Hr( SDTMath.hrFar2C(element.hr, element.far, datum.s), SDTMath.zfar2Far(xScale.invert(x)), datum.s, ) : SDTMath.cFar2Hr( SDTMath.hrFar2C(element.hr, element.far, datum.s), xScale.invert(x), datum.s, ) ), }; }); return line(isoC); }; }); } // EXIT // NOTE: Could add a transition here isoCUpdate.exit().remove(); // Point // DATA-JOIN const pointUpdate = contentMerge.selectAll('.point') .data(this.pointArray, (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; }); if (this.firstUpdate || changedProperties.has('interactive')) { if (this.interactive) { pointMerge .attr('tabindex', 0) .classed('interactive', true) .call(drag) .on('keydown', (event, datum) => { if (['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'].includes(event.key)) { let hr = this.zRoc ? SDTMath.hr2Zhr(datum.hr) : datum.hr; let far = this.zRoc ? SDTMath.far2Zfar(datum.far) : datum.far; switch (event.key) { case 'ArrowUp': hr += this.zRoc ? (event.shiftKey ? 0.05 : 0.25) : (event.shiftKey ? 0.01 : 0.05); break; case 'ArrowDown': hr -= this.zRoc ? (event.shiftKey ? 0.05 : 0.25) : (event.shiftKey ? 0.01 : 0.05); break; case 'ArrowRight': far += this.zRoc ? (event.shiftKey ? 0.05 : 0.25) : (event.shiftKey ? 0.01 : 0.05); break; case 'ArrowLeft': far -= this.zRoc ? (event.shiftKey ? 0.05 : 0.25) : (event.shiftKey ? 0.01 : 0.05); break; default: // no-op } hr = this.zRoc ? SDTMath.zhr2Hr(hr) : hr; far = this.zRoc ? SDTMath.zfar2Far(far) : far; // Clamp FAR and HR to ROC Space hr = (hr < 0.001) ? 0.001 : (hr > 0.999) ? 0.999 : hr; far = (far < 0.001) ? 0.001 : (far > 0.999) ? 0.999 : far; if ((hr !== datum.hr) || (far !== datum.far)) { datum.hr = hr; datum.far = far; if (datum.name === 'default') { this.hr = datum.hr; this.far = datum.far; } this.alignState(); this.requestUpdate(); this.dispatchEvent(new CustomEvent('roc-point-change', { detail: { name: datum.name, far: datum.far, hr: datum.hr, d: datum.d, c: datum.c, s: datum.s, label: datum.label, }, bubbles: true, })); } event.preventDefault(); } }); } else { pointMerge .attr('tabindex', null) .classed('interactive', false) .on('drag', null) .on('keydown', null); } } if (this.firstUpdate || changedProperties.has('zRoc')) { pointMerge.transition() .duration(this.drag ? 0 : (transitionDuration * 2)) // Extra long transition! .ease(d3.easeCubicOut) .attr('transform', (datum, index, elements) => { const element = elements[index]; element.d = undefined; element.c = undefined; element.s = undefined; return `translate( ${xScale(this.zRoc ? SDTMath.far2Zfar(datum.far) : datum.far)}, ${yScale(this.zRoc ? SDTMath.hr2Zhr(datum.hr) : datum.hr)} )`; }); } else if (this.sdt) { pointMerge.transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attrTween('transform', (datum, index, elements) => { const element = elements[index]; const interpolateD = d3.interpolate( (element.d !== undefined) ? element.d : datum.d, datum.d, ); const interpolateC = d3.interpolate( (element.c !== undefined) ? element.c : datum.c, datum.c, ); const interpolateS = d3.interpolate( (element.s !== undefined) ? element.s : datum.s, datum.s, ); return (time) => { element.d = interpolateD(time); element.c = interpolateC(time); element.s = interpolateS(time); return `translate( ${xScale( this.zRoc ? SDTMath.far2Zfar(SDTMath.dC2Far(element.d, element.c, element.s)) : SDTMath.dC2Far(element.d, element.c, element.s), )}, ${yScale( this.zRoc ? SDTMath.hr2Zhr(SDTMath.dC2Hr(element.d, element.c, element.s)) : SDTMath.dC2Hr(element.d, element.c, element.s), )} )`; }; }); } else { pointMerge.transition() .duration(this.drag ? 0 : transitionDuration) .ease(d3.easeCubicOut) .attr('transform', (datum, index, elements) => { const element = elements[index]; element.d = undefined; element.c = undefined; element.s = undefined; return `translate( ${xScale(this.zRoc ? SDTMath.far2Zfar(datum.far) : datum.far)}, ${yScale(this.zRoc ? SDTMath.hr2Zhr(datum.hr) : datum.hr)} )`; }); } // EXIT // NOTE: Could add a transition here pointUpdate.exit().remove(); this.drag = false; this.sdt = false; this.firstUpdate = false; } } customElements.define('roc-space', ROCSpace);