@decidables/detectable-elements 0.0.3

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE.md +1112 -0
  3. package/README.md +1218 -0
  4. package/lib/detectableElements.esm.js +18385 -0
  5. package/lib/detectableElements.esm.js.map +1 -0
  6. package/lib/detectableElements.esm.min.js +13 -0
  7. package/lib/detectableElements.esm.min.js.map +1 -0
  8. package/lib/detectableElements.umd.js +18413 -0
  9. package/lib/detectableElements.umd.js.map +1 -0
  10. package/lib/detectableElements.umd.min.js +13 -0
  11. package/lib/detectableElements.umd.min.js.map +1 -0
  12. package/package.json +58 -0
  13. package/src/components/detectable-control.js +272 -0
  14. package/src/components/detectable-response.js +414 -0
  15. package/src/components/detectable-table.js +602 -0
  16. package/src/components/index.js +7 -0
  17. package/src/components/rdk-task.js +586 -0
  18. package/src/components/roc-space.js +1220 -0
  19. package/src/components/sdt-model.js +1835 -0
  20. package/src/detectable-element.js +121 -0
  21. package/src/equations/dc2far.js +182 -0
  22. package/src/equations/dc2hr.js +191 -0
  23. package/src/equations/facr2far.js +120 -0
  24. package/src/equations/hm2hr.js +121 -0
  25. package/src/equations/hmfacr2acc.js +161 -0
  26. package/src/equations/hrfar2c.js +179 -0
  27. package/src/equations/hrfar2d.js +162 -0
  28. package/src/equations/index.js +8 -0
  29. package/src/equations/sdt-equation.js +141 -0
  30. package/src/examples/double-interactive.js +171 -0
  31. package/src/examples/human.js +184 -0
  32. package/src/examples/index.js +6 -0
  33. package/src/examples/interactive.js +131 -0
  34. package/src/examples/model.js +203 -0
  35. package/src/examples/sdt-example.js +76 -0
  36. package/src/examples/unequal.js +43 -0
  37. package/src/index.js +6 -0
@@ -0,0 +1,586 @@
1
+
2
+ import {html, css} from 'lit';
3
+ import * as d3 from 'd3';
4
+
5
+ import DetectableElement from '../detectable-element';
6
+
7
+ /*
8
+ RDKTask element
9
+ <rdk-task>
10
+
11
+ Attributes:
12
+ Dots; Coherence;
13
+ # Direction, Speed, Lifetime
14
+ */
15
+ export default class RDKTask extends DetectableElement {
16
+ static get properties() {
17
+ return {
18
+ coherence: {
19
+ attribute: 'coherence',
20
+ type: Number,
21
+ reflect: true,
22
+ },
23
+ count: {
24
+ attribute: 'count',
25
+ type: Number,
26
+ reflect: true,
27
+ },
28
+ probability: {
29
+ attribute: 'probability',
30
+ type: Number,
31
+ reflect: true,
32
+ },
33
+ duration: {
34
+ attribute: 'duration',
35
+ type: Number,
36
+ reflect: true,
37
+ },
38
+ wait: {
39
+ attribute: 'wait',
40
+ type: Number,
41
+ reflect: true,
42
+ },
43
+ iti: {
44
+ attribute: 'iti',
45
+ type: Number,
46
+ reflect: true,
47
+ },
48
+ trials: {
49
+ attribute: 'trials',
50
+ type: Number,
51
+ reflect: true,
52
+ },
53
+ running: {
54
+ attribute: 'running',
55
+ type: Boolean,
56
+ reflect: true,
57
+ },
58
+
59
+ direction: {
60
+ attribute: false,
61
+ type: Number,
62
+ reflect: false,
63
+ },
64
+ lifetime: {
65
+ attribute: false,
66
+ type: Number,
67
+ reflect: false,
68
+ },
69
+ speed: {
70
+ attribute: false,
71
+ type: Number,
72
+ reflect: false,
73
+ },
74
+
75
+ width: {
76
+ attribute: false,
77
+ type: Number,
78
+ reflect: false,
79
+ },
80
+ height: {
81
+ attribute: false,
82
+ type: Number,
83
+ reflect: false,
84
+ },
85
+ rem: {
86
+ attribute: false,
87
+ type: Number,
88
+ reflect: false,
89
+ },
90
+ };
91
+ }
92
+
93
+ constructor() {
94
+ super();
95
+
96
+ // Attributes
97
+ this.coherence = 0.5; // Proportion of dots moving coherently
98
+ this.count = 100; // Number of dots
99
+ this.probability = 0.5; // Probability of signal (as opposed to noise)
100
+ this.duration = 2000; // Duration of stimulus in milliseconds
101
+ this.wait = 2000; // Duration of wait period for response in milliseconds
102
+ this.iti = 2000; // Duration of inter-trial interval in milliseconds
103
+ this.trials = 5; // Number of trials per block
104
+ this.running = false; // Currently executing block of trials
105
+
106
+ // Properties
107
+ this.direction = -1; // Direction of current trial in degrees
108
+ this.lifetime = 400; // Lifetime of each dot in milliseconds
109
+ this.speed = 50; // Rate of dot movement in pixels per second
110
+
111
+ this.width = NaN; // Width of component in pixels
112
+ this.height = NaN; // Height of component in pixels
113
+ this.rem = NaN; // Pixels per rem for component
114
+
115
+ // Private
116
+ this.firstUpdate = true;
117
+ this.COHERENT = 0; // "Constant" for index to coherent dots
118
+ this.RANDOM = 1; // "Constant" for index to random dots
119
+ this.dots = [[], []]; // Array of array of dots
120
+ this.trial = 0; // Count of current trial
121
+
122
+ this.states = ['resetted', 'iti', 'stimulus', 'wait', 'ended']; // Possible states of task
123
+ this.state = 'resetted'; // Current state of task
124
+
125
+ this.baseTime = 0; // Real time, in milliseconds, that the current block started
126
+ this.pauseTime = 0; // Real time, in milliseconds, that block was paused at
127
+ this.startTime = 0; // Virtual time, in milliseconds, that current stage of trial started
128
+ this.lastTime = 0; // Virtual time, in milliseconds, of the most recent frame
129
+ this.currentDirection = undefined; // Direction in degrees for current trial
130
+
131
+ this.signals = ['present', 'absent']; // Possible trial types
132
+ this.signal = undefined; // Current trial type
133
+
134
+ this.runner = undefined; // D3 Interval for frame timing
135
+ this.xScale = undefined; // D3 Scale for x-axis
136
+ this.yScale = undefined; // D3 Scale for y-axis
137
+ }
138
+
139
+ static get styles() {
140
+ return [
141
+ super.styles,
142
+ css`
143
+ :host {
144
+ display: inline-block;
145
+
146
+ width: 10rem;
147
+ height: 10rem;
148
+ }
149
+
150
+ .main {
151
+ width: 100%;
152
+ height: 100%;
153
+ }
154
+
155
+ .background {
156
+ fill: var(---color-element-disabled);
157
+ stroke: none;
158
+ }
159
+
160
+ .outline {
161
+ fill: none;
162
+ stroke: var(---color-element-emphasis);
163
+ stroke-width: 2px;
164
+ }
165
+
166
+ .dot {
167
+ /* r: 2px; HACK: Firefox does not support CSS SVG Geometry Properties */
168
+ }
169
+
170
+ .dots.coherent {
171
+ fill: var(---color-background);
172
+ }
173
+
174
+ .dots.random {
175
+ fill: var(---color-background);
176
+ }
177
+
178
+ .fixation {
179
+ stroke: var(---color-text);
180
+ stroke-width: 2px;
181
+ }
182
+
183
+ .query {
184
+ font-size: 1.75rem;
185
+ font-weight: 600;
186
+ }
187
+ `,
188
+ ];
189
+ }
190
+
191
+ render() { // eslint-disable-line class-methods-use-this
192
+ return html``;
193
+ }
194
+
195
+ getDimensions() {
196
+ this.width = parseFloat(this.getComputedStyleValue('width'), 10);
197
+ this.height = parseFloat(this.getComputedStyleValue('height'), 10);
198
+ this.rem = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('font-size'), 10);
199
+ // console.log(`rdk-task: width = ${this.width}, height = ${this.height}, rem = ${this.rem}`);
200
+ }
201
+
202
+ connectedCallback() {
203
+ super.connectedCallback();
204
+ window.addEventListener('resize', this.getDimensions.bind(this));
205
+ }
206
+
207
+ disconnectedCallback() {
208
+ window.removeEventListener('resize', this.getDimensions.bind(this));
209
+ super.disconnectedCallback();
210
+ }
211
+
212
+ firstUpdated(changedProperties) {
213
+ super.firstUpdated(changedProperties);
214
+
215
+ // Get the width and height after initial render/update has occurred
216
+ // HACK Edge: Edge doesn't have width/height until after a 0ms timeout
217
+ window.setTimeout(this.getDimensions.bind(this), 0);
218
+ }
219
+
220
+ update(changedProperties) {
221
+ super.update(changedProperties);
222
+
223
+ // Bail out if we can't get the width/height/rem
224
+ if (Number.isNaN(this.width) || Number.isNaN(this.height) || Number.isNaN(this.rem)) {
225
+ return;
226
+ }
227
+
228
+ const elementWidth = this.width;
229
+ const elementHeight = this.height;
230
+ const elementSize = Math.min(elementWidth, elementHeight);
231
+
232
+ const margin = {
233
+ top: 0.25 * this.rem,
234
+ bottom: 0.25 * this.rem,
235
+ left: 0.25 * this.rem,
236
+ right: 0.25 * this.rem,
237
+ };
238
+ const height = elementSize - (margin.top + margin.bottom);
239
+ const width = elementSize - (margin.left + margin.right);
240
+
241
+ // X Scale
242
+ this.xScale = d3.scaleLinear()
243
+ .domain([-1, 1])
244
+ .range([0, width]);
245
+
246
+ // Y Scale
247
+ this.yScale = d3.scaleLinear()
248
+ .domain([1, -1])
249
+ .range([0, height]);
250
+
251
+ // Svg
252
+ // DATA-JOIN
253
+ const svgUpdate = d3.select(this.renderRoot).selectAll('.main')
254
+ .data([{
255
+ width: this.width,
256
+ height: this.height,
257
+ rem: this.rem,
258
+ }]);
259
+ // ENTER
260
+ const svgEnter = svgUpdate.enter().append('svg')
261
+ .classed('main', true);
262
+ // MERGE
263
+ const svgMerge = svgEnter.merge(svgUpdate)
264
+ .attr('viewBox', `0 0 ${elementSize} ${elementSize}`);
265
+
266
+ // Clippath
267
+ // ENTER
268
+ svgEnter.append('clipPath')
269
+ .attr('id', 'clip-rdk-task')
270
+ .append('circle');
271
+ // MERGE
272
+ svgMerge.select('clipPath circle')
273
+ .attr('cx', this.xScale(0))
274
+ .attr('cy', this.yScale(0))
275
+ .attr('r', this.xScale(1) - this.xScale(0));
276
+
277
+ // Plot
278
+ // ENTER
279
+ const plotEnter = svgEnter.append('g')
280
+ .classed('plot', true);
281
+ // MERGE
282
+ const plotMerge = svgMerge.select('.plot')
283
+ .attr('transform', `translate(${margin.left}, ${margin.top})`);
284
+
285
+ // Underlayer
286
+ // ENTER
287
+ const underlayerEnter = plotEnter.append('g')
288
+ .classed('underlayer', true);
289
+ // MERGE
290
+ const underlayerMerge = plotMerge.select('.underlayer');
291
+
292
+ // Background
293
+ // ENTER
294
+ underlayerEnter.append('circle')
295
+ .classed('background', true);
296
+ // MERGE
297
+ underlayerMerge.select('.background')
298
+ .attr('cx', this.xScale(0))
299
+ .attr('cy', this.yScale(0))
300
+ .attr('r', this.xScale(1) - this.xScale(0));
301
+
302
+ // Content
303
+ // ENTER
304
+ plotEnter.append('g')
305
+ .classed('content', true)
306
+ .attr('clip-path', 'url(#clip-rdk-task)');
307
+ // MERGE
308
+ const contentMerge = plotMerge.select('.content');
309
+
310
+ // Dot Groups
311
+ // DATA-JOIN
312
+ const dotsUpdate = contentMerge.selectAll('.dots')
313
+ .data([[], []]);
314
+ // ENTER
315
+ dotsUpdate.enter().append('g')
316
+ .classed('dots', true)
317
+ .classed('coherent', (datum, index) => { return index === this.COHERENT; })
318
+ .classed('random', (datum, index) => { return index === this.RANDOM; });
319
+
320
+ // Overlayer
321
+ // ENTER
322
+ const overlayerEnter = plotEnter.append('g')
323
+ .classed('overlayer', true);
324
+ // MERGE
325
+ const overlayerMerge = plotMerge.select('.overlayer');
326
+
327
+ // Outline
328
+ // ENTER
329
+ overlayerEnter.append('circle')
330
+ .classed('outline', true);
331
+ // MERGE
332
+ overlayerMerge.select('.outline')
333
+ .attr('cx', this.xScale(0))
334
+ .attr('cy', this.yScale(0))
335
+ .attr('r', this.xScale(1) - this.yScale(0));
336
+
337
+ // Start or stop trial block
338
+ if (this.firstUpdate || changedProperties.has('running')) {
339
+ if (this.running) {
340
+ // (Re)Start
341
+ if (this.pauseTime) {
342
+ // Shift timeline forward as if paused time never happened
343
+ this.baseTime += (d3.now() - this.pauseTime);
344
+ this.pauseTime = 0;
345
+ }
346
+ this.runner = d3.interval(this.run.bind(this), 20); // FIXME??
347
+ } else if (this.runner !== undefined) {
348
+ // Pause
349
+ this.runner.stop();
350
+ this.pauseTime = d3.now();
351
+ }
352
+ }
353
+
354
+ this.firstUpdate = false;
355
+ }
356
+
357
+ reset() {
358
+ if (this.runner !== undefined) {
359
+ this.runner.stop();
360
+ }
361
+ this.running = false;
362
+ this.trial = 0;
363
+ this.state = 'resetted';
364
+ this.baseTime = 0;
365
+ this.pauseTime = 0;
366
+ this.startTime = 0;
367
+ this.lastTime = 0;
368
+ this.signal = undefined;
369
+ this.currentDirection = undefined;
370
+
371
+ const dotsUpdate = d3.select(this.renderRoot).select('.content').selectAll('.dots')
372
+ .data([[], []]);
373
+ const dotUpdate = dotsUpdate.selectAll('.dot')
374
+ .data((datum) => { return datum; });
375
+ dotUpdate.exit()
376
+ .remove();
377
+
378
+ const fixationUpdate = d3.select(this.renderRoot).select('.content').selectAll('.fixation')
379
+ .data([]);
380
+ fixationUpdate.exit()
381
+ .remove();
382
+ const queryUpdate = d3.select(this.renderRoot).select('.content').selectAll('.query')
383
+ .data([]);
384
+ queryUpdate.exit()
385
+ .remove();
386
+ }
387
+
388
+ run(/* elapsed */) {
389
+ const realTime = d3.now();
390
+ const currentTime = (this.baseTime) ? (realTime - this.baseTime) : 0;
391
+ const elapsedTime = (this.baseTime) ? (currentTime - this.startTime) : 0;
392
+ const frameTime = (this.baseTime) ? (currentTime - this.lastTime) : 0;
393
+ this.lastTime = currentTime;
394
+ let newTrial = false;
395
+ if (this.state === 'resetted') {
396
+ // Start block with an ITI
397
+ this.state = 'iti';
398
+ this.baseTime = realTime;
399
+ this.startTime = 0;
400
+ this.dispatchEvent(new CustomEvent('rdk-block-start', {
401
+ detail: {
402
+ trials: this.trials,
403
+ },
404
+ bubbles: true,
405
+ }));
406
+ } else if ((this.state === 'iti') && (elapsedTime >= this.iti)) {
407
+ // Start new trial with a stimulus
408
+ newTrial = true;
409
+ this.trial += 1;
410
+ this.state = 'stimulus';
411
+ this.startTime = currentTime;
412
+ this.signal = (Math.random() < this.probability) ? 'present' : 'absent';
413
+ this.currentDirection = (this.signal === 'absent')
414
+ ? undefined
415
+ : (this.direction >= 0)
416
+ ? this.direction
417
+ : (Math.random() * 360);
418
+ this.dispatchEvent(new CustomEvent('rdk-trial-start', {
419
+ detail: {
420
+ trials: this.trials,
421
+ duration: this.duration,
422
+ wait: this.wait,
423
+ iti: this.iti,
424
+ trial: this.trial,
425
+ signal: this.signal,
426
+ },
427
+ bubbles: true,
428
+ }));
429
+ } else if ((this.state === 'stimulus') && (elapsedTime >= this.duration)) {
430
+ // Stimulus is over, now wait
431
+ this.state = 'wait';
432
+ this.startTime = currentTime;
433
+ this.dispatchEvent(new CustomEvent('rdk-trial-middle', {
434
+ detail: {
435
+ trials: this.trials,
436
+ duration: this.duration,
437
+ wait: this.wait,
438
+ iti: this.iti,
439
+ trial: this.trial,
440
+ signal: this.signal,
441
+ },
442
+ bubbles: true,
443
+ }));
444
+ } else if ((this.state === 'wait') && (elapsedTime >= this.wait)) {
445
+ // Wait is over, end of trial
446
+ this.dispatchEvent(new CustomEvent('rdk-trial-end', {
447
+ detail: {
448
+ trials: this.trials,
449
+ duration: this.duration,
450
+ wait: this.wait,
451
+ iti: this.iti,
452
+ trial: this.trial,
453
+ signal: this.signal,
454
+ },
455
+ bubbles: true,
456
+ }));
457
+ if (this.trial >= this.trials) {
458
+ // End of block
459
+ this.runner.stop();
460
+ this.running = false;
461
+ this.state = 'ended';
462
+ this.baseTime = 0;
463
+ this.pauseTime = 0;
464
+ this.startTime = 0;
465
+ this.lastTime = 0;
466
+ this.signal = undefined;
467
+ this.currentDirection = undefined;
468
+ this.dispatchEvent(new CustomEvent('rdk-block-end', {
469
+ detail: {
470
+ trials: this.trial,
471
+ },
472
+ bubbles: true,
473
+ }));
474
+ } else {
475
+ // ITI
476
+ this.state = 'iti';
477
+ this.startTime = currentTime;
478
+ }
479
+ }
480
+
481
+ // Dots
482
+ if (this.state === 'stimulus') {
483
+ this.dots[this.COHERENT].length = (this.signal === 'present') ? Math.round(this.count * this.coherence) : 0;
484
+ this.dots[this.RANDOM].length = (this.signal === 'present') ? (this.count - this.dots[this.COHERENT].length) : this.count;
485
+
486
+ for (let t = 0; t < this.dots.length; t += 1) {
487
+ for (let i = 0; i < this.dots[t].length; i += 1) {
488
+ const newDot = (this.dots[t][i] === undefined);
489
+ if (newDot) {
490
+ this.dots[t][i] = {};
491
+ }
492
+ const dot = this.dots[t][i];
493
+ if (newTrial || newDot) {
494
+ dot.direction = (t === this.RANDOM) ? (Math.random() * 360) : this.currentDirection;
495
+ dot.birth = currentTime - Math.floor(Math.random() * this.lifetime);
496
+ const angle = Math.random() * 2 * Math.PI;
497
+ const radius = Math.sqrt(Math.random());
498
+ dot.x = this.xScale(radius * Math.cos(angle));
499
+ dot.y = this.yScale(radius * Math.sin(angle));
500
+ } else if (currentTime > (dot.birth + this.lifetime)) {
501
+ // Dot has died, so rebirth
502
+ dot.birth += this.lifetime;
503
+ dot.direction = (t === this.RANDOM) ? (Math.random() * 360) : this.currentDirection;
504
+ const angle = Math.random() * 2 * Math.PI;
505
+ const radius = Math.sqrt(Math.random());
506
+ dot.x = this.xScale(radius * Math.cos(angle));
507
+ dot.y = this.yScale(radius * Math.sin(angle));
508
+ } else {
509
+ if (t === this.COHERENT) {
510
+ dot.direction = this.currentDirection;
511
+ }
512
+ const directionR = dot.direction * (Math.PI / 180);
513
+ dot.dx = this.speed * (frameTime / 1000) * Math.cos(directionR);
514
+ dot.dy = this.speed * (frameTime / 1000) * Math.sin(directionR);
515
+ // Update position
516
+ dot.x += dot.dx;
517
+ dot.y += dot.dy;
518
+ // Calculate squared distance from center
519
+ const distance2 = ((dot.x - this.xScale(0)) ** 2) + ((dot.y - this.yScale(0)) ** 2);
520
+ const radius2 = (this.xScale(1) - this.xScale(0)) ** 2;
521
+ if (distance2 > radius2) {
522
+ // Dot has exited so move to other side
523
+ dot.x = -(dot.x - this.xScale(0)) + this.xScale(0);
524
+ dot.y = -(dot.y - this.yScale(0)) + this.yScale(0);
525
+ }
526
+ }
527
+ }
528
+ }
529
+ }
530
+
531
+ // Fixation
532
+ // DATA-JOIN
533
+ const fixationUpdate = d3.select(this.renderRoot).select('.content').selectAll('.fixation')
534
+ .data((this.state === 'iti') ? [true] : []);
535
+ // ENTER
536
+ const fixationEnter = fixationUpdate.enter().append('g')
537
+ .classed('fixation', true);
538
+ fixationEnter.append('line')
539
+ .attr('x1', this.xScale(-0.1))
540
+ .attr('y1', this.xScale(0))
541
+ .attr('x2', this.xScale(0.1))
542
+ .attr('y2', this.xScale(0));
543
+ fixationEnter.append('line')
544
+ .attr('x1', this.xScale(0))
545
+ .attr('y1', this.xScale(-0.1))
546
+ .attr('x2', this.xScale(0))
547
+ .attr('y2', this.xScale(0.1));
548
+ // EXIT
549
+ fixationUpdate.exit().remove();
550
+
551
+ // Dots
552
+ // DATA-JOIN
553
+ const dotsUpdate = d3.select(this.renderRoot).select('.content').selectAll('.dots')
554
+ .data((this.state === 'stimulus') ? this.dots : [[], []]);
555
+ const dotUpdate = dotsUpdate.selectAll('.dot')
556
+ .data((datum) => { return datum; });
557
+ // ENTER
558
+ const dotEnter = dotUpdate.enter().append('circle')
559
+ .classed('dot', true)
560
+ .attr('r', 2); /* HACK: Firefox does not support CSS SVG Geometry Properties */
561
+ // MERGE
562
+ dotEnter.merge(dotUpdate)
563
+ .attr('cx', (datum) => { return datum.x; })
564
+ .attr('cy', (datum) => { return datum.y; });
565
+ // EXIT
566
+ dotUpdate.exit().remove();
567
+
568
+ // Query
569
+ // DATA-JOIN
570
+ const queryUpdate = d3.select(this.renderRoot).select('.content').selectAll('.query')
571
+ .data((this.state === 'wait') ? [true] : []);
572
+ // ENTER
573
+ const queryEnter = queryUpdate.enter().append('g')
574
+ .classed('query', true);
575
+ queryEnter.append('text')
576
+ .attr('x', this.xScale(0))
577
+ .attr('y', this.xScale(0))
578
+ .attr('text-anchor', 'middle')
579
+ .attr('alignment-baseline', 'middle')
580
+ .text('?');
581
+ // EXIT
582
+ queryUpdate.exit().remove();
583
+ }
584
+ }
585
+
586
+ customElements.define('rdk-task', RDKTask);