Skip to content

Commit ef8a66c

Browse files
committed
Merge feat/site-stats-odometer: coupled odometer wheels, roll only on change
2 parents fcc89a0 + 5c59acd commit ef8a66c

2 files changed

Lines changed: 141 additions & 36 deletions

File tree

site/index.qmd

Lines changed: 104 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
523525
const 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.
531531
const MLS_CACHE_PREFIX = 'mlsysbook.stat.v1.';
532532
533533
function 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.
556555
const MLS_ROLL_MIN_MS = 400;
557556
const 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.
572569
function 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
618686
mlsReady(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);

site/landing-v3.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,3 +838,40 @@
838838
font-size: 0.95rem;
839839
}
840840
}
841+
842+
/* ---------------------------------------------------------------------------
843+
Odometer wheels for the impact figures.
844+
845+
Each digit sits in a window one line box tall with a strip of numerals
846+
behind it; the strip is translated to bring the right digit into view. The
847+
markup exists only while a figure is rolling and collapses back to plain
848+
text when it lands, so the resting card is unchanged and the number stays
849+
selectable. Sizes come from JavaScript because they must match the figure's
850+
own computed line height, which differs between the reach readings and the
851+
star count.
852+
--------------------------------------------------------------------------- */
853+
.mls-odo {
854+
display: inline-flex;
855+
align-items: flex-start;
856+
font-variant-numeric: tabular-nums;
857+
}
858+
859+
.mls-odo-col {
860+
display: inline-block;
861+
overflow: hidden;
862+
will-change: filter;
863+
}
864+
865+
.mls-odo-strip {
866+
display: block;
867+
will-change: transform;
868+
}
869+
870+
.mls-odo-cell {
871+
display: block;
872+
text-align: center;
873+
}
874+
875+
.mls-odo-sep {
876+
display: inline-block;
877+
}

0 commit comments

Comments
 (0)