wake-marquee 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.
package/src/motion.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The arithmetic behind a wake-marquee, with no DOM in sight.
3
+ *
4
+ * Everything here is a pure function of numbers. That is not tidiness for its
5
+ * own sake: the loop in `marquee.js` runs sixty times a second across every
6
+ * instance on the page, and the only way to know its maths is right is to be
7
+ * able to run it without a browser. Every function below has a unit test.
8
+ *
9
+ * @module wake-marquee/motion
10
+ */
11
+
12
+ /**
13
+ * @param {number} value
14
+ * @param {number} min
15
+ * @param {number} max
16
+ * @returns {number}
17
+ */
18
+ export function clamp(value, min, max) {
19
+ return value < min ? min : value > max ? max : value;
20
+ }
21
+
22
+ /**
23
+ * How many lanes it takes to cover the track without a gap, worst case.
24
+ *
25
+ * The shared transform on the lanes always sits in `[-period, 0)`, so lane 0
26
+ * covers everything left of the origin and the remaining lanes have to span
27
+ * the full track width. That is `ceil(trackWidth / period) + 1`, plus one
28
+ * spare so a sub-pixel rounding error at the right edge cannot open a seam.
29
+ *
30
+ * @param {number} trackWidth Width of the moving layer in px.
31
+ * @param {number} period Width of one lane in px, the loop's repeat distance.
32
+ * @param {number} [buffer=1] Extra lanes held back against rounding.
33
+ * @returns {number} Lane count, at least 1.
34
+ */
35
+ export function laneCount(trackWidth, period, buffer = 1) {
36
+ if (!(period > 0) || !Number.isFinite(trackWidth)) return 1;
37
+ return Math.ceil(trackWidth / period) + 1 + buffer;
38
+ }
39
+
40
+ /**
41
+ * Move the loop on by one frame and wrap it back into `[0, period)`.
42
+ *
43
+ * The wrap is a double modulo rather than a plain one, because JavaScript's
44
+ * `%` keeps the sign of the dividend: `-5 % 100` is `-5`, not `95`. A single
45
+ * modulo would therefore hand back a negative offset the moment the marquee
46
+ * runs backwards, and the lanes would jump a full period sideways.
47
+ *
48
+ * @param {number} offset Current offset in px.
49
+ * @param {number} velocity Signed speed in px per second.
50
+ * @param {number} dt Frame time in seconds.
51
+ * @param {number} period Loop repeat distance in px.
52
+ * @returns {number} Offset in `[0, period)`.
53
+ */
54
+ export function advance(offset, velocity, dt, period) {
55
+ if (!(period > 0)) return 0;
56
+ const next = offset + velocity * dt;
57
+ return ((next % period) + period) % period;
58
+ }
59
+
60
+ /**
61
+ * Ease the direction factor towards its target, framerate independently.
62
+ *
63
+ * A plain `current += (target - current) * 0.1` per frame is the usual lerp,
64
+ * and it is wrong here: it converges at whatever rate the display happens to
65
+ * refresh at, so the same reversal is twice as fast on a 120 Hz screen. The
66
+ * exponential form asks how much of the remaining distance should be closed
67
+ * over `dt` seconds, which is the same answer at any framerate.
68
+ *
69
+ * @param {number} current Direction factor in `[-1, 1]`.
70
+ * @param {number} target Where it is heading, usually -1, 0 or 1.
71
+ * @param {number} dt Frame time in seconds.
72
+ * @param {number} ease Convergence rate per second; higher snaps harder.
73
+ * @returns {number}
74
+ */
75
+ export function easeDirection(current, target, dt, ease) {
76
+ return current + (target - current) * (1 - Math.exp(-dt * ease));
77
+ }
78
+
79
+ /**
80
+ * How far through its own passage across the viewport an element is.
81
+ *
82
+ * 0 when its top edge is about to enter from below, 1 when its bottom edge
83
+ * has just left at the top. The denominator is `viewport + height` because
84
+ * that is the full distance the element travels while any part of it is on
85
+ * screen, which keeps a tall block and a thin one on the same scale.
86
+ *
87
+ * @param {number} top Element top relative to the viewport, in px.
88
+ * @param {number} height Element height in px.
89
+ * @param {number} viewport Viewport height in px.
90
+ * @returns {number} Progress in `[0, 1]`.
91
+ */
92
+ export function viewProgress(top, height, viewport) {
93
+ const travel = viewport + height;
94
+ if (!(travel > 0)) return 0;
95
+ return clamp((viewport - top) / travel, 0, 1);
96
+ }
97
+
98
+ /**
99
+ * The wake: how far the moving layer is pushed against its own direction of
100
+ * travel at a given point in the element's passage across the viewport.
101
+ *
102
+ * `1 - 2 * progress` runs from +1 to -1, so the layer is displaced one full
103
+ * amplitude one way as the element enters and the other way as it leaves,
104
+ * passing through zero at the centre. The result is bounded by `amplitude`,
105
+ * which is exactly the overhang the track is given on each side. That bound
106
+ * is the whole point: overshoot it and the audience sees the end of the row.
107
+ *
108
+ * @param {number} progress Passage progress in `[0, 1]`.
109
+ * @param {number} amplitude Maximum displacement in px.
110
+ * @param {number} dirSign Base travel direction, +1 right or -1 left.
111
+ * @returns {number} Displacement in px, within `[-amplitude, amplitude]`.
112
+ */
113
+ export function wakeOffset(progress, amplitude, dirSign) {
114
+ return -dirSign * amplitude * (1 - 2 * progress);
115
+ }
116
+
117
+ /**
118
+ * Clamp a frame delta to something a physics step can survive.
119
+ *
120
+ * A backgrounded tab, a long task or a breakpoint in the devtools all hand
121
+ * the next frame a delta measured in seconds. Integrating that would teleport
122
+ * the loop. Capping it means a stall costs a little drift, never a jump.
123
+ *
124
+ * @param {number} ms Milliseconds since the previous frame.
125
+ * @param {number} [max=0.1] Cap in seconds.
126
+ * @returns {number} Seconds, never negative, never above `max`.
127
+ */
128
+ export function frameDelta(ms, max = 0.1) {
129
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
130
+ return Math.min(ms / 1000, max);
131
+ }
@@ -0,0 +1,115 @@
1
+ /*! wake-marquee | MIT | https://github.com/robin-gogolok/wake-marquee */
2
+
3
+ /**
4
+ * Everything lives in one cascade layer, so your own unlayered CSS wins
5
+ * without reaching for !important. Declare the order yourself if you use
6
+ * layers too: @layer theme, wake-marquee, utilities;
7
+ */
8
+ @layer wake-marquee {
9
+ /**
10
+ * The container clips, and its box is the geometry the wake is measured
11
+ * against. It is a flex row before the script ever runs, which is what
12
+ * makes the unenhanced page look deliberate: one static row of items,
13
+ * cut off at the edge, rather than a stack.
14
+ */
15
+ [data-wake-marquee] {
16
+ display: flex;
17
+ position: relative;
18
+ overflow: hidden;
19
+ /* A flex or grid child refuses to shrink below its content without this,
20
+ and a marquee is all content. Left out, the row pushes the page wider
21
+ instead of clipping. */
22
+ min-width: 0;
23
+ }
24
+
25
+ /**
26
+ * `clip` rather than `hidden` where it is available. `hidden` makes the
27
+ * element a scroll container, so a stray focus inside it can scroll the row
28
+ * sideways and strand it mid-loop; `clip` cannot be scrolled at all. It also
29
+ * lets shadows and hover lifts overflow vertically, which `hidden` would cut.
30
+ */
31
+ @supports (overflow: clip) {
32
+ [data-wake-marquee] {
33
+ overflow-x: clip;
34
+ overflow-y: visible;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Items, before the script runs and after. The spacing sits inside the item
40
+ * as a margin rather than as `gap` on the row, and that is load-bearing: the
41
+ * lane's width is the loop's repeat distance, so the space after the last
42
+ * item has to be part of the lane. With `gap` there is no space between the
43
+ * last item and the first item of the next lane, and the seam is visible on
44
+ * every pass.
45
+ *
46
+ * A margin, not padding, because an item with a background or a border would
47
+ * otherwise render the spacing as part of itself.
48
+ */
49
+ [data-wake-marquee] > :not(.wake-track),
50
+ .wake-lane > * {
51
+ flex: none;
52
+ margin-inline-end: var(--wake-gap, 2rem);
53
+ }
54
+
55
+ /**
56
+ * The layer the scroll displaces. Its overhang and width are set by the
57
+ * script from the `wake` option: it is wider than the container by exactly
58
+ * the distance the wake can move it, so the row never runs out of content
59
+ * at either edge no matter where the scroll is.
60
+ */
61
+ .wake-track {
62
+ display: flex;
63
+ flex: none;
64
+ flex-wrap: nowrap;
65
+ width: 100%;
66
+ }
67
+
68
+ /**
69
+ * One repeat of the content. Neither shrinking nor wrapping is allowed:
70
+ * both would change the width, and the width *is* the loop period.
71
+ */
72
+ .wake-lane {
73
+ display: flex;
74
+ flex: none;
75
+ flex-wrap: nowrap;
76
+ align-items: center;
77
+ }
78
+
79
+ /**
80
+ * Promoted only while the row is actually on screen. `will-change` held
81
+ * permanently is a standing request for a compositor layer, and a page with
82
+ * eight marquees pays for eight of them whether or not any are in view.
83
+ */
84
+ [data-wake-active] > .wake-track {
85
+ will-change: transform;
86
+ }
87
+
88
+ [data-wake-fade] {
89
+ -webkit-mask-image: linear-gradient(
90
+ to right,
91
+ transparent,
92
+ #000 var(--wake-fade, 0px),
93
+ #000 calc(100% - var(--wake-fade, 0px)),
94
+ transparent
95
+ );
96
+ mask-image: linear-gradient(
97
+ to right,
98
+ transparent,
99
+ #000 var(--wake-fade, 0px),
100
+ #000 calc(100% - var(--wake-fade, 0px)),
101
+ transparent
102
+ );
103
+ }
104
+
105
+ /**
106
+ * The script stops itself under a reduced-motion preference and resets the
107
+ * transforms, so the row simply stands still. This only drops the layer
108
+ * promotion that would then be paid for and never used.
109
+ */
110
+ @media (prefers-reduced-motion: reduce) {
111
+ [data-wake-active] > .wake-track {
112
+ will-change: auto;
113
+ }
114
+ }
115
+ }