| 1 | /* light.js: the light. First-party, dependency-free, and deletable: |
| 2 | remove this file and its script tag and the page is the still depth |
| 3 | design, because every property here has a default the stylesheet |
| 4 | already carries. |
| 5 | |
| 6 | What it does: lights the page from where the sun is at this hour |
| 7 | (from the left in the morning, overhead at noon, from the right in |
| 8 | the evening); puts the pointer's position on the button under it, so |
| 9 | the button catches a sheen there; puts a press's position on the |
| 10 | button, for a mouse and for a touch, so the press darkens most where |
| 11 | it landed; and lets a card breathe: its rim brightens near the |
| 12 | pointer over about a second and fades when the pointer leaves. The |
| 13 | whole page's light can follow the pointer instead: set FOLLOW below |
| 14 | to true. It is off because every write of it restyles the page. |
| 15 | |
| 16 | What it writes: --rst-light-x and --rst-light-y on the root, from -1 |
| 17 | to 1; --rst-mx, --rst-my, --rst-px and --rst-py on a button, in the |
| 18 | button's own pixels; --rst-card-x, --rst-card-y and --rst-card-lit |
| 19 | on a card. Nothing else. It never writes on a switch, so a drag |
| 20 | rastrillo.js owns (data-rst-drag) never meets it. |
| 21 | |
| 22 | What it never does: capture the pointer, read a sensor (no ambient |
| 23 | light, no tilt: both need a permission or a flag), or run under |
| 24 | prefers-reduced-motion or forced colours, where it adds no listener |
| 25 | and clears what it wrote if the setting turns on mid-session. One |
| 26 | animation frame loop, running only while something is still easing; |
| 27 | the hour is read again every five minutes; the loop pauses while the |
| 28 | page is hidden. |
| 29 | |
| 30 | For a test: dispatch a CustomEvent "rst:light" on the document with |
| 31 | detail {x, y, hold}; hold keeps the light there against pointer |
| 32 | moves. */ |
| 33 | (function () { |
| 34 | "use strict"; |
| 35 | var root = document.documentElement; |
| 36 | // Without matchMedia, or with one that cannot report a change (Safari |
| 37 | // before 14), the reduced-motion and forced-colours checks cannot be |
| 38 | // made or kept, so the page keeps the still design rather than a |
| 39 | // light that ignores them. |
| 40 | if (!window.matchMedia || !window.matchMedia("all").addEventListener) return; |
| 41 | var reduce = matchMedia("(prefers-reduced-motion: reduce)"); |
| 42 | var forced = matchMedia("(forced-colors: active)"); |
| 43 | |
| 44 | // FOLLOW is C9's fallback: false drops the pointer-following root |
| 45 | // light for the hour-only light and keeps the per-element sheen. It |
| 46 | // is false because every root write restyles and repaints the whole |
| 47 | // document, about 12ms over 500 rows, and even at one write per |
| 48 | // WRITE_MS that put the CI runner at 4.15 to 4.20 ms a frame against |
| 49 | // a 4ms budget; hour-only measured 2.07 ms. The eased, rate-limited |
| 50 | // path below stays, so turning it back on is this one line |
| 51 | // (TestTheLightStaysWithinItsFrameBudget measures it). |
| 52 | var FOLLOW = false; |
| 53 | var WRITE_MS = 200; |
| 54 | var BTN = "[rst-btn],.rst-btn"; |
| 55 | var CARD = "[rst-box],.rst-box,[rst-card],.rst-card,[rst-stats],.rst-stats,[rst-list],.rst-list"; |
| 56 | // Time constants in ms: the light settles in about a third of a |
| 57 | // second, a card's spot follows at a walk, its brightness over about |
| 58 | // a second. Elapsed time, not a per-frame step, so 120 Hz is no faster. |
| 59 | var LIGHT_T = 125, CARD_T = 320, LIT_T = 400; |
| 60 | |
| 61 | var on = false, tx = 0, ty = -1, lx = 0, ly = -1, wx = 0, wy = -1; |
| 62 | var raf = 0, last = 0, pending = null, pointer = false, hold = false, force = false, wrote = 0; |
| 63 | var cards = new WeakMap(), live = new Set(), timers = new Set(), clock = 0; |
| 64 | |
| 65 | function sun() { |
| 66 | var d = new Date(), h = d.getHours() + d.getMinutes() / 60; |
| 67 | var t = Math.max(-1, Math.min(1, (h - 12) / 6)); |
| 68 | return [t * 0.8, -1 + Math.abs(t) * 0.35]; |
| 69 | } |
| 70 | function target() { |
| 71 | if (hold || (pointer && FOLLOW)) return; |
| 72 | var s = sun(); |
| 73 | tx = s[0]; |
| 74 | ty = s[1]; |
| 75 | } |
| 76 | // Every path that wants a frame comes through here, so a timer or a |
| 77 | // move that fires after stop() or while the page is hidden schedules |
| 78 | // nothing. |
| 79 | function go() { |
| 80 | if (!on || document.hidden) return; |
| 81 | if (!raf) raf = requestAnimationFrame(step); |
| 82 | } |
| 83 | function ease(v, to, dt, T) { |
| 84 | return v + (to - v) * (1 - Math.exp(-dt / T)); |
| 85 | } |
| 86 | function light(el, x, y) { |
| 87 | var o = cards.get(el); |
| 88 | if (!o) { o = { x: x, y: y, l: 0 }; cards.set(el, o); } |
| 89 | o.tx = x; o.ty = y; o.tl = 1; |
| 90 | live.add(el); |
| 91 | } |
| 92 | function dim(el) { |
| 93 | var o = cards.get(el); |
| 94 | if (o) { o.tl = 0; live.add(el); } |
| 95 | } |
| 96 | |
| 97 | // One loop. Every read (the pending move's rectangles) happens before |
| 98 | // any write, and it reschedules itself only while something is still |
| 99 | // easing. |
| 100 | function step(now) { |
| 101 | raf = 0; |
| 102 | var dt = last ? Math.min(100, now - last) : 16; |
| 103 | last = now; |
| 104 | var moving = false; |
| 105 | if (pending) { |
| 106 | var e = pending; |
| 107 | pending = null; |
| 108 | var b = e.target.closest && e.target.closest(BTN); |
| 109 | var c = e.target.closest && e.target.closest(CARD); |
| 110 | var br = b && b.getBoundingClientRect(); |
| 111 | var cr = c && c.getBoundingClientRect(); |
| 112 | if (b) { |
| 113 | b.style.setProperty("--rst-mx", (e.clientX - br.left).toFixed(0) + "px"); |
| 114 | b.style.setProperty("--rst-my", (e.clientY - br.top).toFixed(0) + "px"); |
| 115 | } |
| 116 | live.forEach(function (k) { if (k !== c) dim(k); }); |
| 117 | if (c) light(c, e.clientX - cr.left, e.clientY - cr.top); |
| 118 | } |
| 119 | if (!hold) { |
| 120 | lx = ease(lx, tx, dt, LIGHT_T); |
| 121 | ly = ease(ly, ty, dt, LIGHT_T); |
| 122 | } |
| 123 | if (force || ((Math.abs(lx - wx) > 0.01 || Math.abs(ly - wy) > 0.01) && now - wrote >= WRITE_MS)) { |
| 124 | force = false; |
| 125 | wx = lx; wy = ly; wrote = now; |
| 126 | root.style.setProperty("--rst-light-x", lx.toFixed(3)); |
| 127 | root.style.setProperty("--rst-light-y", ly.toFixed(3)); |
| 128 | } |
| 129 | if (Math.abs(tx - lx) > 0.002 || Math.abs(ty - ly) > 0.002) moving = true; |
| 130 | live.forEach(function (el) { |
| 131 | if (!el.isConnected) { live.delete(el); return; } |
| 132 | var o = cards.get(el); |
| 133 | o.x = ease(o.x, o.tx, dt, CARD_T); |
| 134 | o.y = ease(o.y, o.ty, dt, CARD_T); |
| 135 | o.l = ease(o.l, o.tl, dt, LIT_T); |
| 136 | var done = Math.abs(o.tx - o.x) < 0.5 && Math.abs(o.ty - o.y) < 0.5 && Math.abs(o.tl - o.l) < 0.004; |
| 137 | if (done) { o.x = o.tx; o.y = o.ty; o.l = o.tl; } |
| 138 | // A lit card that has settled is written once, not on every frame |
| 139 | // the root light moves: a card is a large paint. |
| 140 | if (!done || !o.still) { |
| 141 | el.style.setProperty("--rst-card-x", o.x.toFixed(1) + "px"); |
| 142 | el.style.setProperty("--rst-card-y", o.y.toFixed(1) + "px"); |
| 143 | el.style.setProperty("--rst-card-lit", o.l.toFixed(3)); |
| 144 | } |
| 145 | o.still = done; |
| 146 | if (!done) moving = true; else if (o.tl === 0) live.delete(el); |
| 147 | }); |
| 148 | if (moving) go(); else last = 0; |
| 149 | } |
| 150 | |
| 151 | function onMove(e) { |
| 152 | if (e.pointerType !== "mouse") return; |
| 153 | if (FOLLOW && !hold) { |
| 154 | pointer = true; |
| 155 | // The light sits at the pointer, a little above the page. |
| 156 | tx = Math.max(-1, Math.min(1, (e.clientX / innerWidth - 0.5) * 2)); |
| 157 | ty = Math.max(-1, Math.min(1, (e.clientY / innerHeight - 0.5) * 2)) * 0.6 - 0.4; |
| 158 | } |
| 159 | pending = e; |
| 160 | go(); |
| 161 | } |
| 162 | // The press darkens most where it landed, for mouse and touch alike; |
| 163 | // a touch leaves pointer mode, and a tapped card catches the light |
| 164 | // briefly. Both rectangles are read before either element is written. |
| 165 | function onDown(e) { |
| 166 | var b = e.target.closest && e.target.closest(BTN); |
| 167 | var c = e.pointerType !== "mouse" && e.target.closest && e.target.closest(CARD); |
| 168 | var br = b && b.getBoundingClientRect(); |
| 169 | var cr = c && c.getBoundingClientRect(); |
| 170 | if (b) { |
| 171 | b.style.setProperty("--rst-px", (e.clientX - br.left).toFixed(0) + "px"); |
| 172 | b.style.setProperty("--rst-py", (e.clientY - br.top).toFixed(0) + "px"); |
| 173 | } |
| 174 | if (e.pointerType === "mouse") return; |
| 175 | leavePointer(); |
| 176 | if (c) { |
| 177 | light(c, e.clientX - cr.left, e.clientY - cr.top); |
| 178 | var id = setTimeout(function () { timers.delete(id); dim(c); go(); }, 1400); |
| 179 | timers.add(id); |
| 180 | go(); |
| 181 | } |
| 182 | } |
| 183 | function leavePointer() { |
| 184 | pointer = false; |
| 185 | target(); |
| 186 | go(); |
| 187 | } |
| 188 | // The queued move goes first: a move over a card in the same task as |
| 189 | // the leave would be read by the next frame and light the card the |
| 190 | // pointer has just left, and it would settle lit with no pointer on |
| 191 | // the page (Astra A6). |
| 192 | function onLeave() { |
| 193 | pending = null; |
| 194 | live.forEach(dim); |
| 195 | leavePointer(); |
| 196 | } |
| 197 | function onVisibility() { |
| 198 | if (document.hidden) { |
| 199 | if (raf) cancelAnimationFrame(raf); |
| 200 | raf = 0; last = 0; |
| 201 | } else { |
| 202 | go(); |
| 203 | } |
| 204 | } |
| 205 | // The test hook (C14): sets the light at once and, with hold, keeps |
| 206 | // it there against pointer moves until the next event without hold. |
| 207 | function onHook(e) { |
| 208 | var d = e.detail || {}; |
| 209 | hold = !!d.hold; |
| 210 | if (typeof d.x === "number") { tx = lx = d.x; } |
| 211 | if (typeof d.y === "number") { ty = ly = d.y; } |
| 212 | force = true; |
| 213 | go(); |
| 214 | } |
| 215 | |
| 216 | function start() { |
| 217 | if (on) return; |
| 218 | on = true; |
| 219 | addEventListener("pointermove", onMove, { passive: true }); |
| 220 | addEventListener("pointerdown", onDown, { passive: true }); |
| 221 | addEventListener("keydown", leavePointer, { passive: true }); |
| 222 | document.addEventListener("pointerleave", onLeave); |
| 223 | document.addEventListener("visibilitychange", onVisibility); |
| 224 | document.addEventListener("rst:light", onHook); |
| 225 | clock = setInterval(function () { target(); go(); }, 5 * 60 * 1000); |
| 226 | // Set, not eased, at start: easing in from straight above would be |
| 227 | // a root write a frame for a third of a second while the page |
| 228 | // loads, toward a position nobody saw it leave. |
| 229 | target(); |
| 230 | lx = tx; ly = ty; force = true; |
| 231 | go(); |
| 232 | } |
| 233 | // Reduced motion is total, and forced colours drop every background |
| 234 | // and shadow this file moves: under either, no listener, no frame, no |
| 235 | // timer, and every property this file wrote goes back to its default. |
| 236 | function stop() { |
| 237 | if (!on) return; |
| 238 | on = false; |
| 239 | removeEventListener("pointermove", onMove); |
| 240 | removeEventListener("pointerdown", onDown); |
| 241 | removeEventListener("keydown", leavePointer); |
| 242 | document.removeEventListener("pointerleave", onLeave); |
| 243 | document.removeEventListener("visibilitychange", onVisibility); |
| 244 | document.removeEventListener("rst:light", onHook); |
| 245 | clearInterval(clock); |
| 246 | timers.forEach(clearTimeout); |
| 247 | timers.clear(); |
| 248 | if (raf) cancelAnimationFrame(raf); |
| 249 | raf = 0; last = 0; pending = null; pointer = false; hold = false; force = false; wrote = 0; |
| 250 | lx = wx = tx = 0; ly = wy = ty = -1; |
| 251 | live.clear(); |
| 252 | // The cards' state goes with their properties: a card that had |
| 253 | // settled lit kept l = 1 and still = true here, so after a |
| 254 | // preference toggle the pointer returning to it found nothing to |
| 255 | // ease and wrote nothing, and the card stayed dark (Astra A7). |
| 256 | cards = new WeakMap(); |
| 257 | root.style.removeProperty("--rst-light-x"); |
| 258 | root.style.removeProperty("--rst-light-y"); |
| 259 | // Every element this file ever wrote on carries one of its |
| 260 | // properties in its style attribute; no list of them is kept, so a |
| 261 | // button or card replaced by a fragment is not retained here. |
| 262 | var written = document.querySelectorAll('[style*="--rst-"]'); |
| 263 | for (var i = 0; i < written.length; i++) { |
| 264 | ["--rst-mx", "--rst-my", "--rst-px", "--rst-py", "--rst-card-x", "--rst-card-y", "--rst-card-lit"].forEach(function (p) { |
| 265 | written[i].style.removeProperty(p); |
| 266 | }); |
| 267 | } |
| 268 | } |
| 269 | function settle() { |
| 270 | if (reduce.matches || forced.matches) stop(); else start(); |
| 271 | } |
| 272 | reduce.addEventListener("change", settle); |
| 273 | forced.addEventListener("change", settle); |
| 274 | settle(); |
| 275 | })(); |
| 276 | |