-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpointer.js
More file actions
1748 lines (1632 loc) · 98.3 KB
/
Copy pathpointer.js
File metadata and controls
1748 lines (1632 loc) · 98.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* Pointer-reactive button specimens.
Same base button as the CSS set; the difference is that the response depends
on where the pointer is, how fast it moves, or which edge it crossed, so the
effect cannot be expressed as a hover state. One window listener feeds every
specimen; each runs a damped spring so motion settles instead of snapping.
Reduced motion turns the whole engine off and leaves the plain button. */
(function () {
var RM = matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ---- spring: critically-ish damped, integrated once per frame ---- */
function sp() { return { v: 0, t: 0, vel: 0 }; }
function step(s, k, d) { s.vel += (s.t - s.v) * k; s.vel *= d; s.v += s.vel; return s.v; }
function snap(x) { x.v = x.t; x.vel = 0; }
function clamp(n, a, b) { return n < a ? a : n > b ? b : n; }
function px(n) { return n.toFixed(2) + 'px'; }
/* ---- SVG border path: four edges, each a quadratic with a movable control ---- */
var W = 160, H = 48, I = 1; /* viewBox and stroke inset */
function edgePath(o) {
/* o = [top, right, bottom, left] control offsets, positive = outward */
var x0 = I, y0 = I, x1 = W - I, y1 = H - I, mx = W / 2, my = H / 2;
return 'M' + x0 + ' ' + y0 +
' Q' + mx + ' ' + (y0 - o[0]) + ' ' + x1 + ' ' + y0 +
' Q' + (x1 + o[1]) + ' ' + my + ' ' + x1 + ' ' + y1 +
' Q' + mx + ' ' + (y1 + o[2]) + ' ' + x0 + ' ' + y1 +
' Q' + (x0 - o[3]) + ' ' + my + ' ' + x0 + ' ' + y0 + 'Z';
}
/* corner path: four corner points, each displaced by [dx, dy] */
function cornerPath(c) {
return 'M' + (I + c[0][0]) + ' ' + (I + c[0][1]) +
' L' + (W - I + c[1][0]) + ' ' + (I + c[1][1]) +
' L' + (W - I + c[2][0]) + ' ' + (H - I + c[2][1]) +
' L' + (I + c[3][0]) + ' ' + (H - I + c[3][1]) + 'Z';
}
/* point on the border where a ray from the centre at angle th lands */
function rim(th) {
var c = Math.cos(th), n = Math.sin(th);
var t = Math.min((W / 2 - I) / Math.max(Math.abs(c), 1e-6), (H / 2 - I) / Math.max(Math.abs(n), 1e-6));
return [W / 2 + t * c, H / 2 + t * n];
}
/* distance along the border, clockwise from the top-left corner, of a rim point */
function rimPos(p) {
var x = p[0], y = p[1], w = W - 2 * I, h = H - 2 * I;
if (y < I + .5) return x - I;
if (x > W - I - .5) return w + (y - I);
if (y > H - I - .5) return w + h + (W - I - x);
return 2 * w + h + (H - I - y);
}
var PERIM = 2 * ((W - 2 * I) + (H - 2 * I));
/* set an angular target on a spring by the shortest way round */
function aim(spr, th) {
while (th - spr.v > Math.PI) th -= 2 * Math.PI;
while (th - spr.v < -Math.PI) th += 2 * Math.PI;
spr.t = th;
}
/* per-letter helpers: letter centres relative to the button, a bell curve
around the pointer, and a weight setter that rounds to whole units */
function centres(s) {
var r = s.el.getBoundingClientRect(); s.cxs = [];
for (var i = 0; i < s.letters.length; i++) { var b = s.letters[i].getBoundingClientRect(); s.cxs.push(b.left + b.width / 2 - r.left); }
}
function bell(d, sig) { return Math.exp(-(d * d) / (2 * sig * sig)); }
function letters(s) { s.letters = s.el.querySelectorAll('.label i'); s.cxs = null; }
function seed(arr, v) { for (var i = 0; i < arr.length; i++) { arr[i].v = arr[i].t = v; arr[i].vel = 0; } }
/* hatching helpers: a dot with a cached radius, a seeded scatter, SVG
element creation, and the 4x4 Bayer matrix read row by row */
var PITCH = 12, RAMP = 64, UID = 0, NS = 'http://www.w3.org/2000/svg';
var BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
function mk(parent, tag, attrs) {
var el = document.createElementNS(NS, tag);
for (var k in attrs) el.setAttribute(k, attrs[k]);
parent.appendChild(el); return el;
}
function dot(svg, x, y) { return { x: x, y: y, r: 0, soak: 0, el: mk(svg, 'circle', { cx: x.toFixed(1), cy: y.toFixed(1), r: 0 }) }; }
function setR(d, r) { r = Math.max(0, r); if (Math.abs(r - d.r) < .01) return; d.r = r; d.el.setAttribute('r', r.toFixed(2)); }
function rnd(i) { var x = Math.sin(i * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }
/* ---- 02-intent helpers ---- */
/* intent helpers: a short position history for heading estimates, and the
angle of travel across it. hist holds [x, y, t] triples, newest last. */
function histPush(s, x, y, t, span) {
var l = s.hist[s.hist.length - 1];
if (l && (t - l[2] > 100 || Math.abs(x - l[0]) + Math.abs(y - l[1]) > 120)) s.hist.length = 0; /* asleep or jumped: start over */
s.hist.push([x, y, t]);
while (s.hist.length > 2 && t - s.hist[0][2] > span) s.hist.shift();
}
/* heading in radians over the whole history, or null if it barely moved */
function histHeading(s, min) {
if (s.hist.length < 2) return null;
var a = s.hist[0], b = s.hist[s.hist.length - 1], dx = b[0] - a[0], dy = b[1] - a[1];
return dx * dx + dy * dy < min * min ? null : Math.atan2(dy, dx);
}
/* ---- 03-press helpers ---- */
/* press helpers: a soft clamp that lets a drag run past L only slowly, and
the release kick shared by the bounce for pointer and keyboard */
function soft(d, L) { return L * Math.tanh(d / L); }
function kick(s) { s.a.vel = .012 + .05 * clamp(s.held / 1000, 0, 1); }
/* ---- 04-keyboard helpers ---- */
/* keyboard focus only: true while the button is focused and the focus ring
would show, so a mouse click that lands focus does not run the effect */
function kbf(s) { return s.focused && s.el.matches(':focus-visible'); }
/* ---- 05-shape helpers ---- */
/* shape morph: the outline is rebuilt per frame from the rest rectangle.
Sides are numbered clockwise from the top (0 top, 1 right, 2 bottom,
3 left); u runs along a side in its clockwise direction and o is the
outward distance, negative into the box. At rest every builder returns
the same string as the markup: 'M1 1 L159 1 L159 47 L1 47Z'. */
var SLEN = [W - 2 * I, H - 2 * I, W - 2 * I, H - 2 * I];
var SIDE = { top: 0, right: 1, bottom: 2, left: 3 };
var CX = [I, W - I, W - I, I], CY = [I, I, H - I, H - I];
function num(v) { return String(Math.round(v * 100) / 100); }
function pt(p) { return num(p[0]) + ' ' + num(p[1]); }
function sidePt(k, u, o) {
if (k === 0) return [I + u, I - o];
if (k === 1) return [W - I + o, I + u];
if (k === 2) return [W - I - u, H - I + o];
return [I - o, H - I - u];
}
/* along-side coordinate of a point (x, y) projected onto side k */
function along(k, x, y) { return k === 0 ? x - I : k === 1 ? y - I : k === 2 ? W - I - x : H - I - y; }
/* which side a rim point sits on */
function sideOf(p) { return p[1] <= I + .5 ? 0 : p[0] >= W - I - .5 ? 1 : p[1] >= H - I - .5 ? 2 : 3; }
/* the rectangle with an optional path fragment inserted along each side */
function outline(ins) {
return 'M1 1' + ins[0] + ' L159 1' + ins[1] + ' L159 47' + ins[2] + ' L1 47' + ins[3] + 'Z';
}
/* a closed polygon */
function poly(p) {
var d = '';
for (var i = 0; i < p.length; i++) d += (i ? ' L' : 'M') + pt(p[i]);
return d + 'Z';
}
/* each corner cut back by c[k] along both of its edges, as a straight
chamfer or, with round set, a quarter arc of radius c[k] */
function cutPath(c, round) {
var d = '';
for (var k = 0; k < 4; k++) {
var v = c[k] > .05 ? c[k] : 0, x = CX[k], y = CY[k];
if (!v) { d += (k ? ' L' : 'M') + x + ' ' + y; continue; }
var ix = (k === 0 || k === 3) ? 1 : -1, iy = k < 2 ? 1 : -1; /* into the box */
var a = k % 2 ? [x + ix * v, y] : [x, y + iy * v]; /* arrive, clockwise */
var b = k % 2 ? [x, y + iy * v] : [x + ix * v, y]; /* leave */
d += (k ? ' L' : 'M') + pt(a) + (round ? ' A' + num(v) + ' ' + num(v) + ' 0 0 1 ' : ' L') + pt(b);
}
return d + 'Z';
}
/* proximity of the nearest corner: sets one spring target per corner (only
the nearest is non-zero, eased so it grows early) and returns the values */
function cornerCut(s, amp) {
var mi = -1, m = 0, c = [];
if (s.near) for (var i = 0; i < 4; i++) {
var ax = s.lxr - CX[i], ay = s.lyr - CY[i], w = 1 - Math.sqrt(ax * ax + ay * ay) / 100;
if (w > m) { m = w; mi = i; }
}
for (var j = 0; j < 4; j++) { s.m[j].t = j === mi ? m * (2 - m) * amp : 0; c.push(Math.max(0, step(s.m[j], .18, .70))); }
return c;
}
/* ---- 06-physics helpers ---- */
/* physics helpers. calm() lands a spring exactly on its target once it is
within eps, so a settled value is written in its rest form and never as
a rounding remainder. The jelly outline is twelve points on the border
rectangle, clockwise from the top-left corner: three between each pair
of top and bottom corners, one in the middle of each side. */
function calm(spr, eps) {
if (Math.abs(spr.v - spr.t) < eps && Math.abs(spr.vel) < eps) { spr.v = spr.t; spr.vel = 0; return true; }
return false;
}
var JN = 12;
function jellyRest() {
var p = [], x0 = I, x1 = W - I, y0 = I, y1 = H - I, i;
for (i = 0; i < 4; i++) p.push([x0 + (x1 - x0) * i / 4, y0]);
p.push([x1, y0]); p.push([x1, H / 2]);
for (i = 0; i < 4; i++) p.push([x1 - (x1 - x0) * i / 4, y1]);
p.push([x0, y1]); p.push([x0, H / 2]);
return p;
}
function jellyPath(x) {
var d = '';
for (var i = 0; i < JN; i++) d += (i ? 'L' : 'M') + x[2 * i].v.toFixed(2) + ' ' + x[2 * i + 1].v.toFixed(2);
return d + 'Z';
}
/* ---- 08-cursor helpers ---- */
/* cursor helpers: the drawn cursor lives only while the pointer is over the
card's stage. Stage enter shows it under the pointer in the same frame and
stage leave hides it at once, so there is never a frame with no cursor. */
function stageWatch(s, onEnter) {
var st = s.el.parentElement, cur = s.q('.cur');
st.addEventListener('pointerenter', function (e) {
var r = s.el.getBoundingClientRect();
s.lxr = e.clientX - r.left; s.lyr = e.clientY - r.top; s.on = 1;
if (onEnter) onEnter(s);
s.def.frame(s);
});
st.addEventListener('pointerleave', function () { s.on = 0; cur.style.visibility = 'hidden'; });
}
function curXY(cur, x, y) { cur.style.transform = 'translate(' + px(x) + ',' + px(y) + ')'; cur.style.visibility = 'visible'; }
/* ---- 11-filters helpers ---- */
/* filter helpers: an attribute value that is exactly '0' at rest, and a
discrete alpha table of k zeros then ones, so the cut is binary */
function filtNum(v) { return v ? v.toFixed(2) : '0'; }
function filtCut(k) { var t = []; for (var i = 0; i < 64; i++) t.push(i < k ? 0 : 1); return t.join(' '); }
/* ---- 13-after helpers ---- */
/* after the press: states start on a completed click (up or keyup) and time
themselves from s.t0; a drag off the button is not a click. creep() is a
progress curve that stalls the way real progress does; ease() is smoothstep */
function afterStart(s) { if (!s.t0) s.t0 = performance.now(); }
function afterAgain(s) { s.t0 = performance.now(); }
function afterNothing() {}
function afterToggle(s) { if (s.t0) { s.t0 = 0; s.q('.ink').style.transform = ''; } else s.t0 = performance.now(); }
var CREEP = [[0, 0], [.22, .48], [.6, .68], [.86, .78], [1, 1]];
function creep(u) {
for (var i = 1; i < CREEP.length; i++) if (u <= CREEP[i][0]) {
var a = CREEP[i - 1], b = CREEP[i];
return a[1] + (b[1] - a[1]) * (u - a[0]) / (b[0] - a[0]);
}
return 1;
}
function ease(u) { u = clamp(u, 0, 1); return u * u * (3 - 2 * u); }
/* the check path runs along the bottom and left border first (coincident with
the rule, so unseen), then across to the check. The dash is exactly the
check's length and slides from the border into place */
var CHK_PTS = [[40, 47], [1, 47], [1, 24], [69, 24], [76, 31], [91, 16]];
var CHK_PATH = CHK_PTS.map(function (p, i) { return (i ? 'L' : 'M') + p[0] + ' ' + p[1]; }).join(' ');
function seglen(a, b) { return Math.sqrt((b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])); }
var CHK_L = 0, CHK_D = seglen(CHK_PTS[3], CHK_PTS[4]) + seglen(CHK_PTS[4], CHK_PTS[5]);
for (var ci = 1; ci < CHK_PTS.length; ci++) CHK_L += seglen(CHK_PTS[ci - 1], CHK_PTS[ci]);
/* ---- 20-label helpers ---- */
/* label content helpers. The word may change but the box may not: each
letter remembers its source text and is fixed at its measured rest width
once fonts are ready, glyphs centred in it (see pointer.css). With lower
set, the lowercase advance of each letter is measured too, so Case morph
can re-pack the word at natural spacing inside the fixed box. rest()
puts every letter back to its source text with no inline colour, caret
or case override; it only touches what differs. */
var POOL = 'ABCDEFGHKLNOPRSTUVXYZ', ALT = 'BOUTON';
function keepBox(s, lower) {
letters(s); s.src = []; s.was = 0; s.t0 = 0; s.tx = 0; s.salt = 0;
s.until = 1; /* read every sample from the start, so the first entry edge is the real one */
for (var i = 0; i < s.letters.length; i++) s.src.push(s.letters[i].textContent);
document.fonts.ready.then(function () {
var i, L, wu = [], wl = [];
if (lower) {
for (i = 0; i < s.letters.length; i++) { L = s.letters[i]; L.style.textTransform = 'none'; wl.push(L.getBoundingClientRect().width); L.style.textTransform = ''; }
}
for (i = 0; i < s.letters.length; i++) wu.push(s.letters[i].getBoundingClientRect().width);
for (i = 0; i < s.letters.length; i++) s.letters[i].style.width = px(wu[i]);
s.wu = wu; if (lower) s.wl = wl;
});
}
function rest(s) {
for (var i = 0; i < s.letters.length; i++) {
var L = s.letters[i];
if (L.textContent !== s.src[i]) L.textContent = s.src[i];
if (L.style.color) L.style.color = '';
if (L.style.boxShadow) L.style.boxShadow = '';
if (L.style.textTransform) L.style.textTransform = '';
}
}
/* entry and exit stamps taken in the frame loop: t0 is the frame the
pointer was first seen inside, tx the frame it was first seen outside */
function stamp(s) {
if (s.inside && !s.was) { s.was = 1; s.t0 = s.now; s.ox = s.ex; s.salt++; }
if (!s.inside && s.was) { s.was = 0; s.tx = s.now; s.xx = s.lx; }
}
/* letter index order starting from a side: 'right' counts down */
function fromSide(side) { return side === 'right' ? [5, 4, 3, 2, 1, 0] : [0, 1, 2, 3, 4, 5]; }
/* which half a crossing belongs to: left and right edges answer for
themselves, a top or bottom crossing answers with the half it came
through, so every entry side has a direction and none is inert */
function halfOf(s, side, x) {
if (side === 'left' || side === 'right') return side;
return (s.r && x > s.r.width / 2) ? 'right' : 'left';
}
/* re-pack the letters at their current advances, centred in the fixed
label box: each glyph slides on a spring from its box centre to where
it would sit if the word were laid out naturally */
function pack(s) {
if (!s.wu || !s.wl) return;
var i, cur = [], total = 0, box = 0;
for (i = 0; i < 6; i++) { cur[i] = s.letters[i].style.textTransform === 'none' ? s.wl[i] : s.wu[i]; total += cur[i]; box += s.wu[i]; }
var pos = (box - total) / 2, bpos = 0;
for (i = 0; i < 6; i++) {
s.m[i].t = (pos + cur[i] / 2) - (bpos + s.wu[i] / 2); pos += cur[i]; bpos += s.wu[i];
var v = step(s.m[i], .25, .65), L = s.letters[i];
L.style.transform = Math.abs(v) < .01 ? '' : 'translateX(' + px(v) + ')';
}
}
/* ---- 05-shape press helper ---- */
/* the pressed form of cornerCut: the same four corner springs, but the
target is a flat amount rather than the proximity easing, and with all
set every corner takes it instead of only the nearest one. */
function cornerHold(s, amp, all) {
var mi = 0, m = -1e9, c = [], i, j, ax, ay, w;
for (i = 0; i < 4; i++) {
ax = s.lxr - CX[i]; ay = s.lyr - CY[i]; w = 1 - Math.sqrt(ax * ax + ay * ay) / 100;
if (w > m) { m = w; mi = i; }
}
for (j = 0; j < 4; j++) { s.m[j].t = (all || j === mi) ? amp : 0; c.push(Math.max(0, step(s.m[j], .18, .70))); }
return c;
}
/* ---- effects. s carries the shared pointer state; see read() below ---- */
/* ---- 01-pointer press helpers: the point a press happened at, so a press
state can pin itself there while the pointer keeps moving. A keyboard
press has no point, so it falls back to the centre of the button. ---- */
function ppt(s) { s.pxp = s.inside ? s.lx : W / 2; s.pyp = s.inside ? s.ly : H / 2; }
var PFX = {
/* 37 Magnet: the button drifts toward the pointer, up to 14px, and springs
back. Reach extends past the edge so the pull starts before hover.
Press: the pull completes, the plate centre lands under the pointer. */
magnet: { reach: 90, frame: function (s) {
var d = s.down || s.kdown;
s.a.t = clamp(s.dx * (d ? 1 : 0.3), d ? -26 : -14, d ? 26 : 14) * s.near;
s.b.t = clamp(s.dy * (d ? 1 : 0.3), d ? -15 : -10, d ? 15 : 10) * s.near;
s.el.style.transform = 'translate(' + px(step(s.a, .16, .74)) + ',' + px(step(s.b, .16, .74)) + ')';
} },
/* 38 Label magnet: the frame is fixed, the label alone leans.
Press: the lean carries on past the pointer to the near edge. */
maglabel: { reach: 70, frame: function (s) {
var d = s.down || s.kdown;
s.a.t = clamp(s.dx * (d ? 1.6 : 0.16), d ? -26 : -12, d ? 26 : 12) * s.near;
s.b.t = clamp(s.dy * (d ? 1.6 : 0.16), d ? -11 : -7, d ? 11 : 7) * s.near;
s.q('.label').style.transform = 'translate(' + px(step(s.a, .14, .76)) + ',' + px(step(s.b, .14, .76)) + ')';
} },
/* 39 Ink bleed: the fill grows as a disc from the exact point of entry, so
two approaches to the same button never fill the same way.
Press: the fill drains back to a disc on the entry point it grew from. */
bleed: { frame: function (s) {
var d = s.down || s.kdown;
s.a.t = d ? 30 : s.inside ? 190 : 0;
var r = step(s.a, .085, d ? .62 : .72);
s.q('.ink').style.clipPath = 'circle(' + Math.max(0, r).toFixed(1) + 'px at ' + px(s.ex) + ' ' + px(s.ey) + ')';
} },
/* 40 Follow disc: a black disc tracks the pointer inside the button.
Press: the disc pins where it was pressed and draws in to a bead. */
disc: { frame: function (s) {
var d = s.down || s.kdown;
if (!d) { s.a.t = s.lx; s.b.t = s.ly; }
s.c.t = d ? 0.42 : s.inside ? 1 : 0;
var d = s.q('.ink');
d.style.transform = 'translate(' + px(step(s.a, .30, .60) - 34) + ',' + px(step(s.b, .30, .60) - 34) + ') scale(' + step(s.c, .18, .70).toFixed(3) + ')';
}, init: function (s) { s.a.v = s.a.t = W / 2; s.b.v = s.b.t = H / 2; },
keydown: function (s) { if (!s.inside) { s.a.t = W / 2; s.b.t = H / 2; } } },
/* 41 Direction fill: the fill enters from the edge the pointer crossed.
Press: the fill backs part way out of the edge it came in by. */
dirfill: { frame: function (s) {
s.a.t = (s.down || s.kdown) ? 0.62 : s.inside ? 0 : 1;
var v = step(s.a, .16, .70), e = s.entry;
var x = e === 'left' ? -v : e === 'right' ? v : 0;
var y = e === 'top' ? -v : e === 'bottom' ? v : 0;
s.q('.ink').style.transform = 'translate(' + (x * 101) + '%,' + (y * 101) + '%)';
} },
/* 42 Direction out: the fill enters from the crossed edge and leaves by the
edge the pointer left through, so it reads as one continuous pass.
Press: the pass starts early, half out by the far edge, and waits. */
dirout: { frame: function (s) {
var d = s.down || s.kdown, opp = { left: 'right', right: 'left', top: 'bottom', bottom: 'top' };
s.a.t = d ? 0.55 : s.inside ? 0 : 1;
var v = step(s.a, .16, .70), e = d ? opp[s.entry] : s.inside ? s.entry : s.exit;
var x = e === 'left' ? -v : e === 'right' ? v : 0;
var y = e === 'top' ? -v : e === 'bottom' ? v : 0;
s.q('.ink').style.transform = 'translate(' + (x * 101) + '%,' + (y * 101) + '%)';
} },
/* 43 Pointer tilt: a real 3D tilt that tracks the pointer, not a fixed one.
Press: the same tilt driven near twice as far, as if leaned on. */
ptilt: { frame: function (s) {
var d = s.down || s.kdown, g = d ? 1.9 : 1;
s.a.t = s.inside ? -s.ny * 11 * g : 0;
s.b.t = s.inside ? s.nx * 15 * g : 0;
s.el.style.transform = 'perspective(420px) rotateX(' + step(s.a, .20, .68).toFixed(2) + 'deg) rotateY(' + step(s.b, .20, .68).toFixed(2) + 'deg)';
} },
/* 44 Wobble tilt: the same tracking with a looser spring, so the plate
overshoots and rocks once before it settles under the pointer.
Press: the rocking is caught and the plate holds dead level. */
wobble: { frame: function (s) {
var d = s.down || s.kdown;
s.a.t = s.inside && !d ? -s.ny * 13 : 0;
s.b.t = s.inside && !d ? s.nx * 18 : 0;
s.el.style.transform = 'perspective(420px) rotateX(' + step(s.a, d ? .22 : .10, d ? .68 : .875).toFixed(2) + 'deg) rotateY(' + step(s.b, d ? .22 : .10, d ? .68 : .875).toFixed(2) + 'deg)';
} },
/* 45 Squash: the button stretches along the direction of travel and thins
across it, by pointer speed. Move slowly and nothing happens.
Press: the same stretch with the sign flipped, short along travel. */
squash: { frame: function (s) {
var d = s.down || s.kdown;
var sp2 = Math.min(Math.sqrt(s.vx * s.vx + s.vy * s.vy) / 22, 1);
s.a.t = d ? -0.11 : s.inside ? sp2 * 0.26 : 0;
if (s.inside && sp2 > 0.06) s.ang = Math.atan2(s.vy, s.vx) * 180 / Math.PI;
var k = step(s.a, .22, .66);
s.el.style.transform = 'rotate(' + s.ang.toFixed(1) + 'deg) scale(' + (1 + k).toFixed(3) + ',' + (1 - k * 0.62).toFixed(3) + ') rotate(' + (-s.ang).toFixed(1) + 'deg)';
} },
/* 46 Label lag: the label is dragged along behind the pointer and catches up
when it stops, like something heavy on a short tether.
Press: the tether goes taut and the label catches up all the way. */
lag: { frame: function (s) {
var d = s.down || s.kdown, g = d ? 1 : 0.5;
s.a.t = s.inside ? clamp(s.lx - W / 2, -30, 30) * g : 0;
s.b.t = s.inside ? clamp(s.ly - H / 2, -14, 14) * g : 0;
s.q('.label').style.transform = 'translate(' + px(step(s.a, .055, .84)) + ',' + px(step(s.b, .055, .84)) + ')';
} },
/* 47 Edge bulge: the border edge nearest the pointer bows away from it, as
if the rule were elastic and the pointer were pressing on it.
Press: the same bow driven deeper and over a wider span of the edge. */
bulge: { svg: 1, frame: function (s) {
var t = [0, 0, 0, 0], dn = s.down || s.kdown;
if (s.inside || dn) {
var d = [s.ly, W - s.lx, H - s.ly, s.lx]; /* distance to each edge */
var m = 0, mi = 0, fall = dn ? 70 : 26, amp = dn ? 26 : 16;
for (var i = 0; i < 4; i++) { var w = Math.max(0, 1 - d[i] / fall); if (w > m) { m = w; mi = i; } }
t[mi] = m * amp; /* positive = away from the pointer */
}
for (var j = 0; j < 4; j++) { s.o[j].t = t[j]; step(s.o[j], .22, .66); }
s.q('path').setAttribute('d', edgePath([s.o[0].v, s.o[1].v, s.o[2].v, s.o[3].v]));
} },
/* 48 Elastic frame: every edge is slack; all four lean toward the pointer at
once, weighted by distance, so the whole outline leans with it.
Press: the lean keeps its bias and every edge gathers inward. */
cloth: { svg: 1, frame: function (s) {
var t = [0, 0, 0, 0];
if (s.inside) {
var cx = [W / 2, W - I, W / 2, I], cy = [I, H / 2, H - I, H / 2];
var nrm = [[0, -1], [1, 0], [0, 1], [-1, 0]];
for (var i = 0; i < 4; i++) {
var ax = s.lx - cx[i], ay = s.ly - cy[i];
var dist = Math.sqrt(ax * ax + ay * ay);
t[i] = (ax * nrm[i][0] + ay * nrm[i][1]) / Math.max(dist, 1) * Math.max(0, 1 - dist / 90) * 13;
}
}
if (s.down || s.kdown) for (var n = 0; n < 4; n++) t[n] = t[n] * 0.4 - 11; /* press gathers every edge inward */
for (var j = 0; j < 4; j++) { s.o[j].t = t[j]; step(s.o[j], .12, .78); }
s.q('path').setAttribute('d', edgePath([s.o[0].v, s.o[1].v, s.o[2].v, s.o[3].v]));
} },
/* 49 Speed tracking: letter-spacing opens with pointer speed and closes
again the moment the pointer rests.
Press: the same track run below base, the letters packed tight. */
speed: { frame: function (s) {
var v = Math.min(Math.sqrt(s.vx * s.vx + s.vy * s.vy) / 30, 1);
s.a.t = (s.down || s.kdown) ? 0 : s.inside ? 0.08 + v * 0.26 : 0.08;
s.q('.label').style.letterSpacing = step(s.a, .14, .74).toFixed(3) + 'em';
}, init: function (s) { s.a.v = s.a.t = 0.08; } },
/* 50 Press ripple: the ring starts at the point pressed, not at the centre,
and always runs its expansion out rather than retracting on release. */
ripple: { frame: function (s) {
var r = s.q('.ink');
if (s.rip >= 1) { r.style.visibility = 'hidden'; return; }
s.rip = Math.min(1, s.rip + 0.038);
var e = 1 - Math.pow(1 - s.rip, 3); /* ease out, one pass */
r.style.visibility = 'visible';
r.style.left = px(s.px_); r.style.top = px(s.py_);
r.style.transform = 'translate(-50%,-50%) scale(' + (e * 2.4).toFixed(3) + ')';
}, down: function (s) { s.rip = 0; s.px_ = s.lx; s.py_ = s.ly; } },
/* 51 Proximity wake: the rule thickens as the pointer approaches, before
any hover has happened, and thins again as it leaves. */
awake: { reach: 150, frame: function (s) {
s.a.t = s.near;
var v = step(s.a, .16, .74);
s.el.style.setProperty('--near', v.toFixed(3));
} },
/* 52 Cast shadow: the pointer is the light; a hard shadow falls on the far
side and shortens as the pointer comes closer.
Press: the light crosses over and the shadow swings to the near side. */
cast: { reach: 170, frame: function (s) {
var d = Math.max(Math.sqrt(s.dx * s.dx + s.dy * s.dy), 1), g = (s.down || s.kdown) ? 1 : -1;
s.a.t = g * s.dx / d * 12 * s.near;
s.b.t = g * s.dy / d * 12 * s.near;
s.el.style.boxShadow = px(step(s.a, .18, .72)) + ' ' + px(step(s.b, .18, .72)) + ' 0 0 #000';
} },
/* 53 Label repel: the frame stays, the label shies away from the pointer.
Press: the shy goes the whole way, the label jammed to the far edge. */
repel: { reach: 60, frame: function (s) {
var d = s.down || s.kdown;
s.a.t = -clamp(s.dx * (d ? 0.9 : 0.18), d ? -28 : -14, d ? 28 : 14) * s.near;
s.b.t = -clamp(s.dy * (d ? 0.9 : 0.18), d ? -13 : -8, d ? 13 : 8) * s.near;
s.q('.label').style.transform = 'translate(' + px(step(s.a, .14, .76)) + ',' + px(step(s.b, .14, .76)) + ')';
} },
/* 54 Lean: a 2D skew toward the pointer, the top edge leading.
Press: the same skew on the other sign, the top edge falling back. */
lean: { frame: function (s) {
var d = s.down || s.kdown, sg = s.nx >= 0 ? 1 : -1;
s.a.t = d ? sg * Math.max(Math.abs(s.nx) * 14, 6) : s.inside ? -s.nx * 12 : 0;
s.el.style.transform = 'skewX(' + step(s.a, .16, .72).toFixed(2) + 'deg)';
} },
/* 55 Sway: rotates against the direction of travel, like a body leaning
into a turn, and rights itself when the pointer stops.
Press: the lean of the turn is held at full instead of righting. */
sway: { frame: function (s) {
if (s.inside && Math.abs(s.vx) > 1.2) s.sgn = s.vx > 0 ? 1 : -1;
s.a.t = (s.down || s.kdown) ? -6 * (s.sgn || 1) : s.inside ? clamp(-s.vx * 0.45, -8, 8) : 0;
s.el.style.transform = 'rotate(' + step(s.a, .12, .80).toFixed(2) + 'deg)';
} },
/* 56 Slinky: each letter follows the pointer on a looser spring than the
one before it, so the word stretches out under motion and regathers. */
slinky: { frame: function (s) {
var d = s.down || s.kdown, n = s.letters.length;
var t = (s.inside || d) ? clamp((s.lx - W / 2) * 0.3, -18, 18) : 0;
for (var i = 0; i < n; i++) {
s.m[i].t = !d ? t : s.inside ? clamp(t * (0.2 + i * 0.5), -30, 30) /* held: the looseness is held open */
: (i - (n - 1) / 2) * 5; /* a keyboard press fans the word about its middle */
s.letters[i].style.transform = 'translateX(' + px(step(s.m[i], .30 - i * .04, .70)) + ')';
}
}, init: function (s) { s.letters = s.el.querySelectorAll('.label i'); } },
/* 57 Dimple: the button shrinks a little, pivoting on the pointer itself. */
dimple: { frame: function (s) {
var d = s.down || s.kdown;
s.a.t = d ? 0.90 : s.inside ? 0.95 : 1;
s.b.t = d ? s.pxp : s.lx; s.c.t = d ? s.pyp : s.ly;
s.el.style.transformOrigin = px(step(s.b, .25, .65)) + ' ' + px(step(s.c, .25, .65));
s.el.style.transform = 'scale(' + step(s.a, .18, .70).toFixed(4) + ')';
}, init: function (s) { s.a.v = s.a.t = 1; s.b.v = W / 2; s.c.v = H / 2; }, down: ppt, keydown: ppt },
/* 58 Fill to pointer: the fill comes in from the entry edge and stops at
the pointer, so it tracks back and forth as the pointer does. */
reach: { frame: function (s) {
var e = s.entry, ink = s.q('.ink');
var horiz = e === 'left' || e === 'right';
var d = s.down || s.kdown;
s.a.t = d ? (e === 'left' ? W : e === 'right' ? 0 : e === 'top' ? H : 0) /* held: the fill runs on to the far edge */
: s.inside ? (horiz ? s.lx : s.ly) : (e === 'left' ? 0 : e === 'right' ? W : e === 'top' ? 0 : H);
var f = step(s.a, .20, .68);
ink.style.clipPath = e === 'left' ? 'inset(0 ' + px(W - f) + ' 0 0)' : e === 'right' ? 'inset(0 0 0 ' + px(f) + ')' :
e === 'top' ? 'inset(0 0 ' + px(H - f) + ' 0)' : 'inset(' + px(f) + ' 0 0 0)';
}, enter: function (s) { s.a.v = s.entry === 'right' ? W : s.entry === 'bottom' ? H : 0; s.a.vel = 0; } },
/* 59 Angle wipe: a straight edge sweeps across along the true angle of
entry, not the nearest of four sides, and leaves along the exit angle. */
angle: { frame: function (s) {
var d = s.down || s.kdown;
s.a.t = d ? -260 : s.inside ? 0 : 260; /* held: the edge carries on out the far side */
var th = (s.inside || d) ? s.eth : s.xth;
s.q('.ink').style.transform = 'translate(-50%,-50%) rotate(' + (th * 180 / Math.PI).toFixed(1) + 'deg) translateX(' + px(step(s.a, .14, .72)) + ')';
}, init: function (s) { s.a.v = s.a.t = 260; }, enter: function (s) { s.a.v = 260; s.a.vel = 0; } },
/* 60 Momentum: the same edge fill as 41, but it fills at the speed the
pointer arrived. Enter slowly and it creeps; flick in and it slams. */
momentum: { frame: function (s) {
var d = s.down || s.kdown;
s.a.t = (s.inside && !d) ? 0 : 1; /* held: it leaves the way it came */
var k = (s.inside || d) ? 0.05 + Math.min(s.espd / 30, 1) * 0.25 : 0.16;
var v = step(s.a, k, .70), e = s.entry;
var x = e === 'left' ? -v : e === 'right' ? v : 0;
var y = e === 'top' ? -v : e === 'bottom' ? v : 0;
s.q('.ink').style.transform = 'translate(' + (x * 101) + '%,' + (y * 101) + '%)';
}, init: function (s) { s.a.v = s.a.t = 1; } },
/* 61 Pinhole: the button goes black and a white hole rides under the
pointer; on exit the hole swallows the black. */
pinhole: { frame: function (s) {
var ink = s.q('.ink'), hole = s.q('.hole');
var d = s.down || s.kdown;
if ((s.inside || d) && !s.pin) { s.pin = 1; s.a.v = 0; s.a.vel = 0; }
s.a.t = d ? 7 : s.inside ? 22 : 220; /* held: the hole closes to a pinprick */
s.b.t = d ? s.pxp : s.lx; s.c.t = d ? s.pyp : s.ly;
var r = step(s.a, .14, .72);
if (!s.inside && !d && r > 200) { s.pin = 0; ink.style.visibility = 'hidden'; hole.style.transform = 'scale(0)'; return; }
ink.style.visibility = 'visible';
hole.style.transform = 'translate(' + px(step(s.b, .30, .60) - 22) + ',' + px(step(s.c, .30, .60) - 22) + ') scale(' + (r / 22).toFixed(3) + ')';
}, down: ppt, keydown: ppt },
/* 62 Reticle: a hollow ring rides under the pointer. */
reticle: { frame: function (s) {
var d = s.down || s.kdown, ink = s.q('.ink');
s.a.t = d ? s.pxp : s.lx; s.b.t = d ? s.pyp : s.ly; s.c.t = (s.inside || d) ? 1 : 0;
s.x[0].t = d ? 1 : 0; /* held: the ring fills in from its own edge */
var f = Math.max(0, step(s.x[0], .16, .70)) * 17;
ink.style.boxShadow = f > .05 ? 'inset 0 0 0 ' + px(f) + ' #000' : '';
ink.style.transform = 'translate(' + px(step(s.a, .30, .60) - 18) + ',' + px(step(s.b, .30, .60) - 18) + ') scale(' + step(s.c, .18, .70).toFixed(3) + ')';
}, init: function (s) { s.x.push(sp()); }, down: ppt, keydown: ppt },
/* 63 Crosshair: a rule through the pointer on each axis, growing out from
the pointer itself. */
xhair: { frame: function (s) {
var d = s.down || s.kdown;
s.a.t = d ? s.pxp : s.lx; s.b.t = d ? s.pyp : s.ly; s.c.t = (s.inside || d) ? 1 : 0;
s.x[0].t = d ? 1 : 0; /* held: the arms run back into the press point */
var x = step(s.a, .30, .60), y = step(s.b, .30, .60), g = step(s.c, .16, .72);
var k = g * (1 - 0.85 * clamp(step(s.x[0], .16, .70), 0, 1));
var hx = s.q('.hx'), vy = s.q('.vy');
hx.style.transformOrigin = px(x) + ' 50%'; hx.style.transform = 'translateY(' + px(y - 1) + ') scaleX(' + k.toFixed(3) + ')';
vy.style.transformOrigin = '50% ' + px(y); vy.style.transform = 'translateX(' + px(x - 1) + ') scaleY(' + k.toFixed(3) + ')';
}, init: function (s) { s.x.push(sp()); }, down: ppt, keydown: ppt },
/* 64 Scanner bar: a full-height bar tracks the pointer sideways. */
scan: { frame: function (s) {
var d = s.down || s.kdown;
s.a.t = d ? s.pxp : s.lx; s.c.t = (s.inside || d) ? 1 : 0;
s.x[0].t = d ? 1 : 0; /* held: the bar covers everything it was sampling */
var w = 1 + 15 * Math.max(0, step(s.x[0], .16, .62));
s.q('.ink').style.transform = 'translateX(' + px(step(s.a, .24, .64) - 10) + ') scaleY(' + step(s.c, .18, .70).toFixed(3) + ') scaleX(' + w.toFixed(3) + ')';
}, init: function (s) { s.x.push(sp()); }, down: ppt, keydown: ppt },
/* 65 Comet: four discs follow the pointer on progressively looser
springs, so a fast pointer draws a tail and a still one a single dot. */
comet: { frame: function (s) {
var d = s.down || s.kdown;
s.c.t = (s.inside || d) ? 1 : 0;
s.x[0].t = d ? 1 : 0; /* held: the tail motion drew is laid out and kept */
var g = step(s.c, .18, .70), K = [.34, .22, .14, .09], R = [20, 14, 9, 5];
var tl = Math.max(0, step(s.x[0], .12, .70)) * 14, ax = Math.cos(s.eth) * tl, ay = Math.sin(s.eth) * tl;
for (var i = 0; i < 4; i++) {
s.m[i].t = (d ? s.pxp : s.lx) + ax * i; s.m[i + 4].t = (d ? s.pyp : s.ly) + ay * i;
s.dots[i].style.transform = 'translate(' + px(step(s.m[i], K[i], .64) - R[i]) + ',' + px(step(s.m[i + 4], K[i], .64) - R[i]) + ') scale(' + g.toFixed(3) + ')';
}
}, init: function (s) { s.dots = s.el.querySelectorAll('.dot'); s.x.push(sp()); }, down: ppt, keydown: ppt },
/* 66 Dwell bloom: a disc under the pointer grows while the pointer rests
and shrinks while it moves, so the fill is a measure of hesitation. */
dwell: { frame: function (s) {
var d = s.down || s.kdown;
if (!d) { if (s.inside) s.dw = clamp(s.dw + (s.spd < 1.5 ? 2.4 : -5), 0, 150); else s.dw = 0; }
s.a.t = d ? 26 : s.dw; /* held: the hesitation collapses onto the point decided on */
s.b.t = d ? s.pxp : s.lx; s.c.t = d ? s.pyp : s.ly;
s.q('.ink').style.clipPath = 'circle(' + Math.max(0, step(s.a, .10, .74)).toFixed(1) + 'px at ' + px(step(s.b, .25, .65)) + ' ' + px(step(s.c, .25, .65)) + ')';
}, init: function (s) { s.dw = 0; }, down: ppt, keydown: ppt },
/* 67 Corner pull: the corner nearest the pointer reaches toward it, from
outside as the pointer approaches and from inside once it is over. */
cpull: { reach: 60, svg: 'corner', frame: function (s) {
var cx = [I, W - I, W - I, I], cy = [I, I, H - I, H - I], c = [];
var hd = s.down || s.kdown, tx = hd ? s.pxp : s.lxr, ty = hd ? s.pyp : s.lyr;
for (var i = 0; i < 4; i++) {
var ax = tx - cx[i], ay = ty - cy[i], d = Math.sqrt(ax * ax + ay * ay);
var w = hd ? Math.min(d * 0.45, 18) : s.near ? Math.max(0, 1 - d / 80) * 18 : 0; /* held: every corner comes, not just the near one */
s.m[i].t = d ? ax / d * w : 0; s.m[i + 4].t = d ? ay / d * w : 0;
c.push([step(s.m[i], .16, .72), step(s.m[i + 4], .16, .72)]);
}
s.q('path').setAttribute('d', cornerPath(c));
}, down: ppt, keydown: ppt },
/* 68 Push in: approach from outside and the edge you are nearing dents
inward under the pressure; cross it and the dent releases. */
push: { reach: 70, svg: 1, frame: function (s) {
var t = [0, 0, 0, 0], i;
if (s.down || s.kdown) { /* held: the dent comes back, deeper, from the inside */
var ds = [s.pyp, W - s.pxp, H - s.pyp, s.pxp], mi = 0;
for (i = 1; i < 4; i++) if (ds[i] < ds[mi]) mi = i;
t[mi] = -18;
} else if (!s.inside && s.near) {
i = s.oside === 'top' ? 0 : s.oside === 'right' ? 1 : s.oside === 'bottom' ? 2 : 3;
t[i] = -s.near * 12;
}
for (var j = 0; j < 4; j++) { s.o[j].t = t[j]; step(s.o[j], .18, .70); }
s.q('path').setAttribute('d', edgePath([s.o[0].v, s.o[1].v, s.o[2].v, s.o[3].v]));
}, down: ppt, keydown: ppt },
/* 69 Gap follows: a gap opens in the border on the side facing the
pointer and slides round the perimeter to keep facing it. */
gap: { reach: 100, frame: function (s) {
aim(s.a, Math.atan2(s.dy, s.dx));
s.b.t = (s.down || s.kdown) ? 130 : s.near * 28; /* held: the gap opens until the rule is two arcs */
var th = step(s.a, .14, .74), g = Math.max(0, step(s.b, .16, .72));
var pos = rimPos(rim(th)), rect = s.q('rect');
rect.style.strokeDasharray = '0 ' + g.toFixed(2) + ' ' + (PERIM - g).toFixed(2);
rect.style.strokeDashoffset = (-(pos - g / 2)).toFixed(2);
} },
/* 70 Bead: a bead sits on the border at the point nearest the pointer
and slides round to stay there. */
bead: { reach: 100, frame: function (s) {
var d = s.down || s.kdown;
aim(s.a, Math.atan2(s.dy, s.dx));
s.b.t = d ? 14 : s.near * 5; /* held: the bead swells into a stud on the rule */
var p = rim(step(s.a, .14, .74)), c = s.q('circle');
c.setAttribute('cx', p[0].toFixed(2)); c.setAttribute('cy', p[1].toFixed(2));
c.setAttribute('r', Math.max(0, step(s.b, .16, .72)).toFixed(2));
} },
/* 71 Swell: grows as the pointer approaches, by distance, before hover. */
swell: { reach: 120, frame: function (s) {
s.a.t = (s.down || s.kdown) ? 0.94 : 1 + s.near * 0.06; /* held: the swell runs back the other way */
s.el.style.transform = 'scale(' + step(s.a, .14, .74).toFixed(4) + ')';
}, init: function (s) { s.a.v = s.a.t = 1; } },
/* 72 Rise to meet: lifts off a hard shadow as the pointer approaches, and
lands the moment the pointer arrives. */
rise: { reach: 120, frame: function (s) {
s.a.t = (s.down || s.kdown) ? -1 : s.inside ? 0 : s.near; /* held: it goes below the plane, shadow overhead */
var v = step(s.a, .14, .74);
s.el.style.transform = 'translateY(' + px(-8 * v) + ')';
s.el.style.boxShadow = '0 ' + px(8 * v) + ' 0 0 #000';
} },
/* ---- variable font: Inter wght 100..900 and opsz 14..32, plus per-letter transforms ---- */
/* 73 Bolden: the whole label gains weight as the pointer approaches.
Press flips the axis: the weight carries on past the base, out to the
thin end, and comes back to the hover weight on release. */
bolden: { reach: 120, frame: function (s) {
s.a.t = (s.down || s.kdown) ? 300 : 600 + 300 * s.near;
s.q('.label').style.fontWeight = Math.round(step(s.a, .14, .74));
}, init: function (s) { s.a.v = s.a.t = 600; } },
/* 74 Tracking by x: letter-spacing follows the pointer across the button,
tight at the left edge and wide at the right. Press runs the track on
past the pointer, wider than the right edge ever gives. */
xtrack: { frame: function (s) {
s.a.t = (s.down || s.kdown) ? 0.34 : s.inside ? 0.02 + (s.lx / W) * 0.28 : 0.08;
s.q('.label').style.letterSpacing = step(s.a, .16, .72).toFixed(3) + 'em';
}, init: function (s) { s.a.v = s.a.t = 0.08; } },
/* 75 Weight by x: thin at the left edge, black at the right. Press
finishes the ramp: the label commits to the end it was heading for. */
weightx: { frame: function (s) {
var d = s.down || s.kdown, x = s.inside ? s.lx : W / 2;
s.a.t = d ? (x >= W / 2 ? 900 : 300) : s.inside ? 300 + (s.lx / W) * 600 : 600;
s.q('.label').style.fontWeight = Math.round(step(s.a, .16, .72));
}, init: function (s) { s.a.v = s.a.t = 600; } },
/* 76 Weight keys: the letter under the pointer goes bold, its neighbours
less so, on a bell curve, like keys under a hand. Press reverses the
peak alone: the key taken down goes thin inside the bold shoulders. */
wkeys: { frame: function (s) {
if (!s.cxs) return;
var d = s.down || s.kdown, x = s.inside ? s.lx : W / 2;
for (var i = 0; i < 6; i++) {
var g = s.cxs[i] - x;
s.m[i].t = d ? 600 + 300 * bell(g, 16) - 600 * bell(g, 7) : s.inside ? 600 + 300 * bell(g, 16) : 600;
s.letters[i].style.fontWeight = Math.round(step(s.m[i], .22, .68));
}
}, init: function (s) { letters(s); seed(s.m, 600); }, enter: centres,
keydown: function (s) { if (!s.cxs) centres(s); } },
/* 77 Dock: the letter under the pointer grows from its baseline, the
neighbours a little, the way a dock magnifies. Press resolves the
magnification onto one letter: the bell narrows and the rest sit down. */
dock: { frame: function (s) {
if (!s.cxs) return;
var d = s.down || s.kdown, x = s.inside ? s.lx : W / 2;
for (var i = 0; i < 6; i++) {
var g = s.cxs[i] - x;
s.m[i].t = d ? 1 + 0.75 * bell(g, 6) : s.inside ? 1 + 0.6 * bell(g, 18) : 1;
s.letters[i].style.transform = 'scale(' + step(s.m[i], .22, .68).toFixed(3) + ')';
}
}, init: function (s) { letters(s); seed(s.m, 1); }, enter: centres,
keydown: function (s) { if (!s.cxs) centres(s); } },
/* 78 Keys press: the letter under the pointer sinks like a pressed key.
Press takes the same key the rest of the way down to its bottom. */
keys: { frame: function (s) {
if (!s.cxs) return;
var d = s.down || s.kdown, x = s.inside ? s.lx : W / 2;
for (var i = 0; i < 6; i++) {
var g = bell(s.cxs[i] - x, 16);
s.m[i].t = d ? 10 * g : s.inside ? 5 * g : 0;
s.letters[i].style.transform = 'translateY(' + px(step(s.m[i], .24, .66)) + ')';
}
}, init: function (s) { letters(s); }, enter: centres,
keydown: function (s) { if (!s.cxs) centres(s); } },
/* 79 Weight ripple: on hover every letter heads for bold, each on a
looser spring than the one before, starting from the side you entered,
so the weight travels through the word. Press sends a thinning wave
back the other way, from the far side to the side you came in. */
wripple: { frame: function (s) {
var d = s.down || s.kdown, back = (s.entry === 'right') !== !!d;
for (var i = 0; i < 6; i++) {
var o = back ? 5 - i : i;
s.m[i].t = d ? 400 : s.inside ? 900 : 600;
s.letters[i].style.fontWeight = Math.round(step(s.m[i], .26 - o * .038, .70));
}
}, init: function (s) { letters(s); seed(s.m, 600); } },
/* 80 Optical size: Inter's opsz axis, 14 (text) to 32 (display), by
distance. Subtle: tighter apertures and spacing as the pointer nears.
Press gives most of it back, down to 18, and the display cut returns
on release. */
opsz: { reach: 120, frame: function (s) {
s.a.t = (s.down || s.kdown) ? 18 : 14 + 18 * s.near;
s.q('.label').style.fontVariationSettings = '"opsz" ' + step(s.a, .14, .74).toFixed(2);
}, init: function (s) { s.a.v = s.a.t = 14; } },
/* 81 Face the pointer: each letter turns toward the pointer, the way a
row of heads follows someone walking past. Press flips every angle:
the same heads turn away by the same amount. */
face: { frame: function (s) {
if (!s.cxs) return;
var d = s.down || s.kdown, x = s.inside ? s.lx : W / 2;
for (var i = 0; i < 6; i++) {
var g = clamp((x - s.cxs[i]) * 0.5, -24, 24);
s.m[i].t = d ? -g : s.inside ? g : 0;
s.letters[i].style.transform = 'rotate(' + step(s.m[i], .18, .70).toFixed(2) + 'deg)';
}
}, init: function (s) { letters(s); }, enter: centres,
keydown: function (s) { if (!s.cxs) centres(s); } },
/* 82 Part: letters near the pointer push apart to make room for it.
Press finishes the parting: the bell goes flat and the word opens into
two blocks with the press point in the gap. */
part: { frame: function (s) {
if (!s.cxs) return;
var d = s.down || s.kdown, x = s.inside ? s.lx : W / 2;
for (var i = 0; i < 6; i++) {
var g = s.cxs[i] - x, sg = g < 0 ? -1 : 1;
s.m[i].t = d ? sg * 20 : s.inside ? sg * 10 * bell(g, 60) : 0; /* wide bell: a gap opens, neighbours do not crowd */
s.letters[i].style.transform = 'translateX(' + px(step(s.m[i], .20, .70)) + ')';
}
}, init: function (s) { letters(s); }, enter: centres,
keydown: function (s) { if (!s.cxs) centres(s); } },
/* 83 Weight by speed: bolder the faster the pointer moves, back to the
base weight the moment it stops. Press pays the same weight for a
pointer that has stopped: holding stands in for moving. */
wspeed: { frame: function (s) {
s.a.t = (s.down || s.kdown) ? 900 : s.inside ? 600 + Math.min(s.spd / 30, 1) * 300 : 600;
s.q('.label').style.fontWeight = Math.round(step(s.a, .14, .74));
}, init: function (s) { s.a.v = s.a.t = 600; } },
/* 84 Weight by dwell: bolder the longer the pointer rests on it. Press
mirrors the dwell about the base weight: a long wait is spent back
down to 600, and a press with no wait behind it takes the bold at once. */
wdwell: { frame: function (s) {
if (s.inside) s.dw = clamp(s.dw + (s.spd < 1.5 ? 0.016 : -0.04), 0, 1); else s.dw = 0;
s.a.t = 600 + 300 * ((s.down || s.kdown) ? 1 - s.dw : s.dw);
s.q('.label').style.fontWeight = Math.round(step(s.a, .10, .74));
}, init: function (s) { s.a.v = s.a.t = 600; s.dw = 0; } },
/* 85 Grass: letters lean away from the pointer, most where it is
nearest, like grass parting round a foot. Press leans them further and
wider, so the blades at the edge of the word go over too. */
grass: { frame: function (s) {
if (!s.cxs) return;
var d = s.down || s.kdown, x = s.inside ? s.lx : W / 2;
for (var i = 0; i < 6; i++) {
var g = s.cxs[i] - x, sg = g < 0 ? 1 : -1;
s.m[i].t = d ? sg * 28 * bell(g, 40) : s.inside ? sg * 18 * bell(g, 26) : 0;
s.letters[i].style.transform = 'skewX(' + step(s.m[i], .20, .70).toFixed(2) + 'deg)';
}
}, init: function (s) { letters(s); }, enter: centres,
keydown: function (s) { if (!s.cxs) centres(s); } },
/* 86 Weight holds width: bold on hover, but letter-spacing gives back
exactly what the heavier glyphs take, so the word does not grow. Press
drops the compensation and keeps the weight, so the bold word finally
takes the width it costs. */
holdw: { frame: function (s) {
var d = s.down || s.kdown, on = d || s.inside;
s.a.t = on ? 1 : 0; s.b.t = (on && !d) ? 1 : 0;
var t = step(s.a, .16, .72), c = step(s.b, .16, .72), lab = s.q('.label');
lab.style.fontWeight = Math.round(600 + 300 * t);
lab.style.letterSpacing = px(1.12 - (s.gain || 0) / 6 * c);
}, init: function (s) {
var lab = s.q('.label');
document.fonts.ready.then(function () {
var w6 = lab.getBoundingClientRect().width;
lab.style.fontWeight = 900; var w9 = lab.getBoundingClientRect().width;
lab.style.fontWeight = ''; s.gain = w9 - w6;
});
} },
/* ---- hatching and halftone: darkness as line or dot density, so the fill
is still only black on white. Grid dots and dither cells are built once
in init; every frame writes sizes, never colours. ---- */
/* 87 Hatch fill: parallel lines, perpendicular to the pointer, thicken
from nothing at the edge of reach to a solid fill under the pointer. */
hatch: { reach: 140, frame: function (s) {
var d = s.down || s.kdown;
aim(s.a, Math.atan2(s.dy, s.dx));
s.b.t = d ? PITCH : s.near * PITCH;
s.c.t = d ? 1 : 0;
var th = step(s.a, .14, .74) * 180 / Math.PI + 90, t = clamp(step(s.b, .16, .72), 0, PITCH);
/* press: the solid fill breaks back into an open hatch at half the pitch,
a mid-ramp of its own hover held; a solid core keeps the label legible */
var p = clamp(step(s.c, .18, .62), 0, 1), P = PITCH * (1 - .5 * p), t2 = Math.min(t, P) * (1 - .58 * p);
var st = s.q('.ink').style, core = p > .005;
st.backgroundImage = t2 < .05 && !core ? 'none' :
(core ? 'linear-gradient(#000,#000),' : '') +
'repeating-linear-gradient(' + th.toFixed(1) + 'deg,#000 0 ' + px(t2) + ',transparent ' + px(t2) + ' ' + P.toFixed(2) + 'px)';
st.backgroundRepeat = core ? 'no-repeat,repeat' : '';
st.backgroundPosition = core ? 'center,0 0' : '';
st.backgroundSize = core ? '106px 22px,auto' : '';
} },
/* 88 Halftone bloom: a grid of dots; each dot's radius follows a bell
centred on the pointer, so the dots under it merge into solid black
and the ones at the far end stay pinpricks. */
halftone: { reach: 60, frame: function (s) {
/* press: the bell pins to the press point and tightens onto it, so the
dots under the finger merge and the far end falls back to pinpricks */
var dn = s.down || s.kdown;
if (dn && !s.pinned) { s.pinned = 1; s.ppx = s.down ? s.lxr - 2 : W / 2; s.ppy = s.down ? s.lyr - 2 : H / 2; }
if (!dn) s.pinned = 0;
s.a.t = dn ? s.ppx : s.lxr - 2; s.b.t = dn ? s.ppy : s.lyr - 2; s.c.t = dn ? 1 : s.near;
s.x[0].t = dn ? 1 : 0;
var x = step(s.a, .28, .62), y = step(s.b, .28, .62), g = step(s.c, .16, .72);
var p = clamp(step(s.x[0], .16, .70), 0, 1), sig = 26 - 12 * p, amp = 5.8 + 2.4 * p;
for (var i = 0; i < s.dots.length; i++) {
var d = s.dots[i], ax = d.x - x, ay = d.y - y;
setR(d, g * amp * bell(Math.sqrt(ax * ax + ay * ay), sig));
}
}, init: function (s) {
var svg = s.q('svg'); s.dots = []; s.pinned = 0; s.ppx = W / 2; s.ppy = H / 2; s.x.push(sp());
for (var y = 6; y < H - 4; y += 8) for (var x = 6; x < W - 4; x += 8) s.dots.push(dot(svg, x, y));
} },
/* 89 Stipple bleed: ink soaks in from the entry point. The front is a
ragged band of dots that swell as they sit; 40px behind it the dots have
merged into solid ink, so the label is speckled only at the fringe. */
stipple: { frame: function (s) {
var dn = s.down || s.kdown;
if (s.inside || dn) s.fr = Math.min(s.fr + 2.2, 300); else s.fr = Math.max(0, s.fr - 11);
/* press: the soak runs backwards out of the press point. The core leaves
the entry point for the pressed point and shrinks to a disc, and the
dots around it come back off the page, so the ink is a fringe again. */
if (dn && !s.pinned) { s.pinned = 1; s.ppx = s.down ? s.lx : W / 2; s.ppy = s.down ? s.ly : H / 2; }
if (!dn) s.pinned = 0;
s.a.t = dn ? 1 : 0;
var p = clamp(step(s.a, .14, .62), 0, 1);
for (var i = 0; i < s.dots.length; i++) {
var d = s.dots[i], k = s.fr - d.soak;
setR(d, k <= 0 ? 0 : (0.9 + 1.6 * Math.min(k / 40, 1)) * (1 - .44 * p));
}
var cr = Math.max(0, s.fr - 44) * (1 - p) + 56 * p;
s.q('.ink').style.clipPath = 'circle(' + cr.toFixed(1) + 'px at ' + px(s.ex + (s.ppx - s.ex) * p) + ' ' + px(s.ey + (s.ppy - s.ey) * p) + ')';
}, init: function (s) {
var svg = s.q('svg'); s.dots = []; s.fr = 0; s.pinned = 0; s.ppx = W / 2; s.ppy = H / 2;
for (var i = 0; i < 260; i++) {
var d = dot(svg, 2 + rnd(i * 2) * (W - 8), 2 + rnd(i * 2 + 1) * (H - 8));
d.j = 0.7 + 0.6 * rnd(600 + i); s.dots.push(d);
}
}, enter: function (s) {
s.fr = 0;
for (var i = 0; i < s.dots.length; i++) {
var d = s.dots[i], ax = d.x - (s.ex - 2), ay = d.y - (s.ey - 2);
d.soak = Math.sqrt(ax * ax + ay * ay) * d.j;
}
} },
/* 90 Dither wipe: an ordered dither sweeps in from the entry edge. Sixteen
layers, one per Bayer threshold, each clipped a little further back than
the last, so the ramp between white and black is a dither, never a grey. */
dither: { frame: function (s) {
var e = s.entry, horiz = e === 'left' || e === 'right', w = W - 4, h = H - 4, span = horiz ? w : h;
var dn = s.down || s.kdown;
s.a.t = s.inside || dn ? span + RAMP : 0;
s.b.t = dn ? 1 : 0;
var f = step(s.a, .12, .74);
/* press: the top eight thresholds run their own ramp back out the way it
came, so the finished solid lifts to a half dither and the wipe is
legible again; a solid core under the label is held while they go */
var p = clamp(step(s.b, .14, .62), 0, 1), g = f - p * (span + RAMP);
for (var k = 0; k < 16; k++) {
var L = Math.round(clamp((k < 8 ? f : g) - k * RAMP / 16, 0, span) / 2) * 2, r = s.lay[k];
if (horiz) { r.setAttribute('width', L); r.setAttribute('x', e === 'left' ? 0 : w - L); }
else { r.setAttribute('height', L); r.setAttribute('y', e === 'top' ? 0 : h - L); }
}
if (p > .005) { s.core.setAttribute('width', 106); s.core.setAttribute('height', 22); }
else { s.core.setAttribute('width', 0); s.core.setAttribute('height', 0); }
}, init: function (s) {
var svg = s.q('svg'), defs = mk(svg, 'defs'), id = 'bayer' + (++UID); s.lay = [];
for (var k = 0; k < 16; k++) {
var p = mk(defs, 'pattern', { id: id + '-' + k, width: 8, height: 8, patternUnits: 'userSpaceOnUse' });
var i = BAYER.indexOf(k);
mk(p, 'rect', { x: (i % 4) * 2, y: Math.floor(i / 4) * 2, width: 2, height: 2 });
s.lay.push(mk(svg, 'rect', { x: 0, y: 0, width: 0, height: 0, fill: 'url(#' + id + '-' + k + ')' }));
}
s.core = mk(svg, 'rect', { x: 27, y: 13, width: 0, height: 0 });
}, enter: function (s) {
var horiz = s.entry === 'left' || s.entry === 'right';
s.a.v = 0; s.a.vel = 0;
for (var k = 0; k < 16; k++) { var r = s.lay[k]; r.setAttribute('x', 0); r.setAttribute('y', 0); r.setAttribute('width', horiz ? 0 : W - 4); r.setAttribute('height', horiz ? H - 4 : 0); }
} }
,
/* ---- 02-intent ---- */
/* Anticipation: the pointer's velocity, taken per frame from its unclamped
position, is extrapolated 150ms ahead; if the predicted point lands in
the box the fill blooms from that predicted landing point before the