Merge nucleic/calm-willow-ibis-dozm into main
Mirrored from corpo 8248d89.
This commit is contained in:
+92
-49
@@ -1458,10 +1458,21 @@ const canonical = new URL('/', Astro.site);
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Whatever the scroll says, at once. A card crossed at speed is skipped rather
|
||||||
|
than queued and played out: a queue makes the stage the one thing on the page
|
||||||
|
that disagrees with the scroll — the marks are already at the far end and the
|
||||||
|
cards are still working through the middle, which reads as the stack lagging
|
||||||
|
rather than as the products it passed being shown. Nothing here waits, so the
|
||||||
|
marks and the stage are the same reading of the same number. */
|
||||||
|
let shownTechnology = -1;
|
||||||
|
|
||||||
/* A card waits below the active one and leaves above it, so which side of the
|
/* A card waits below the active one and leaves above it, so which side of the
|
||||||
composition a given card sits on depends on where the story currently is — the
|
composition a given card sits on depends on where the story currently is — the
|
||||||
markup class is only the resting state of the stack before any of this runs. */
|
markup class is only the resting state of the stack before any of this runs. */
|
||||||
const showTechnology = (index: number) => {
|
const showTechnology = (index: number) => {
|
||||||
|
if (index === shownTechnology) return;
|
||||||
|
shownTechnology = index;
|
||||||
|
|
||||||
cards.forEach((card, cardIndex) => {
|
cards.forEach((card, cardIndex) => {
|
||||||
const isActive = cardIndex === index;
|
const isActive = cardIndex === index;
|
||||||
card.classList.toggle('is-active', isActive);
|
card.classList.toggle('is-active', isActive);
|
||||||
@@ -1475,45 +1486,6 @@ const canonical = new URL('/', Astro.site);
|
|||||||
setFooterVisible(index === cards.length - 1);
|
setFooterVisible(index === cards.length - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Each card holds the stage for a few hundred pixels of scroll, so one coarse
|
|
||||||
gesture — a flung trackpad, Page Down — crosses a whole card in a single frame.
|
|
||||||
Its box and the next one's then arrive in the same observer callback and the
|
|
||||||
middle product is handed the stage and taken off it in the same tick, which
|
|
||||||
reads as skipping straight past it. Scroll still chooses the card; it just
|
|
||||||
can't take one away before it has been seen. A card is given the stage one at
|
|
||||||
a time and holds it for at least its own entrance, so a jump plays the beats it
|
|
||||||
crossed instead of discarding them. Ordinary scrolling never reaches this —
|
|
||||||
beats are further apart than the interval. */
|
|
||||||
const BEAT_MS = 400;
|
|
||||||
let shownTechnology = 0;
|
|
||||||
let pendingTechnology = 0;
|
|
||||||
let shownSince = 0;
|
|
||||||
let beatTimer = 0;
|
|
||||||
|
|
||||||
const playNextBeat = () => {
|
|
||||||
beatTimer = 0;
|
|
||||||
if (shownTechnology === pendingTechnology) return;
|
|
||||||
|
|
||||||
shownTechnology += pendingTechnology > shownTechnology ? 1 : -1;
|
|
||||||
showTechnology(shownTechnology);
|
|
||||||
shownSince = performance.now();
|
|
||||||
if (shownTechnology !== pendingTechnology) {
|
|
||||||
beatTimer = window.setTimeout(playNextBeat, BEAT_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const activateTechnology = (index: number) => {
|
|
||||||
pendingTechnology = index;
|
|
||||||
if (beatTimer || shownTechnology === pendingTechnology) return;
|
|
||||||
|
|
||||||
const held = performance.now() - shownSince;
|
|
||||||
if (held >= BEAT_MS) {
|
|
||||||
playNextBeat();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
beatTimer = window.setTimeout(playNextBeat, BEAT_MS - held);
|
|
||||||
};
|
|
||||||
|
|
||||||
showTechnology(0);
|
showTechnology(0);
|
||||||
|
|
||||||
/* The fixed shell deliberately keeps the masthead and footer outside main's
|
/* The fixed shell deliberately keeps the masthead and footer outside main's
|
||||||
@@ -1588,14 +1560,50 @@ const canonical = new URL('/', Astro.site);
|
|||||||
{ capture: true, passive: false },
|
{ capture: true, passive: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* One swipe, one product — the finger's version of the rule above, and it has
|
||||||
|
to be taken over rather than damped: the momentum a touch scroller hands back
|
||||||
|
after the finger has gone is the whole of the problem, since it carries a
|
||||||
|
flick clean across the stack and there is nothing left holding the glass to
|
||||||
|
ask it not to. So the swipe is given the product it starts on and the one
|
||||||
|
either side of it, tracks the finger exactly between them, and past them
|
||||||
|
takes the finger's weight and returns a fifth of it, the way a scroller
|
||||||
|
resists at its end. Lifting off settles on the product on the stage. A tap is
|
||||||
|
untouched — nothing is prevented until the finger has actually moved — and so
|
||||||
|
is a second finger, which is the reader zooming and not scrolling at all. */
|
||||||
|
const TOUCH_STRETCH = 0.2;
|
||||||
|
/* And never further than this, however long the swipe goes on. A resistance
|
||||||
|
that only ever divides still arrives at the next product eventually, and the
|
||||||
|
reader sees it take the stage and get taken off again on the way back — the
|
||||||
|
one thing the whole rule is here to prevent. A fifteenth of the screen is far
|
||||||
|
enough to read as give and much nearer than the next card's edge. */
|
||||||
|
const TOUCH_STRETCH_MAX = 0.06;
|
||||||
|
|
||||||
let touchY: number | null = null;
|
let touchY: number | null = null;
|
||||||
|
let touchBeat = 0;
|
||||||
|
let touchGive = 0;
|
||||||
|
/* A tap is a finger that never moved, and settling under one would scroll the
|
||||||
|
page out from under the link being followed. */
|
||||||
|
let touchMoved = false;
|
||||||
|
/* Where the swipe has asked to be, before any of the resistance below: reading
|
||||||
|
it back off the element instead would compound the resistance against itself,
|
||||||
|
and the finger would meet a wall rather than a spring. */
|
||||||
|
let touchTop = 0;
|
||||||
|
let touchLow = 0;
|
||||||
|
let touchHigh = 0;
|
||||||
|
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
'touchstart',
|
'touchstart',
|
||||||
(event) => {
|
(event) => {
|
||||||
touchY = scrollRoot.contains(event.target as Node)
|
touchY = event.touches.length === 1 ? event.touches[0]?.clientY ?? null : null;
|
||||||
? null
|
if (touchY === null) return;
|
||||||
: event.touches[0]?.clientY ?? null;
|
|
||||||
|
measureBeats();
|
||||||
|
touchMoved = false;
|
||||||
|
touchBeat = technologyAtScroll();
|
||||||
|
touchTop = scrollRoot.scrollTop;
|
||||||
|
touchLow = beatRest(touchBeat - 1);
|
||||||
|
touchHigh = beatRest(touchBeat + 1);
|
||||||
|
touchGive = scrollRoot.clientHeight * TOUCH_STRETCH_MAX;
|
||||||
},
|
},
|
||||||
{ capture: true, passive: true },
|
{ capture: true, passive: true },
|
||||||
);
|
);
|
||||||
@@ -1607,14 +1615,41 @@ const canonical = new URL('/', Astro.site);
|
|||||||
if (touchY === null || nextTouchY === undefined) return;
|
if (touchY === null || nextTouchY === undefined) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
scrollRoot.scrollBy({ top: touchY - nextTouchY, behavior: 'auto' });
|
touchMoved = true;
|
||||||
|
touchTop += touchY - nextTouchY;
|
||||||
touchY = nextTouchY;
|
touchY = nextTouchY;
|
||||||
|
|
||||||
|
const bound = Math.min(touchHigh, Math.max(touchLow, touchTop));
|
||||||
|
const past = touchTop - bound;
|
||||||
|
/* Starts out giving back a fifth and closes on the cap: the same curve a
|
||||||
|
scroller bounces with, so the swipe meets a spring rather than a wall. */
|
||||||
|
const give =
|
||||||
|
Math.sign(past) *
|
||||||
|
touchGive *
|
||||||
|
(1 - 1 / (1 + (TOUCH_STRETCH * Math.abs(past)) / touchGive));
|
||||||
|
|
||||||
|
scrollRoot.scrollTop = bound + give;
|
||||||
},
|
},
|
||||||
{ capture: true, passive: false },
|
{ capture: true, passive: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
document.addEventListener('touchend', () => { touchY = null; }, { capture: true, passive: true });
|
/* The stage is asked where it is rather than told, so a swipe that gave up
|
||||||
document.addEventListener('touchcancel', () => { touchY = null; }, { capture: true, passive: true });
|
halfway settles back where it came from — but never further than the one
|
||||||
|
product the swipe was granted, whatever the stretch above let through. */
|
||||||
|
const settleTouch = () => {
|
||||||
|
if (touchY === null) return;
|
||||||
|
touchY = null;
|
||||||
|
if (!touchMoved) return;
|
||||||
|
|
||||||
|
const beat = Math.min(touchBeat + 1, Math.max(touchBeat - 1, technologyAtScroll()));
|
||||||
|
scrollRoot.scrollTo({
|
||||||
|
top: beatRest(beat),
|
||||||
|
behavior: reducedMotion.matches ? 'auto' : 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('touchend', settleTouch, { capture: true, passive: true });
|
||||||
|
document.addEventListener('touchcancel', settleTouch, { capture: true, passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Where the band below reaches: the story hands over to a card when that card's
|
/* Where the band below reaches: the story hands over to a card when that card's
|
||||||
@@ -1643,7 +1678,7 @@ const canonical = new URL('/', Astro.site);
|
|||||||
above without ever entering it, and a stack driven by entries alone would sit
|
above without ever entering it, and a stack driven by entries alone would sit
|
||||||
on a card the reader has already scrolled away from. The callback still does
|
on a card the reader has already scrolled away from. The callback still does
|
||||||
the job it is here for: saying that something moved. */
|
the job it is here for: saying that something moved. */
|
||||||
() => activateTechnology(technologyAtScroll()),
|
() => showTechnology(technologyAtScroll()),
|
||||||
/* A wider center band advances the next card earlier, reducing the scroll
|
/* A wider center band advances the next card earlier, reducing the scroll
|
||||||
distance between beats without changing the sticky composition. */
|
distance between beats without changing the sticky composition. */
|
||||||
{ root: scrollRoot, rootMargin: '-38% 0px -38% 0px' },
|
{ root: scrollRoot, rootMargin: '-38% 0px -38% 0px' },
|
||||||
@@ -1729,7 +1764,7 @@ const canonical = new URL('/', Astro.site);
|
|||||||
stops just the other side of a crossing gets no further callback, and
|
stops just the other side of a crossing gets no further callback, and
|
||||||
the story would sit on the card it was leaving. Every scrolled frame
|
the story would sit on the card it was leaving. Every scrolled frame
|
||||||
asks instead. */
|
asks instead. */
|
||||||
activateTechnology(technologyAtScroll());
|
showTechnology(technologyAtScroll());
|
||||||
syncProgress();
|
syncProgress();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1751,14 +1786,22 @@ const canonical = new URL('/', Astro.site);
|
|||||||
const goToBeat = (index: number) => {
|
const goToBeat = (index: number) => {
|
||||||
keyBeat = Math.min(cards.length - 1, Math.max(0, index));
|
keyBeat = Math.min(cards.length - 1, Math.max(0, index));
|
||||||
keyBeatAt = performance.now();
|
keyBeatAt = performance.now();
|
||||||
|
/* A mark asked for by name is a request for that product, not for the ones
|
||||||
|
in between: scrolled to over a third of a second they would each take the
|
||||||
|
stage on the way past, which is the same lag the marks are there to spare
|
||||||
|
the reader. A step to the next one is still travelled. */
|
||||||
|
const skips = Math.abs(keyBeat - technologyAtScroll()) > 1;
|
||||||
|
|
||||||
activateTechnology(keyBeat);
|
/* The stage is not told where it is going. It is read off the scroll like
|
||||||
|
everything else here, so a step that is still travelling shows the product
|
||||||
|
it is still on rather than announcing the next one and being contradicted
|
||||||
|
by its own animation a frame later. */
|
||||||
scrollRoot.scrollTo({
|
scrollRoot.scrollTo({
|
||||||
top: beatRest(keyBeat),
|
top: beatRest(keyBeat),
|
||||||
/* Read now rather than at load: the preference can be turned on with the
|
/* Read now rather than at load: the preference can be turned on with the
|
||||||
page already open, and this is the one piece of motion here that a
|
page already open, and this is the one piece of motion here that a
|
||||||
stylesheet cannot reach. */
|
stylesheet cannot reach. */
|
||||||
behavior: reducedMotion.matches ? 'auto' : 'smooth',
|
behavior: reducedMotion.matches || skips ? 'auto' : 'smooth',
|
||||||
});
|
});
|
||||||
readHint();
|
readHint();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user