@@ -517,17 +517,17 @@ function mlsReady(fn) {
517517 }
518518}
519519
520- // Count one figure up to its value. Only a plain digit string rolls: a label
521- // like "August 2026", a country list, or a rounded "1,100+" is set outright,
522- // because counting up to a value that is not a number reads as a glitch.
520+ // A figure moves only when it has actually moved. On a first visit, and on a
521+ // return where the value is unchanged, the number is simply shown: there is no
522+ // change for the motion to describe, and counting up from zero would assert a
523+ // readership the site does not have. The build already bakes the current figure
524+ // into the HTML, so a first-time reader sees the real number at first paint.
523525const mlsReduceMotion = !!(window.matchMedia
524526 && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
525527
526- // Each figure remembers what the reader last saw. A returning reader then
527- // watches the number travel from that remembered value to the current one, so
528- // the animation shows the growth since their last visit rather than replaying
529- // the same sweep from zero. A first visit, or a visit where nothing moved,
530- // still gets the full sweep.
528+ // Each figure remembers what the reader last saw, so a returning reader watches
529+ // the number travel from that value to the current one and the animation
530+ // measures the growth since their last visit.
531531const MLS_CACHE_PREFIX = 'mlsysbook.stat.v1.';
532532
533533function mlsCacheGet(key) {
@@ -545,14 +545,13 @@ function mlsCacheSet(key, value) {
545545 try {
546546 window.localStorage.setItem(MLS_CACHE_PREFIX + key, String(value));
547547 } catch (err) {
548- /* quota or private mode: the roll still works, it just starts from zero */
548+ /* quota or private mode: the figure simply shows without rolling */
549549 }
550550}
551551
552- // Time the roll to the distance it travels, on a log scale. A jump of a few
553- // hundred readers should tick over quickly; a full sweep from zero earns the
554- // long version. Linear scaling would make every realistic delta indistinguishable
555- // from instant, because a day of growth is a rounding error against the total.
552+ // Time the roll to the distance it travels, on a log scale. Linear scaling
553+ // would collapse every realistic delta to instant, because a day of growth is
554+ // a rounding error against the total.
556555const MLS_ROLL_MIN_MS = 400;
557556const MLS_ROLL_MAX_MS = 1400;
558557
@@ -566,53 +565,122 @@ function mlsRollDuration(from, to) {
566565}
567566
568567// Drop the width reservation taken for the animation. It is a pixel value
569- // measured at one viewport, so leaving it behind would fight a later rotation
570- // or resize; once the figure holds its final string it occupies exactly that
571- // width on its own and needs no help.
568+ // measured at one viewport, so leaving it behind would fight a later rotation.
572569function mlsRelease(el) {
573570 el.style.minWidth = '';
574571 el.style.display = '';
575572 el.style.textAlign = '';
576573}
577574
578- function mlsRoll(el, from, to, final, duration) {
575+ // Where the wheel at place p sits for value v. The units wheel turns
576+ // continuously; every wheel above it holds its digit and snaps through only as
577+ // the wheel below completes its turn, the way a star-wheel counter does. At
578+ // rest each wheel therefore lands on a whole digit rather than between two.
579+ function mlsWheelPos(v, p) {
580+ const scaled = v / Math.pow(10, p);
581+ const fl = Math.floor(scaled);
582+ const digit = ((fl % 10) + 10) % 10;
583+ const frac = scaled - fl;
584+ if (p === 0) return digit + frac;
585+ const CARRY = 0.88;
586+ return digit + (frac <= CARRY ? 0 : (frac - CARRY) / (1 - CARRY));
587+ }
588+
589+ function mlsRoll(el, finalStr, from) {
590+ const digits = finalStr.replace(/[^0-9]/g, '');
591+ const target = Number(digits);
592+ const n = digits.length;
593+
594+ // The digit window is the figure's own line box, so it is exactly as tall
595+ // rolling as at rest and nothing below it moves.
596+ const cs = window.getComputedStyle(el);
597+ let cell = parseFloat(cs.lineHeight);
598+ if (!Number.isFinite(cell)) cell = parseFloat(cs.fontSize) * 1.2;
599+ cell = Math.round(cell);
600+
601+ el.textContent = '';
602+ const sr = document.createElement('span');
603+ sr.className = 'visually-hidden';
604+ sr.textContent = finalStr; // screen readers hear the value, not the wheels
605+ const odo = document.createElement('span');
606+ odo.className = 'mls-odo';
607+ odo.setAttribute('aria-hidden', 'true');
608+ el.appendChild(sr);
609+ el.appendChild(odo);
610+
611+ const cols = [];
612+ let digitIndex = 0;
613+ for (let c = 0; c < finalStr.length; c++) {
614+ const ch = finalStr[c];
615+ if (ch < '0' || ch > '9') {
616+ const sep = document.createElement('span');
617+ sep.className = 'mls-odo-sep';
618+ sep.textContent = ch;
619+ odo.appendChild(sep);
620+ continue;
621+ }
622+ const place = n - 1 - digitIndex++;
623+ const col = document.createElement('span');
624+ col.className = 'mls-odo-col';
625+ col.style.height = cell + 'px';
626+ const strip = document.createElement('span');
627+ strip.className = 'mls-odo-strip';
628+ // Eleven cells: 0-9 then 0 again, so a wheel passing nine runs onto an
629+ // identical zero and the jump back to the top is invisible.
630+ for (let k = 0; k <= 10; k++) {
631+ const cellEl = document.createElement('span');
632+ cellEl.className = 'mls-odo-cell';
633+ cellEl.style.height = cell + 'px';
634+ cellEl.style.lineHeight = cell + 'px';
635+ cellEl.textContent = String(k % 10);
636+ strip.appendChild(cellEl);
637+ }
638+ col.appendChild(strip);
639+ odo.appendChild(col);
640+ cols.push({ el: col, strip: strip, place: place });
641+ }
642+
643+ const duration = mlsRollDuration(from, target);
579644 let started = null;
580- el.textContent = Math.round( from).toLocaleString() ;
645+ let prev = from;
581646 function frame(now) {
582647 if (started === null) started = now;
583648 const p = Math.min(1, (now - started) / duration);
649+ const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
650+ const v = from + (target - from) * eased;
651+ for (let j = 0; j < cols.length; j++) {
652+ const col = cols[j];
653+ col.strip.style.transform = 'translateY(' + (-mlsWheelPos(v, col.place) * cell) + 'px)';
654+ // A wheel turning many times in one frame would strobe rather than spin,
655+ // so blur it in proportion to how far it moved. That is what the eye sees
656+ // on a real counter, and it turns aliasing into motion.
657+ const turns = Math.abs(v - prev) / Math.pow(10, col.place);
658+ const blur = turns < 0.12 ? 0 : Math.min(3.2, turns * 1.6);
659+ col.el.style.filter = blur ? 'blur(' + blur.toFixed(2) + 'px)' : '';
660+ }
661+ prev = v;
584662 if (p < 1) {
585- // easeOutCubic: quick off the mark, then settles onto the figure.
586- const eased = 1 - Math.pow(1 - p, 3);
587- el.textContent = Math.round(from + (to - from) * eased).toLocaleString();
588663 requestAnimationFrame(frame);
589664 } else {
590- el.textContent = final ; // land on the exact published string
665+ el.textContent = finalStr ; // collapse back to plain, selectable text
591666 mlsRelease(el);
592667 }
593668 }
594669 requestAnimationFrame(frame);
595670}
596671
597- function mlsCountUp(el, text, from) {
672+ // Show a figure: roll it only if this reader has seen a different value before.
673+ function mlsPresent(el, text, from) {
598674 const final = String(text);
599675 const target = Number(final.replace(/,/g, ''));
600676 if (mlsReduceMotion || !/^[\d,]+$/.test(final.trim())
601- || !Number.isFinite(target) || target <= 0) {
677+ || !Number.isFinite(target) || target <= 0
678+ || from === null || from === target) {
602679 el.textContent = final;
603- mlsRelease(el); // nothing animates, so hold no reservation
680+ mlsRelease(el);
604681 return;
605682 }
606- const start = (Number.isFinite(from) && from >= 0) ? from : null;
607- if (start === null || start === target) {
608- // First visit, or nothing moved since the last one: play the full sweep so
609- // the figure still arrives rather than sitting there already finished.
610- mlsRoll(el, 0, target, final, MLS_ROLL_MAX_MS);
611- } else {
612- // Travel from what this reader last saw. Handles a decrease too, which is
613- // rare but real: a star can be withdrawn.
614- mlsRoll(el, start, target, final, mlsRollDuration(start, target));
615- }
683+ mlsRoll(el, final, from);
616684}
617685
618686mlsReady(function () {
@@ -656,7 +724,7 @@ mlsReady(function () {
656724 el.style.minWidth = width + 'px';
657725 el.style.textAlign = 'center';
658726 }
659- mlsCountUp (el, final, mlsCacheGet(key));
727+ mlsPresent (el, final, mlsCacheGet(key));
660728 const value = Number(final.replace(/,/g, ''));
661729 if (/^[\d,]+$/.test(final.trim()) && Number.isFinite(value)) {
662730 mlsCacheSet(key, value);
0 commit comments