@decidables/discountable-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,1073 @@
1
+
2
+ import {html, css} from 'lit';
3
+ import * as d3 from 'd3';
4
+
5
+ import HTDMath from '@decidables/discountable-math';
6
+ import {DecidablesMixinResizeable} from '@decidables/decidables-elements';
7
+
8
+ import DiscountableElement from '../discountable-element';
9
+
10
+ /*
11
+ HTDCurves element
12
+ <htd-curves>
13
+
14
+ Attributes:
15
+ interactive: true/false
16
+
17
+ a: numeric (-infinity, infinity)
18
+ d: numeric [0, infinity)
19
+ k: numeric [0, infinity)
20
+ label: string
21
+
22
+ Styles:
23
+ ??
24
+ */
25
+ export default class HTDCurves extends DecidablesMixinResizeable(DiscountableElement) {
26
+ static get properties() {
27
+ return {
28
+ a: {
29
+ attribute: 'amount',
30
+ type: Number,
31
+ reflect: true,
32
+ },
33
+ d: {
34
+ attribute: 'delay',
35
+ type: Number,
36
+ reflect: true,
37
+ },
38
+ label: {
39
+ attribute: 'label',
40
+ type: String,
41
+ reflect: true,
42
+ },
43
+
44
+ k: {
45
+ attribute: 'k',
46
+ type: Number,
47
+ reflect: true,
48
+ },
49
+
50
+ v: {
51
+ attribute: false,
52
+ type: Number,
53
+ reflect: false,
54
+ },
55
+ };
56
+ }
57
+
58
+ constructor() {
59
+ super();
60
+
61
+ this.firstUpdate = true;
62
+ this.drag = false;
63
+
64
+ this.scale = {
65
+ value: {
66
+ min: 0,
67
+ max: 80,
68
+ step: 1,
69
+ round: Math.round,
70
+ },
71
+ time: {
72
+ min: 0,
73
+ max: 100,
74
+ step: 1,
75
+ round: Math.round,
76
+ },
77
+ discount: {
78
+ min: 0,
79
+ max: 100,
80
+ step: 0.001,
81
+ round: (k) => { return +k.toFixed(3); },
82
+ },
83
+ };
84
+
85
+ this.a = null;
86
+ this.d = null;
87
+ this.label = '';
88
+ this.k = 0.1;
89
+
90
+ this.options = [
91
+ {
92
+ name: 'default',
93
+ a: this.a,
94
+ d: this.d,
95
+ label: this.label,
96
+ },
97
+ ];
98
+
99
+ this.as = null;
100
+ this.ds = null;
101
+ this.al = null;
102
+ this.dl = null;
103
+ this.trialCount = null;
104
+ this.response = null;
105
+
106
+ this.alignState();
107
+ }
108
+
109
+ alignState() {
110
+ // Default options
111
+ this.options[0].a = this.a;
112
+ this.options[0].d = this.d;
113
+ this.options[0].label = this.label;
114
+
115
+ // Update values
116
+ this.options.forEach((option) => {
117
+ option.v = HTDMath.adk2v(option.a, option.d, this.k);
118
+ });
119
+ this.v = this.options[0].v;
120
+ }
121
+
122
+ trial(as, ds, al, dl, trial, response) {
123
+ // Remove the old trial
124
+ if (this.trialCount) this.removeOption(`${this.trialCount}-s`);
125
+ if (this.trialCount) this.removeOption(`${this.trialCount}-l`);
126
+
127
+ this.as = as;
128
+ this.ds = ds;
129
+ this.al = al;
130
+ this.dl = dl;
131
+ this.trialCount = trial;
132
+ this.response = response;
133
+
134
+ // Add the new trial
135
+ this.setOption(this.as, this.ds, `${this.trialCount}-s`, 's', true);
136
+ this.setOption(this.al, this.dl, `${this.trialCount}-l`, 'l', true);
137
+ }
138
+
139
+ // Called to pause trial animations!
140
+ pauseTrial() {
141
+ const lineNew = d3.select(this.renderRoot).selectAll('.lines[data-animating-ease-time-1]');
142
+ lineNew.interrupt('new-1');
143
+ lineNew.interrupt('new-2');
144
+ lineNew.datum((datum) => {
145
+ datum.paused = true;
146
+ return datum;
147
+ });
148
+ }
149
+
150
+ // Called to resume trial animations!
151
+ resumeTrial() {
152
+ const lineNew = d3.select(this.renderRoot).selectAll('.lines[data-animating-ease-time-1]');
153
+ lineNew.datum((datum) => {
154
+ datum.paused = false;
155
+ return datum;
156
+ });
157
+ this.requestUpdate();
158
+ }
159
+
160
+ clearOptions() {
161
+ this.options.splice(1);
162
+
163
+ this.requestUpdate();
164
+ }
165
+
166
+ removeOption(name) {
167
+ this.options = this.options.filter((option) => {
168
+ return (option.name !== name);
169
+ });
170
+
171
+ this.requestUpdate();
172
+ }
173
+
174
+ getOption(name = 'default') {
175
+ return this.options.find((option) => {
176
+ return (option.name === name);
177
+ });
178
+ }
179
+
180
+ setOption(a, d, name = 'default', label = '', trial = false) {
181
+ if (name === 'default') {
182
+ this.a = a;
183
+ this.d = d;
184
+ this.label = label;
185
+ }
186
+
187
+ const myOption = this.options.find((option) => {
188
+ return (option.name === name);
189
+ });
190
+ if (myOption === undefined) {
191
+ this.options.push({
192
+ name: name,
193
+ a: a,
194
+ d: d,
195
+ label: label,
196
+ trial: trial,
197
+ new: trial,
198
+ });
199
+ } else {
200
+ myOption.a = a;
201
+ myOption.d = d;
202
+ myOption.label = label;
203
+ }
204
+
205
+ this.requestUpdate();
206
+ }
207
+
208
+ static get styles() {
209
+ return [
210
+ super.styles,
211
+ css`
212
+ :host {
213
+ display: inline-block;
214
+
215
+ width: 27rem;
216
+ height: 15rem;
217
+ }
218
+
219
+ .main {
220
+ width: 100%;
221
+ height: 100%;
222
+ }
223
+
224
+ text {
225
+ /* stylelint-disable property-no-vendor-prefix */
226
+ -webkit-user-select: none;
227
+ -moz-user-select: none;
228
+ -ms-user-select: none;
229
+ user-select: none;
230
+ }
231
+
232
+ .background {
233
+ fill: var(---color-element-background);
234
+ stroke: var(---color-element-border);
235
+ stroke-width: 1;
236
+ shape-rendering: crispEdges;
237
+ }
238
+
239
+ .title-x,
240
+ .title-y {
241
+ font-weight: 600;
242
+
243
+ fill: currentColor;
244
+ }
245
+
246
+ .tick {
247
+ font-size: 0.75rem;
248
+ }
249
+
250
+ .axis-x path,
251
+ .axis-x line,
252
+ .axis-y path,
253
+ .axis-y line {
254
+ stroke: var(---color-element-border);
255
+ /* shape-rendering: crispEdges; */
256
+ }
257
+
258
+ .curve {
259
+ fill: none;
260
+ stroke: var(---color-element-emphasis);
261
+ stroke-width: 2;
262
+ }
263
+
264
+ .curve.interactive {
265
+ cursor: nwse-resize;
266
+
267
+ filter: url("#shadow-2");
268
+ outline: none;
269
+ }
270
+
271
+ .curve.interactive:hover {
272
+ filter: url("#shadow-4");
273
+ }
274
+
275
+ .curve.interactive:active {
276
+ filter: url("#shadow-8");
277
+ }
278
+
279
+ :host(.keyboard) .curve.interactive:focus {
280
+ filter: url("#shadow-8");
281
+ }
282
+
283
+ .bar {
284
+ fill: none;
285
+ stroke: var(---color-element-emphasis);
286
+ stroke-width: 2;
287
+ }
288
+
289
+ .bar.interactive {
290
+ cursor: ew-resize;
291
+
292
+ filter: url("#shadow-2");
293
+ outline: none;
294
+ }
295
+
296
+ .bar.interactive:hover {
297
+ filter: url("#shadow-4");
298
+ }
299
+
300
+ .bar.interactive:active {
301
+ filter: url("#shadow-8");
302
+ }
303
+
304
+ :host(.keyboard) .bar.interactive:focus {
305
+ filter: url("#shadow-8");
306
+ }
307
+
308
+ .point .mark {
309
+ fill: var(---color-element-emphasis);
310
+
311
+ r: 6px;
312
+ }
313
+
314
+ .point .label {
315
+ font-size: 0.75rem;
316
+
317
+ dominant-baseline: middle;
318
+ text-anchor: middle;
319
+
320
+ fill: var(---color-text-inverse);
321
+ }
322
+
323
+ .point.interactive {
324
+ cursor: ns-resize;
325
+
326
+ filter: url("#shadow-2");
327
+ outline: none;
328
+
329
+ /* HACK: This gets Safari to correctly apply the filter! */
330
+ /* https://github.com/emilbjorklund/svg-weirdness/issues/27 */
331
+ stroke: #000000;
332
+ stroke-opacity: 0;
333
+ stroke-width: 0;
334
+ }
335
+
336
+ .point.interactive:hover {
337
+ filter: url("#shadow-4");
338
+
339
+ /* HACK: This gets Safari to correctly apply the filter! */
340
+ stroke: #ff0000;
341
+ }
342
+
343
+ .point.interactive:active {
344
+ filter: url("#shadow-8");
345
+
346
+ /* HACK: This gets Safari to correctly apply the filter! */
347
+ stroke: #00ff00;
348
+ }
349
+
350
+ :host(.keyboard) .point.interactive:focus {
351
+ filter: url("#shadow-8");
352
+
353
+ /* HACK: This gets Safari to correctly apply the filter! */
354
+ stroke: #0000ff;
355
+ }
356
+ `,
357
+ ];
358
+ }
359
+
360
+ render() { /* eslint-disable-line class-methods-use-this */
361
+ return html``;
362
+ // ${DiscountableElement.svgFilters}
363
+ // `;
364
+ }
365
+
366
+ update(changedProperties) {
367
+ super.update(changedProperties);
368
+
369
+ this.alignState();
370
+
371
+ // Bail out if we can't get the width/height
372
+ if (Number.isNaN(this.width) || Number.isNaN(this.height) || Number.isNaN(this.rem)) {
373
+ return;
374
+ }
375
+
376
+ const hostWidth = this.width;
377
+ const hostHeight = this.height;
378
+ const hostAspectRatio = hostWidth / hostHeight;
379
+
380
+ const elementAspectRatio = 1.8;
381
+ let elementWidth;
382
+ let elementHeight;
383
+
384
+ if (hostAspectRatio > elementAspectRatio) {
385
+ elementHeight = hostHeight;
386
+ elementWidth = elementHeight * elementAspectRatio;
387
+ } else {
388
+ elementWidth = hostWidth;
389
+ elementHeight = elementWidth / elementAspectRatio;
390
+ }
391
+
392
+ const margin = {
393
+ top: 2 * this.rem,
394
+ bottom: 3 * this.rem,
395
+ left: 3 * this.rem,
396
+ right: 2 * this.rem,
397
+ };
398
+ const height = elementHeight - (margin.top + margin.bottom);
399
+ const width = elementWidth - (margin.left + margin.right);
400
+
401
+ const transitionDuration = parseInt(this.getComputedStyleValue('---transition-duration'), 10);
402
+
403
+ // X Scale
404
+ const xScale = d3.scaleLinear()
405
+ .domain([this.scale.time.min, this.scale.time.max])
406
+ .range([0, width]);
407
+
408
+ // Y Scale
409
+ const yScale = d3.scaleLinear()
410
+ .domain([this.scale.value.min, this.scale.value.max])
411
+ .range([height, 0]);
412
+
413
+ // Line for time/value space
414
+ const line = d3.line()
415
+ .x((datum) => { return xScale(datum.d); })
416
+ .y((datum) => { return yScale(datum.v); });
417
+
418
+ // Svg
419
+ // DATA-JOIN
420
+ const svgUpdate = d3.select(this.renderRoot).selectAll('.main')
421
+ .data([{
422
+ width: this.width,
423
+ height: this.height,
424
+ rem: this.rem,
425
+ }]);
426
+ // ENTER
427
+ const svgEnter = svgUpdate.enter().append('svg')
428
+ .classed('main', true);
429
+ svgEnter.html(DiscountableElement.svgDefs);
430
+ // MERGE
431
+ const svgMerge = svgEnter.merge(svgUpdate)
432
+ .attr('viewBox', `0 0 ${elementWidth} ${elementHeight}`);
433
+
434
+ // Plot
435
+ // ENTER
436
+ const plotEnter = svgEnter.append('g')
437
+ .classed('plot', true);
438
+ // MERGE
439
+ const plotMerge = svgMerge.select('.plot')
440
+ .attr('transform', `translate(${margin.left}, ${margin.top})`);
441
+
442
+ // Clippath
443
+ // ENTER
444
+ plotEnter.append('clipPath')
445
+ .attr('id', 'clip-htd-curves')
446
+ .append('rect');
447
+ // MERGE
448
+ plotMerge.select('clipPath rect')
449
+ .attr('height', height + 1)
450
+ .attr('width', width + 1);
451
+
452
+ // Underlayer
453
+ // ENTER
454
+ const underlayerEnter = plotEnter.append('g')
455
+ .classed('underlayer', true);
456
+ // MERGE
457
+ const underlayerMerge = plotMerge.select('.underlayer');
458
+
459
+ // Background
460
+ // ENTER
461
+ underlayerEnter.append('rect')
462
+ .classed('background', true);
463
+ // MERGE
464
+ underlayerMerge.select('.background')
465
+ .attr('height', height)
466
+ .attr('width', width);
467
+
468
+ // X Axis
469
+ // ENTER
470
+ underlayerEnter.append('g')
471
+ .classed('axis-x', true);
472
+ // MERGE
473
+ const scaleXMerge = underlayerMerge.select('.axis-x')
474
+ .attr('transform', `translate(0, ${yScale(0)})`);
475
+ const scaleXTransition = scaleXMerge.transition()
476
+ .duration(transitionDuration * 2) // Extra long transition!
477
+ .ease(d3.easeCubicOut)
478
+ .call(d3.axisBottom(xScale))
479
+ .attr('font-size', null)
480
+ .attr('font-family', null);
481
+ scaleXTransition.selectAll('line, path')
482
+ .attr('stroke', null);
483
+
484
+ // X Axis Title
485
+ // ENTER
486
+ const titleXEnter = underlayerEnter.append('text')
487
+ .classed('title-x', true)
488
+ .attr('text-anchor', 'middle');
489
+ titleXEnter.append('tspan')
490
+ .classed('name', true)
491
+ .text('Delay (');
492
+ titleXEnter.append('tspan')
493
+ .classed('math-var d', true)
494
+ .text('D');
495
+ titleXEnter.append('tspan')
496
+ .classed('name', true)
497
+ .text(')');
498
+ // MERGE
499
+ underlayerMerge.select('.title-x')
500
+ .attr('transform', `translate(${(width / 2)}, ${(height + (2.25 * this.rem))})`);
501
+
502
+ // Y Axis
503
+ // ENTER
504
+ underlayerEnter.append('g')
505
+ .classed('axis-y', true);
506
+ // MERGE
507
+ const scaleYTransition = underlayerMerge.select('.axis-y').transition()
508
+ .duration(transitionDuration * 2) // Extra long transition!
509
+ .ease(d3.easeCubicOut)
510
+ .call(d3.axisLeft(yScale))
511
+ .attr('font-size', null)
512
+ .attr('font-family', null);
513
+ scaleYTransition.selectAll('line, path')
514
+ .attr('stroke', null);
515
+
516
+ // Y Axis Title
517
+ // ENTER
518
+ const titleYEnter = underlayerEnter.append('text')
519
+ .classed('title-y', true)
520
+ .attr('text-anchor', 'middle');
521
+ titleYEnter.append('tspan')
522
+ .classed('name', true)
523
+ .text('Value (');
524
+ titleYEnter.append('tspan')
525
+ .classed('math-var v', true)
526
+ .text('V');
527
+ titleYEnter.append('tspan')
528
+ .classed('name', true)
529
+ .text(')');
530
+ // MERGE
531
+ underlayerMerge.select('.title-y')
532
+ .attr('transform', `translate(${-2 * this.rem}, ${(height / 2)})rotate(-90)`);
533
+
534
+ // Content
535
+ // ENTER
536
+ plotEnter.append('g')
537
+ .classed('content', true);
538
+ // MERGE
539
+ const contentMerge = plotMerge.select('.content');
540
+
541
+ // Options
542
+ // DATA-JOIN
543
+ const optionUpdate = contentMerge.selectAll('.option')
544
+ .data(
545
+ this.options.filter((option) => { return ((option.a !== null) && (option.d !== null)); }),
546
+ (datum) => { return datum.name; },
547
+ );
548
+ // ENTER
549
+ const optionEnter = optionUpdate.enter().append('g')
550
+ .classed('option', true);
551
+ // Curve
552
+ optionEnter.append('path')
553
+ .classed('curve', true)
554
+ .attr('clip-path', 'url(#clip-htd-curves)')
555
+ .attr('d', (datum) => {
556
+ const curve = d3.range(xScale(datum.d), xScale(0), -1).map((range) => {
557
+ return {
558
+ d: xScale.invert(range),
559
+ v: HTDMath.adk2v(
560
+ datum.a,
561
+ datum.d - xScale.invert(range),
562
+ this.k,
563
+ ),
564
+ };
565
+ });
566
+ return line(curve);
567
+ })
568
+ .attr('stroke-dasharray', (datum, index, nodes) => {
569
+ if (datum.trial) {
570
+ const length = nodes[index].getTotalLength();
571
+ return `0,${length}`;
572
+ }
573
+ return 'none';
574
+ });
575
+ // Bar
576
+ optionEnter.append('line')
577
+ .classed('bar', true)
578
+ .attr('x1', (datum) => { return xScale(datum.d); })
579
+ .attr('x2', (datum) => { return xScale(datum.d); })
580
+ .attr('y1', yScale(0))
581
+ .attr('y2', (datum) => { return yScale(datum.a); })
582
+ .attr('stroke-dasharray', (datum, index, nodes) => {
583
+ if (datum.trial) {
584
+ const length = nodes[index].getTotalLength();
585
+ return `0,${length}`;
586
+ }
587
+ return 'none';
588
+ });
589
+ // Point
590
+ const pointEnter = optionEnter.append('g')
591
+ .classed('point', true)
592
+ .attr('transform', (datum) => {
593
+ return `translate(${xScale(datum.d)}, ${yScale(datum.a)})`;
594
+ })
595
+ .attr('opacity', (datum) => {
596
+ if (datum.trial) {
597
+ return 0;
598
+ }
599
+ return 1;
600
+ });
601
+ pointEnter.append('circle')
602
+ .classed('mark', true);
603
+ pointEnter.append('text')
604
+ .classed('label', true);
605
+ // MERGE
606
+ const optionMerge = optionEnter.merge(optionUpdate);
607
+
608
+ // Interactive options
609
+ // Curve
610
+ optionMerge
611
+ .filter((datum, index, nodes) => {
612
+ return (this.interactive && !nodes[index].classList.contains('interactive'));
613
+ })
614
+ .select('.curve')
615
+ .classed('interactive', true)
616
+ .attr('tabindex', 0)
617
+ // Drag interaction
618
+ .call(d3.drag()
619
+ .subject((event) => {
620
+ return {
621
+ x: event.x,
622
+ y: event.y,
623
+ };
624
+ })
625
+ .on('start', (event) => {
626
+ const element = event.currentTarget;
627
+ d3.select(element).classed('dragging', true);
628
+ })
629
+ .on('drag', (event, datum) => {
630
+ this.drag = true;
631
+ const dragD = datum.d - xScale.invert(event.x);
632
+ const d = (dragD < 0)
633
+ ? 0
634
+ : (dragD > datum.d)
635
+ ? datum.d
636
+ : dragD;
637
+ const dragV = yScale.invert(event.y);
638
+ const v = (dragV <= 0)
639
+ ? 0.001
640
+ : (dragV > datum.a)
641
+ ? datum.a
642
+ : dragV;
643
+ const k = HTDMath.adv2k(datum.a, d, v);
644
+ this.k = (k < this.scale.discount.min)
645
+ ? this.scale.discount.min
646
+ : (k > this.scale.discount.max)
647
+ ? this.scale.discount.max
648
+ : this.scale.discount.round(k);
649
+ this.alignState();
650
+ this.requestUpdate();
651
+ this.dispatchEvent(new CustomEvent('htd-curves-change', {
652
+ detail: {
653
+ name: datum.name,
654
+ a: datum.a,
655
+ d: datum.d,
656
+ k: this.k,
657
+ label: datum.label,
658
+ },
659
+ bubbles: true,
660
+ }));
661
+ })
662
+ .on('end', (event) => {
663
+ const element = event.currentTarget;
664
+ d3.select(element).classed('dragging', false);
665
+ }))
666
+ // Keyboard interaction
667
+ .on('keydown', (event, datum) => {
668
+ if (['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'].includes(event.key)) {
669
+ let keyK = this.k;
670
+ switch (event.key) {
671
+ case 'ArrowUp':
672
+ case 'ArrowLeft':
673
+ keyK *= event.shiftKey ? 0.95 : 0.85;
674
+ break;
675
+ case 'ArrowDown':
676
+ case 'ArrowRight':
677
+ keyK *= event.shiftKey ? 1.05 : 1.15;
678
+ break;
679
+ default:
680
+ // no-op
681
+ }
682
+ keyK = (keyK < this.scale.discount.min)
683
+ ? this.scale.discount.min
684
+ : (keyK > this.scale.discount.max)
685
+ ? this.scale.discount.max
686
+ : this.scale.discount.round(keyK);
687
+ if (keyK !== this.k) {
688
+ this.k = keyK;
689
+ this.alignState();
690
+ this.requestUpdate();
691
+ this.dispatchEvent(new CustomEvent('htd-curves-change', {
692
+ detail: {
693
+ name: datum.name,
694
+ a: datum.a,
695
+ d: datum.d,
696
+ k: this.k,
697
+ label: datum.label,
698
+ },
699
+ bubbles: true,
700
+ }));
701
+ }
702
+ event.preventDefault();
703
+ }
704
+ });
705
+ // Bar
706
+ optionMerge
707
+ .filter((datum, index, nodes) => {
708
+ return (this.interactive && !datum.trial && !nodes[index].classList.contains('interactive'));
709
+ })
710
+ .select('.bar')
711
+ .classed('interactive', true)
712
+ .attr('tabindex', 0)
713
+ // Drag interaction
714
+ .call(d3.drag()
715
+ .subject((event, datum) => {
716
+ return {
717
+ x: xScale(datum.d),
718
+ y: yScale(datum.a),
719
+ };
720
+ })
721
+ .on('start', (event) => {
722
+ const element = event.currentTarget;
723
+ d3.select(element).classed('dragging', true);
724
+ })
725
+ .on('drag', (event, datum) => {
726
+ this.drag = true;
727
+ const d = xScale.invert(event.x);
728
+ datum.d = (d < this.scale.time.min)
729
+ ? this.scale.time.min
730
+ : (d > this.scale.time.max)
731
+ ? this.scale.time.max
732
+ : this.scale.time.round(d);
733
+ if (datum.name === 'default') {
734
+ this.d = datum.d;
735
+ }
736
+ this.alignState();
737
+ this.requestUpdate();
738
+ this.dispatchEvent(new CustomEvent('htd-curves-change', {
739
+ detail: {
740
+ name: datum.name,
741
+ a: datum.a,
742
+ d: datum.d,
743
+ k: this.k,
744
+ label: datum.label,
745
+ },
746
+ bubbles: true,
747
+ }));
748
+ })
749
+ .on('end', (event) => {
750
+ const element = event.currentTarget;
751
+ d3.select(element).classed('dragging', false);
752
+ }))
753
+ // Keyboard interaction
754
+ .on('keydown', (event, datum) => {
755
+ if (['ArrowLeft', 'ArrowRight'].includes(event.key)) {
756
+ let keyD = datum.d;
757
+ switch (event.key) {
758
+ case 'ArrowRight':
759
+ keyD += event.shiftKey ? 1 : 5;
760
+ break;
761
+ case 'ArrowLeft':
762
+ keyD -= event.shiftKey ? 1 : 5;
763
+ break;
764
+ default:
765
+ // no-op
766
+ }
767
+ keyD = (keyD < this.scale.time.min)
768
+ ? this.scale.time.min
769
+ : ((keyD > this.scale.time.max)
770
+ ? this.scale.time.max
771
+ : keyD);
772
+ if (keyD !== datum.d) {
773
+ datum.d = keyD;
774
+ if (datum.name === 'default') {
775
+ this.d = datum.d;
776
+ }
777
+ this.alignState();
778
+ this.requestUpdate();
779
+ this.dispatchEvent(new CustomEvent('htd-curves-change', {
780
+ detail: {
781
+ name: datum.name,
782
+ a: datum.a,
783
+ d: datum.d,
784
+ k: this.k,
785
+ label: datum.label,
786
+ },
787
+ bubbles: true,
788
+ }));
789
+ }
790
+ event.preventDefault();
791
+ }
792
+ });
793
+ // Point
794
+ optionMerge
795
+ .filter((datum, index, nodes) => {
796
+ return (this.interactive && !datum.trial && !nodes[index].classList.contains('interactive'));
797
+ })
798
+ .select('.point')
799
+ .classed('interactive', true)
800
+ .attr('tabindex', 0)
801
+ // Drag interaction
802
+ .call(d3.drag()
803
+ .subject((event, datum) => {
804
+ return {
805
+ x: xScale(datum.d),
806
+ y: yScale(datum.a),
807
+ };
808
+ })
809
+ .on('start', (event) => {
810
+ const element = event.currentTarget;
811
+ d3.select(element).classed('dragging', true);
812
+ })
813
+ .on('drag', (event, datum) => {
814
+ this.drag = true;
815
+ const a = yScale.invert(event.y);
816
+ datum.a = (a < this.scale.value.min)
817
+ ? this.scale.value.min
818
+ : (a > this.scale.value.max)
819
+ ? this.scale.value.max
820
+ : this.scale.value.round(a);
821
+ if (datum.name === 'default') {
822
+ this.a = datum.a;
823
+ }
824
+ this.alignState();
825
+ this.requestUpdate();
826
+ this.dispatchEvent(new CustomEvent('htd-curves-change', {
827
+ detail: {
828
+ name: datum.name,
829
+ a: datum.a,
830
+ d: datum.d,
831
+ k: this.k,
832
+ label: datum.label,
833
+ },
834
+ bubbles: true,
835
+ }));
836
+ })
837
+ .on('end', (event) => {
838
+ const element = event.currentTarget;
839
+ d3.select(element).classed('dragging', false);
840
+ }))
841
+ // Keyboard interaction
842
+ .on('keydown', (event, datum) => {
843
+ if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
844
+ let keyA = datum.a;
845
+ switch (event.key) {
846
+ case 'ArrowUp':
847
+ keyA += event.shiftKey ? 1 : 5;
848
+ break;
849
+ case 'ArrowDown':
850
+ keyA -= event.shiftKey ? 1 : 5;
851
+ break;
852
+ default:
853
+ // no-op
854
+ }
855
+ keyA = (keyA < this.scale.value.min)
856
+ ? this.scale.value.min
857
+ : ((keyA > this.scale.value.max)
858
+ ? this.scale.value.max
859
+ : keyA);
860
+ if (keyA !== datum.a) {
861
+ datum.a = keyA;
862
+ if (datum.name === 'default') {
863
+ this.a = datum.a;
864
+ }
865
+ this.alignState();
866
+ this.requestUpdate();
867
+ this.dispatchEvent(new CustomEvent('htd-curves-change', {
868
+ detail: {
869
+ name: datum.name,
870
+ a: datum.a,
871
+ d: datum.d,
872
+ k: this.k,
873
+ label: datum.label,
874
+ },
875
+ bubbles: true,
876
+ }));
877
+ }
878
+ event.preventDefault();
879
+ }
880
+ });
881
+
882
+ // Non-interactive options
883
+ // Curve
884
+ optionMerge
885
+ .filter((datum, index, nodes) => {
886
+ return (!this.interactive && nodes[index].classList.contains('interactive'));
887
+ })
888
+ .select('.curve')
889
+ .classed('interactive', false)
890
+ .attr('tabindex', null)
891
+ .on('drag', null)
892
+ .on('keydown', null);
893
+ // Bar
894
+ optionMerge
895
+ .filter((datum, index, nodes) => {
896
+ return ((!this.interactive || datum.trial) && nodes[index].classList.contains('interactive'));
897
+ })
898
+ .select('.bar')
899
+ .classed('interactive', false)
900
+ .attr('tabindex', null)
901
+ .on('drag', null)
902
+ .on('keydown', null);
903
+ // Point
904
+ optionMerge
905
+ .filter((datum, index, nodes) => {
906
+ return ((!this.interactive || datum.trial) && nodes[index].classList.contains('interactive'));
907
+ })
908
+ .select('.point')
909
+ .classed('interactive', false)
910
+ .attr('tabindex', null)
911
+ .on('drag', null)
912
+ .on('keydown', null);
913
+
914
+ // Trial Animation
915
+ // Curve
916
+ optionMerge
917
+ .filter((datum) => {
918
+ return (datum.new);
919
+ })
920
+ .select('.curve').transition()
921
+ .duration(transitionDuration)
922
+ .delay(transitionDuration + transitionDuration / 10)
923
+ .ease(d3.easeLinear)
924
+ .attrTween('stroke-dasharray', (datum, index, nodes) => {
925
+ const length = nodes[index].getTotalLength();
926
+ return d3.interpolate(`0,${length}`, `${length},${0}`);
927
+ })
928
+ .on('end', (datum) => {
929
+ datum.new = false;
930
+ this.dispatchEvent(new CustomEvent('discountable-response', {
931
+ detail: {
932
+ trial: this.trialCount,
933
+ as: this.as,
934
+ ds: this.ds,
935
+ al: this.al,
936
+ dl: this.dl,
937
+ response: this.response,
938
+ },
939
+ bubbles: true,
940
+ }));
941
+ });
942
+ // Bar
943
+ optionMerge
944
+ .filter((datum) => {
945
+ return (datum.new);
946
+ })
947
+ .select('.bar').transition()
948
+ .duration(transitionDuration)
949
+ .ease(d3.easeLinear)
950
+ .attrTween('stroke-dasharray', (datum, index, nodes) => {
951
+ const length = nodes[index].getTotalLength();
952
+ return d3.interpolate(`0,${length}`, `${length},${length}`);
953
+ });
954
+ // Point
955
+ optionMerge
956
+ .filter((datum) => {
957
+ return (datum.new);
958
+ })
959
+ .select('.point').transition()
960
+ .duration(transitionDuration / 10)
961
+ .delay(transitionDuration)
962
+ .ease(d3.easeLinear)
963
+ .attrTween('opacity', () => { return d3.interpolate(0, 1); });
964
+
965
+ // All options
966
+ optionUpdate.select('.curve').transition()
967
+ .duration(this.drag
968
+ ? 0
969
+ : (this.firstUpdate
970
+ ? (transitionDuration * 2)
971
+ : transitionDuration))
972
+ .ease(d3.easeCubicOut)
973
+ .attrTween('d', (datum, index, elements) => {
974
+ const element = elements[index];
975
+ const interpolateA = d3.interpolate(
976
+ (element.a !== undefined) ? element.a : datum.a,
977
+ datum.a,
978
+ );
979
+ const interpolateD = d3.interpolate(
980
+ (element.d !== undefined) ? element.d : datum.d,
981
+ datum.d,
982
+ );
983
+ return (time) => {
984
+ element.a = interpolateA(time);
985
+ element.d = interpolateD(time);
986
+ const curve = d3.range(xScale(element.d), xScale(0), -1).map((range) => {
987
+ return {
988
+ d: xScale.invert(range),
989
+ v: HTDMath.adk2v(
990
+ element.a,
991
+ element.d - xScale.invert(range),
992
+ this.k,
993
+ ),
994
+ };
995
+ });
996
+ return line(curve);
997
+ };
998
+ });
999
+ optionUpdate.select('.bar').transition()
1000
+ .duration(this.drag
1001
+ ? 0
1002
+ : (this.firstUpdate
1003
+ ? (transitionDuration * 2)
1004
+ : transitionDuration))
1005
+ .ease(d3.easeCubicOut)
1006
+ .attrTween('x1', (datum, index, elements) => {
1007
+ const element = elements[index];
1008
+ const interpolateD = d3.interpolate(
1009
+ (element.d !== undefined) ? element.d : datum.d,
1010
+ datum.d,
1011
+ );
1012
+ return (time) => {
1013
+ element.d = interpolateD(time);
1014
+ return `${xScale(element.d)}`;
1015
+ };
1016
+ })
1017
+ .attrTween('x2', (datum, index, elements) => {
1018
+ const element = elements[index];
1019
+ const interpolateD = d3.interpolate(
1020
+ (element.d !== undefined) ? element.d : datum.d,
1021
+ datum.d,
1022
+ );
1023
+ return (time) => {
1024
+ element.d = interpolateD(time);
1025
+ return `${xScale(element.d)}`;
1026
+ };
1027
+ })
1028
+ .attrTween('y2', (datum, index, elements) => {
1029
+ const element = elements[index];
1030
+ const interpolateA = d3.interpolate(
1031
+ (element.a !== undefined) ? element.a : datum.a,
1032
+ datum.a,
1033
+ );
1034
+ return (time) => {
1035
+ element.a = interpolateA(time);
1036
+ return `${yScale(element.a)}`;
1037
+ };
1038
+ });
1039
+ optionUpdate.select('.point').transition()
1040
+ .duration(this.drag
1041
+ ? 0
1042
+ : (this.firstUpdate
1043
+ ? (transitionDuration * 2)
1044
+ : transitionDuration))
1045
+ .ease(d3.easeCubicOut)
1046
+ .attrTween('transform', (datum, index, elements) => {
1047
+ const element = elements[index];
1048
+ const interpolateD = d3.interpolate(
1049
+ (element.d !== undefined) ? element.d : datum.d,
1050
+ datum.d,
1051
+ );
1052
+ const interpolateA = d3.interpolate(
1053
+ (element.a !== undefined) ? element.a : datum.a,
1054
+ datum.a,
1055
+ );
1056
+ return (time) => {
1057
+ element.d = interpolateD(time);
1058
+ element.a = interpolateA(time);
1059
+ return `translate(${xScale(element.d)}, ${yScale(element.a)})`;
1060
+ };
1061
+ });
1062
+ optionMerge.select('.point .label')
1063
+ .text((datum) => { return datum.label; });
1064
+ // EXIT
1065
+ // NOTE: Could add a transition here
1066
+ optionUpdate.exit().remove();
1067
+
1068
+ this.drag = false;
1069
+ this.firstUpdate = false;
1070
+ }
1071
+ }
1072
+
1073
+ customElements.define('htd-curves', HTDCurves);