@decidables/accumulable-elements 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+
2
+ import {html, css} from 'lit';
3
+
4
+ import '@decidables/decidables-elements/slider';
5
+
6
+ import AccumulableElement from '../accumulable-element';
7
+
8
+ /*
9
+ DDMParameters element
10
+ <ddm-paramters>
11
+
12
+ Attributes:
13
+
14
+ */
15
+ export default class DDMParameters extends AccumulableElement {
16
+ static get properties() {
17
+ return {
18
+ a: {
19
+ attribute: 'boundary-separation',
20
+ type: Number,
21
+ reflect: true,
22
+ },
23
+ z: {
24
+ attribute: 'starting-point',
25
+ type: Number,
26
+ reflect: true,
27
+ },
28
+ v: {
29
+ attribute: 'drift-rate',
30
+ type: Number,
31
+ reflect: true,
32
+ },
33
+ t0: {
34
+ attribute: 'nondecision-time',
35
+ type: Number,
36
+ reflect: true,
37
+ },
38
+ };
39
+ }
40
+
41
+ constructor() {
42
+ super();
43
+
44
+ // Attributes
45
+ this.a = undefined;
46
+ this.z = undefined;
47
+ this.v = undefined;
48
+ this.t0 = undefined;
49
+ }
50
+
51
+ setBoundarySeparation(e) {
52
+ this.a = +e.target.value;
53
+ this.dispatchEvent(new CustomEvent('ddm-parameters-a', {
54
+ detail: {
55
+ a: this.a,
56
+ },
57
+ bubbles: true,
58
+ }));
59
+ }
60
+
61
+ setStartingPoint(e) {
62
+ this.z = +e.target.value;
63
+ this.dispatchEvent(new CustomEvent('ddm-parameters-z', {
64
+ detail: {
65
+ z: this.z,
66
+ },
67
+ bubbles: true,
68
+ }));
69
+ }
70
+
71
+ setDriftRate(e) {
72
+ this.v = +e.target.value;
73
+ this.dispatchEvent(new CustomEvent('ddm-parameters-v', {
74
+ detail: {
75
+ v: this.v,
76
+ },
77
+ bubbles: true,
78
+ }));
79
+ }
80
+
81
+ setNondecisionTime(e) {
82
+ this.t0 = +e.target.value;
83
+ this.dispatchEvent(new CustomEvent('ddm-parameters-t0', {
84
+ detail: {
85
+ t0: this.t0,
86
+ },
87
+ bubbles: true,
88
+ }));
89
+ }
90
+
91
+ static get styles() {
92
+ return [
93
+ super.styles,
94
+ css`
95
+ :host {
96
+ display: inline-block;
97
+ }
98
+
99
+ .holder {
100
+ display: flex;
101
+
102
+ flex-direction: row;
103
+
104
+ align-items: stretch;
105
+ justify-content: center;
106
+ }
107
+
108
+ decidables-slider {
109
+ line-height: 1;
110
+ text-align: center;
111
+ }
112
+
113
+ decidables-slider div {
114
+ margin-bottom: 0.25rem;
115
+ }
116
+ `,
117
+ ];
118
+ }
119
+
120
+ render() {
121
+ return html`
122
+ <div class="holder">
123
+ ${this.a != null
124
+ ? html`<decidables-slider class="a" ?disabled=${!this.interactive} min="0.01" max="2" step="0.01" .value=${+this.a.toFixed(2)} @change=${this.setBoundarySeparation.bind(this)} @input=${this.setBoundarySeparation.bind(this)}><div>Boundary Separation<br><span class="math-var">a</span></div></decidables-slider>`
125
+ : html``}
126
+ ${this.z != null
127
+ ? html`<decidables-slider class="z" ?disabled=${!this.interactive} min="0.01" max="0.99" step="0.01" .value=${+this.z.toFixed(2)} @change=${this.setStartingPoint.bind(this)} @input=${this.setStartingPoint.bind(this)}><div>Starting Point<br><span class="math-var">z</span></div></decidables-slider>`
128
+ : html``}
129
+ ${this.v != null
130
+ ? html`<decidables-slider class="v" ?disabled=${!this.interactive} min="0.01" max="5" step="0.01" .value=${+this.v.toFixed(2)} @change=${this.setDriftRate.bind(this)} @input=${this.setDriftRate.bind(this)}><div>Drift Rate<br><span class="math-var">v</span></div></decidables-slider>`
131
+ : html``}
132
+ ${this.t0 != null
133
+ ? html`<decidables-slider class="t0" ?disabled=${!this.interactive} min="0" max="500" step="1" .value=${+this.t0.toFixed(0)} @change=${this.setNondecisionTime.bind(this)} @input=${this.setNondecisionTime.bind(this)}><div>Nondecision Time<br><span class="math-var">t₀</span></div></decidables-slider>`
134
+ : html``}
135
+ </div>`;
136
+ }
137
+ }
138
+
139
+ customElements.define('ddm-parameters', DDMParameters);
@@ -0,0 +1,8 @@
1
+
2
+ export {default as AccumulableControl} from './accumulable-control';
3
+ export {default as AccumulableResponse} from './accumulable-response';
4
+ export {default as AccumulableTable} from './accumulable-table';
5
+ export {default as DDMFit} from './ddm-fit';
6
+ export {default as DDMParameters} from './ddm-parameters';
7
+ export {default as DDMModel} from './ddm-model';
8
+ export {default as RDK2AFCTask} from './rdk-2afc-task';
@@ -0,0 +1,537 @@
1
+
2
+ import {html, css} from 'lit';
3
+ import * as d3 from 'd3';
4
+
5
+ import {DecidablesMixinResizeable} from '@decidables/decidables-elements';
6
+
7
+ import AccumulableElement from '../accumulable-element';
8
+
9
+ /*
10
+ RDK2AFCTask element
11
+ <rdk-2afc-task>
12
+
13
+ Attributes:
14
+ Dots; Coherence;
15
+ # Direction, Speed, Lifetime
16
+ */
17
+ export default class RDK2AFCTask extends DecidablesMixinResizeable(AccumulableElement) {
18
+ static get properties() {
19
+ return {
20
+ coherence: {
21
+ attribute: 'coherence',
22
+ type: Number,
23
+ reflect: true,
24
+ },
25
+ count: {
26
+ attribute: 'count',
27
+ type: Number,
28
+ reflect: true,
29
+ },
30
+ probability: {
31
+ attribute: 'probability',
32
+ type: Number,
33
+ reflect: true,
34
+ },
35
+ duration: {
36
+ attribute: 'duration',
37
+ type: Number,
38
+ reflect: true,
39
+ },
40
+ wait: {
41
+ attribute: 'wait',
42
+ type: Number,
43
+ reflect: true,
44
+ },
45
+ iti: {
46
+ attribute: 'iti',
47
+ type: Number,
48
+ reflect: true,
49
+ },
50
+ trials: {
51
+ attribute: 'trials',
52
+ type: Number,
53
+ reflect: true,
54
+ },
55
+ running: {
56
+ attribute: 'running',
57
+ type: Boolean,
58
+ reflect: true,
59
+ },
60
+
61
+ lifetime: {
62
+ attribute: false,
63
+ type: Number,
64
+ reflect: false,
65
+ },
66
+ speed: {
67
+ attribute: false,
68
+ type: Number,
69
+ reflect: false,
70
+ },
71
+ };
72
+ }
73
+
74
+ constructor() {
75
+ super();
76
+
77
+ // Attributes
78
+ this.coherence = 0.5; // Proportion of dots moving coherently
79
+ this.count = 100; // Number of dots
80
+ this.probability = 0.5; // Probability of left (as opposed to right)
81
+ this.duration = 2000; // Duration of stimulus in milliseconds
82
+ this.wait = 2000; // Duration of wait period for response in milliseconds
83
+ this.iti = 2000; // Duration of inter-trial interval in milliseconds
84
+ this.trials = 5; // Number of trials per block
85
+ this.running = false; // Currently executing block of trials
86
+
87
+ // Properties
88
+ this.lifetime = 400; // Lifetime of each dot in milliseconds
89
+ this.speed = 50; // Rate of dot movement in pixels per second
90
+
91
+ // Private
92
+ this.firstUpdate = true;
93
+ this.COHERENT = 0; // "Constant" for index to coherent dots
94
+ this.RANDOM = 1; // "Constant" for index to random dots
95
+ this.dots = [[], []]; // Array of array of dots
96
+ this.trial = 0; // Count of current trial
97
+
98
+ this.states = ['resetted', 'iti', 'stimulus', 'wait', 'ended']; // Possible states of task
99
+ this.state = 'resetted'; // Current state of task
100
+
101
+ this.baseTime = 0; // Real time, in milliseconds, that the current block started
102
+ this.pauseTime = 0; // Real time, in milliseconds, that block was paused at
103
+ this.startTime = 0; // Virtual time, in milliseconds, that current stage of trial started
104
+ this.lastTime = 0; // Virtual time, in milliseconds, of the most recent frame
105
+
106
+ this.LEFT = 180; // "Constant" for left stimulus direction
107
+ this.RIGHT = 0; // "Constant" for right stimulus direction
108
+ this.currentDirection = undefined; // Direction in degrees for current trial
109
+
110
+ this.signals = ['left', 'right']; // Possible trial types
111
+ this.signal = undefined; // Current trial type
112
+
113
+ this.runner = undefined; // D3 Interval for frame timing
114
+ this.xScale = undefined; // D3 Scale for x-axis
115
+ this.yScale = undefined; // D3 Scale for y-axis
116
+ }
117
+
118
+ static get styles() {
119
+ return [
120
+ super.styles,
121
+ css`
122
+ :host {
123
+ display: inline-block;
124
+
125
+ width: 10rem;
126
+ height: 10rem;
127
+ }
128
+
129
+ .main {
130
+ width: 100%;
131
+ height: 100%;
132
+ }
133
+
134
+ .background {
135
+ fill: var(---color-element-disabled);
136
+ stroke: none;
137
+ }
138
+
139
+ .outline {
140
+ fill: none;
141
+ stroke: var(---color-element-emphasis);
142
+ stroke-width: 2px;
143
+ }
144
+
145
+ .dot {
146
+ r: 2px;
147
+ }
148
+
149
+ .dots.coherent {
150
+ fill: var(---color-background);
151
+ }
152
+
153
+ .dots.random {
154
+ fill: var(---color-background);
155
+ }
156
+
157
+ .fixation {
158
+ stroke: var(---color-text);
159
+ stroke-width: 2px;
160
+ }
161
+
162
+ .query {
163
+ font-size: 1.75rem;
164
+ font-weight: 600;
165
+ }
166
+ `,
167
+ ];
168
+ }
169
+
170
+ render() { /* eslint-disable-line class-methods-use-this */
171
+ return html``;
172
+ }
173
+
174
+ update(changedProperties) {
175
+ super.update(changedProperties);
176
+
177
+ // Bail out if we can't get the width/height/rem
178
+ if (Number.isNaN(this.width) || Number.isNaN(this.height) || Number.isNaN(this.rem)) {
179
+ return;
180
+ }
181
+
182
+ const elementWidth = this.width;
183
+ const elementHeight = this.height;
184
+ const elementSize = Math.min(elementWidth, elementHeight);
185
+
186
+ const margin = {
187
+ top: 0.25 * this.rem,
188
+ bottom: 0.25 * this.rem,
189
+ left: 0.25 * this.rem,
190
+ right: 0.25 * this.rem,
191
+ };
192
+ const height = elementSize - (margin.top + margin.bottom);
193
+ const width = elementSize - (margin.left + margin.right);
194
+
195
+ // X Scale
196
+ this.xScale = d3.scaleLinear()
197
+ .domain([-1, 1])
198
+ .range([0, width]);
199
+
200
+ // Y Scale
201
+ this.yScale = d3.scaleLinear()
202
+ .domain([1, -1])
203
+ .range([0, height]);
204
+
205
+ // Svg
206
+ // DATA-JOIN
207
+ const svgUpdate = d3.select(this.renderRoot).selectAll('.main')
208
+ .data([{
209
+ width: this.width,
210
+ height: this.height,
211
+ rem: this.rem,
212
+ }]);
213
+ // ENTER
214
+ const svgEnter = svgUpdate.enter().append('svg')
215
+ .classed('main', true);
216
+ // MERGE
217
+ const svgMerge = svgEnter.merge(svgUpdate)
218
+ .attr('viewBox', `0 0 ${elementSize} ${elementSize}`);
219
+
220
+ // Clippath
221
+ // ENTER
222
+ svgEnter.append('clipPath')
223
+ .attr('id', 'clip-rdk-task')
224
+ .append('circle');
225
+ // MERGE
226
+ svgMerge.select('clipPath circle')
227
+ .attr('cx', this.xScale(0))
228
+ .attr('cy', this.yScale(0))
229
+ .attr('r', this.xScale(1) - this.xScale(0));
230
+
231
+ // Plot
232
+ // ENTER
233
+ const plotEnter = svgEnter.append('g')
234
+ .classed('plot', true);
235
+ // MERGE
236
+ const plotMerge = svgMerge.select('.plot')
237
+ .attr('transform', `translate(${margin.left}, ${margin.top})`);
238
+
239
+ // Underlayer
240
+ // ENTER
241
+ const underlayerEnter = plotEnter.append('g')
242
+ .classed('underlayer', true);
243
+ // MERGE
244
+ const underlayerMerge = plotMerge.select('.underlayer');
245
+
246
+ // Background
247
+ // ENTER
248
+ underlayerEnter.append('circle')
249
+ .classed('background', true);
250
+ // MERGE
251
+ underlayerMerge.select('.background')
252
+ .attr('cx', this.xScale(0))
253
+ .attr('cy', this.yScale(0))
254
+ .attr('r', this.xScale(1) - this.xScale(0));
255
+
256
+ // Content
257
+ // ENTER
258
+ plotEnter.append('g')
259
+ .classed('content', true)
260
+ .attr('clip-path', 'url(#clip-rdk-task)');
261
+ // MERGE
262
+ const contentMerge = plotMerge.select('.content');
263
+
264
+ // Dot Groups
265
+ // DATA-JOIN
266
+ const dotsUpdate = contentMerge.selectAll('.dots')
267
+ .data([[], []]);
268
+ // ENTER
269
+ dotsUpdate.enter().append('g')
270
+ .classed('dots', true)
271
+ .classed('coherent', (datum, index) => { return index === this.COHERENT; })
272
+ .classed('random', (datum, index) => { return index === this.RANDOM; });
273
+
274
+ // Overlayer
275
+ // ENTER
276
+ const overlayerEnter = plotEnter.append('g')
277
+ .classed('overlayer', true);
278
+ // MERGE
279
+ const overlayerMerge = plotMerge.select('.overlayer');
280
+
281
+ // Outline
282
+ // ENTER
283
+ overlayerEnter.append('circle')
284
+ .classed('outline', true);
285
+ // MERGE
286
+ overlayerMerge.select('.outline')
287
+ .attr('cx', this.xScale(0))
288
+ .attr('cy', this.yScale(0))
289
+ .attr('r', this.xScale(1) - this.yScale(0));
290
+
291
+ // Start or stop trial block
292
+ if (this.firstUpdate || changedProperties.has('running')) {
293
+ if (this.running) {
294
+ // (Re)Start
295
+ if (this.pauseTime) {
296
+ // Shift timeline forward as if paused time never happened
297
+ this.baseTime += (d3.now() - this.pauseTime);
298
+ this.pauseTime = 0;
299
+ }
300
+ this.runner = d3.interval(this.run.bind(this), 20); // FIXME??
301
+ } else if (this.runner !== undefined) {
302
+ // Pause
303
+ this.runner.stop();
304
+ this.pauseTime = d3.now();
305
+ }
306
+ }
307
+
308
+ this.firstUpdate = false;
309
+ }
310
+
311
+ reset() {
312
+ if (this.runner !== undefined) {
313
+ this.runner.stop();
314
+ }
315
+ this.running = false;
316
+ this.trial = 0;
317
+ this.state = 'resetted';
318
+ this.baseTime = 0;
319
+ this.pauseTime = 0;
320
+ this.startTime = 0;
321
+ this.lastTime = 0;
322
+ this.signal = undefined;
323
+ this.currentDirection = undefined;
324
+
325
+ const dotsUpdate = d3.select(this.renderRoot).select('.content').selectAll('.dots')
326
+ .data([[], []]);
327
+ const dotUpdate = dotsUpdate.selectAll('.dot')
328
+ .data((datum) => { return datum; });
329
+ dotUpdate.exit()
330
+ .remove();
331
+
332
+ const fixationUpdate = d3.select(this.renderRoot).select('.content').selectAll('.fixation')
333
+ .data([]);
334
+ fixationUpdate.exit()
335
+ .remove();
336
+ const queryUpdate = d3.select(this.renderRoot).select('.content').selectAll('.query')
337
+ .data([]);
338
+ queryUpdate.exit()
339
+ .remove();
340
+ }
341
+
342
+ run(/* elapsed */) {
343
+ const realTime = d3.now();
344
+ const currentTime = (this.baseTime) ? (realTime - this.baseTime) : 0;
345
+ const elapsedTime = (this.baseTime) ? (currentTime - this.startTime) : 0;
346
+ const frameTime = (this.baseTime) ? (currentTime - this.lastTime) : 0;
347
+ this.lastTime = currentTime;
348
+ let newTrial = false;
349
+ if (this.state === 'resetted') {
350
+ // Start block with an ITI
351
+ this.state = 'iti';
352
+ this.baseTime = realTime;
353
+ this.startTime = 0;
354
+ this.dispatchEvent(new CustomEvent('rdk-block-start', {
355
+ detail: {
356
+ trials: this.trials,
357
+ },
358
+ bubbles: true,
359
+ }));
360
+ } else if ((this.state === 'iti') && (elapsedTime >= this.iti)) {
361
+ // Start new trial with a stimulus
362
+ newTrial = true;
363
+ this.trial += 1;
364
+ this.state = 'stimulus';
365
+ this.startTime = currentTime;
366
+ this.signal = (Math.random() < this.probability) ? 'left' : 'right';
367
+ this.currentDirection = (this.signal === 'left')
368
+ ? this.LEFT
369
+ : this.RIGHT;
370
+ this.dispatchEvent(new CustomEvent('rdk-trial-start', {
371
+ detail: {
372
+ trials: this.trials,
373
+ duration: this.duration,
374
+ wait: this.wait,
375
+ iti: this.iti,
376
+ trial: this.trial,
377
+ signal: this.signal,
378
+ },
379
+ bubbles: true,
380
+ }));
381
+ } else if ((this.state === 'stimulus') && (elapsedTime >= this.duration)) {
382
+ // Stimulus is over, now wait
383
+ this.state = 'wait';
384
+ this.startTime = currentTime;
385
+ this.dispatchEvent(new CustomEvent('rdk-trial-middle', {
386
+ detail: {
387
+ trials: this.trials,
388
+ duration: this.duration,
389
+ wait: this.wait,
390
+ iti: this.iti,
391
+ trial: this.trial,
392
+ signal: this.signal,
393
+ },
394
+ bubbles: true,
395
+ }));
396
+ } else if ((this.state === 'wait') && (elapsedTime >= this.wait)) {
397
+ // Wait is over, end of trial
398
+ this.dispatchEvent(new CustomEvent('rdk-trial-end', {
399
+ detail: {
400
+ trials: this.trials,
401
+ duration: this.duration,
402
+ wait: this.wait,
403
+ iti: this.iti,
404
+ trial: this.trial,
405
+ signal: this.signal,
406
+ },
407
+ bubbles: true,
408
+ }));
409
+ if (this.trial >= this.trials) {
410
+ // End of block
411
+ this.runner.stop();
412
+ this.running = false;
413
+ this.state = 'ended';
414
+ this.baseTime = 0;
415
+ this.pauseTime = 0;
416
+ this.startTime = 0;
417
+ this.lastTime = 0;
418
+ this.signal = undefined;
419
+ this.currentDirection = undefined;
420
+ this.dispatchEvent(new CustomEvent('rdk-block-end', {
421
+ detail: {
422
+ trials: this.trial,
423
+ },
424
+ bubbles: true,
425
+ }));
426
+ } else {
427
+ // ITI
428
+ this.state = 'iti';
429
+ this.startTime = currentTime;
430
+ }
431
+ }
432
+
433
+ // Dots
434
+ if (this.state === 'stimulus') {
435
+ this.dots[this.COHERENT].length = Math.round(this.count * this.coherence);
436
+ this.dots[this.RANDOM].length = (this.count - this.dots[this.COHERENT].length);
437
+
438
+ for (let t = 0; t < this.dots.length; t += 1) {
439
+ for (let i = 0; i < this.dots[t].length; i += 1) {
440
+ const newDot = (this.dots[t][i] === undefined);
441
+ if (newDot) {
442
+ this.dots[t][i] = {};
443
+ }
444
+ const dot = this.dots[t][i];
445
+ if (newTrial || newDot) {
446
+ dot.direction = (t === this.RANDOM) ? (Math.random() * 360) : this.currentDirection;
447
+ dot.birth = currentTime - Math.floor(Math.random() * this.lifetime);
448
+ const angle = Math.random() * 2 * Math.PI;
449
+ const radius = Math.sqrt(Math.random());
450
+ dot.x = this.xScale(radius * Math.cos(angle));
451
+ dot.y = this.yScale(radius * Math.sin(angle));
452
+ } else if (currentTime > (dot.birth + this.lifetime)) {
453
+ // Dot has died, so rebirth
454
+ dot.birth += this.lifetime;
455
+ dot.direction = (t === this.RANDOM) ? (Math.random() * 360) : this.currentDirection;
456
+ const angle = Math.random() * 2 * Math.PI;
457
+ const radius = Math.sqrt(Math.random());
458
+ dot.x = this.xScale(radius * Math.cos(angle));
459
+ dot.y = this.yScale(radius * Math.sin(angle));
460
+ } else {
461
+ if (t === this.COHERENT) {
462
+ dot.direction = this.currentDirection;
463
+ }
464
+ const directionR = dot.direction * (Math.PI / 180);
465
+ dot.dx = this.speed * (frameTime / 1000) * Math.cos(directionR);
466
+ dot.dy = this.speed * (frameTime / 1000) * Math.sin(directionR);
467
+ // Update position
468
+ dot.x += dot.dx;
469
+ dot.y += dot.dy;
470
+ // Calculate squared distance from center
471
+ const distance2 = ((dot.x - this.xScale(0)) ** 2) + ((dot.y - this.yScale(0)) ** 2);
472
+ const radius2 = (this.xScale(1) - this.xScale(0)) ** 2;
473
+ if (distance2 > radius2) {
474
+ // Dot has exited so move to other side
475
+ dot.x = -(dot.x - this.xScale(0)) + this.xScale(0);
476
+ dot.y = -(dot.y - this.yScale(0)) + this.yScale(0);
477
+ }
478
+ }
479
+ }
480
+ }
481
+ }
482
+
483
+ // Fixation
484
+ // DATA-JOIN
485
+ const fixationUpdate = d3.select(this.renderRoot).select('.content').selectAll('.fixation')
486
+ .data((this.state === 'iti') ? [true] : []);
487
+ // ENTER
488
+ const fixationEnter = fixationUpdate.enter().append('g')
489
+ .classed('fixation', true);
490
+ fixationEnter.append('line')
491
+ .attr('x1', this.xScale(-0.1))
492
+ .attr('y1', this.xScale(0))
493
+ .attr('x2', this.xScale(0.1))
494
+ .attr('y2', this.xScale(0));
495
+ fixationEnter.append('line')
496
+ .attr('x1', this.xScale(0))
497
+ .attr('y1', this.xScale(-0.1))
498
+ .attr('x2', this.xScale(0))
499
+ .attr('y2', this.xScale(0.1));
500
+ // EXIT
501
+ fixationUpdate.exit().remove();
502
+
503
+ // Dots
504
+ // DATA-JOIN
505
+ const dotsUpdate = d3.select(this.renderRoot).select('.content').selectAll('.dots')
506
+ .data((this.state === 'stimulus') ? this.dots : [[], []]);
507
+ const dotUpdate = dotsUpdate.selectAll('.dot')
508
+ .data((datum) => { return datum; });
509
+ // ENTER
510
+ const dotEnter = dotUpdate.enter().append('circle')
511
+ .classed('dot', true);
512
+ // MERGE
513
+ dotEnter.merge(dotUpdate)
514
+ .attr('cx', (datum) => { return datum.x; })
515
+ .attr('cy', (datum) => { return datum.y; });
516
+ // EXIT
517
+ dotUpdate.exit().remove();
518
+
519
+ // Query
520
+ // DATA-JOIN
521
+ const queryUpdate = d3.select(this.renderRoot).select('.content').selectAll('.query')
522
+ .data((this.state === 'wait') ? [true] : []);
523
+ // ENTER
524
+ const queryEnter = queryUpdate.enter().append('g')
525
+ .classed('query', true);
526
+ queryEnter.append('text')
527
+ .attr('x', this.xScale(0))
528
+ .attr('y', this.xScale(0))
529
+ .attr('text-anchor', 'middle')
530
+ .attr('alignment-baseline', 'middle')
531
+ .text('?');
532
+ // EXIT
533
+ queryUpdate.exit().remove();
534
+ }
535
+ }
536
+
537
+ customElements.define('rdk-2afc-task', RDK2AFCTask);