initial
This commit is contained in:
commit
690abbd3cf
|
@ -0,0 +1,15 @@
|
|||
ISC License
|
||||
|
||||
Copyright (c) 2024, aiden (aiden@cmp.bz)
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@ -0,0 +1,351 @@
|
|||
<body>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
button {
|
||||
background: bisque;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
height: var(--row-height);
|
||||
width: var(--column-width);
|
||||
}
|
||||
|
||||
button:focus {
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function mod(n, d) {
|
||||
return ((n % d) + d) % d;
|
||||
}
|
||||
|
||||
function clamp(n, min, max) {
|
||||
return (min > max) ? undefined :
|
||||
((n < min) ? min :
|
||||
((n > max) ? max : n)
|
||||
);
|
||||
}
|
||||
|
||||
// slow data source
|
||||
const dataSource = {
|
||||
async index(idx) {
|
||||
await new Promise(res => setTimeout(res, 500 + Math.random() * 750));
|
||||
return idx;
|
||||
},
|
||||
async length() {
|
||||
return 100;
|
||||
},
|
||||
}
|
||||
|
||||
const scroller = {
|
||||
dataSource: null,
|
||||
|
||||
scrollContainer: null,
|
||||
|
||||
rowHeight: null,
|
||||
columns: 1,
|
||||
|
||||
elementsPerViewport: 0,
|
||||
elementsAboveViewport: 0,
|
||||
elementsBelowViewport: 0,
|
||||
scrollInterval: null,
|
||||
|
||||
obs: null,
|
||||
elements: [],
|
||||
elementsBase: 0,
|
||||
|
||||
_prevIntervalAligned: null,
|
||||
prevIntervalAligned(current) {
|
||||
const capture = this._prevIntervalAligned;
|
||||
this._prevIntervalAligned = current;
|
||||
return capture;
|
||||
},
|
||||
_prevAdjustedIdx: null,
|
||||
|
||||
loadElements(firstSourceIdx, firstAvailableElement, nElements) {
|
||||
const elements = this.elements;
|
||||
const totalElements = this.elements.length;
|
||||
const elementsBase = this.elementsBase;
|
||||
const rowHeight = this.rowHeight;
|
||||
const columns = this.columns;
|
||||
|
||||
// elementsBase is fine here since this function doesn't await
|
||||
|
||||
for (let idx = 0; idx < nElements; ++idx) {
|
||||
const elIdx = (elementsBase + firstAvailableElement + idx) % totalElements;
|
||||
const obj = elements[elIdx];
|
||||
const {element} = obj;
|
||||
const sourceIdx = firstSourceIdx + idx;
|
||||
|
||||
element.style.transform = `
|
||||
translateY(${((sourceIdx / columns) | 0) * rowHeight}px)
|
||||
translateX(calc(var(--column-width) * ${sourceIdx % columns}))
|
||||
`;
|
||||
|
||||
if (obj.loadRequest) {
|
||||
obj.loadRequest.aborted = true;
|
||||
}
|
||||
let loadRequest = {aborted: false};
|
||||
obj.loadRequest = loadRequest;
|
||||
|
||||
element.tabIndex = sourceIdx + 1;
|
||||
element.innerText = "loading!";
|
||||
|
||||
dataSource.index(sourceIdx).then(value => {
|
||||
if (!loadRequest.aborted) {
|
||||
obj.sourceIdx = sourceIdx;
|
||||
element.innerText = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
},
|
||||
|
||||
// requires:
|
||||
// I dataSource.length
|
||||
// I scrollContainer
|
||||
// I rowHeight
|
||||
// S columns
|
||||
// S scrollInterval
|
||||
// S elementsAboveViewport
|
||||
// S elementsPerViewport
|
||||
// S elementsBelowViewport
|
||||
adjustedIdx(maxSourceIdx, scrollRow) {
|
||||
const topSourceIdx = scrollRow * this.columns;
|
||||
const adjustedIdx = clamp(topSourceIdx, this.elementsAboveViewport, maxSourceIdx - this.elementsBelowViewport);
|
||||
|
||||
const prevAdjustedIdx = this._prevAdjustedIdx;
|
||||
if (adjustedIdx !== undefined) {
|
||||
this._prevAdjustedIdx = adjustedIdx;
|
||||
}
|
||||
|
||||
return {adjustedIdx, prevAdjustedIdx};
|
||||
},
|
||||
scrollRow(scrollPos) {
|
||||
return (scrollPos / this.rowHeight) | 0;
|
||||
},
|
||||
intervalAligned(scrollPos) {
|
||||
return scrollPos - (scrollPos % this.scrollInterval);
|
||||
},
|
||||
scrollPos() {
|
||||
const maxSourceIdx = this.dataSource.length - this.elementsPerViewport;
|
||||
const scrollPos = Math.min(
|
||||
this.scrollContainer.scrollTop,
|
||||
maxSourceIdx * this.rowHeight
|
||||
);
|
||||
|
||||
return {maxSourceIdx, scrollPos};
|
||||
},
|
||||
|
||||
scroll(adjustedIdx, offset) {
|
||||
const scrollingUp = +(offset > 0);
|
||||
const scrollingDown = +(offset < 0);
|
||||
|
||||
const els = this.elements.length;
|
||||
|
||||
// amount of elements that we have to update
|
||||
const freeElements = Math.min(Math.abs(offset), els);
|
||||
// index of first available element relative to elementsBase
|
||||
const firstAvailableElement = scrollingUp * (els - freeElements);
|
||||
|
||||
const firstSourceIdx = adjustedIdx - this.elementsAboveViewport + (scrollingDown * (els - freeElements));
|
||||
|
||||
console.log("firstSourceIdx", firstSourceIdx, "firstAvailableElement", firstAvailableElement, "freeElements", freeElements);
|
||||
this.loadElements(firstSourceIdx, firstAvailableElement, freeElements);
|
||||
this.elementsBase = mod(this.elementsBase - clamp(offset, -els, els), els);
|
||||
|
||||
return;
|
||||
},
|
||||
|
||||
callback() {
|
||||
// not first run (first run is handled by `size` now)
|
||||
|
||||
console.log("callback");
|
||||
|
||||
const {maxSourceIdx, scrollPos} = this.scrollPos();
|
||||
|
||||
// this is simply for the sake of aborting if the interval was not crossed
|
||||
const intervalAligned = this.intervalAligned(scrollPos);
|
||||
const prevIntervalAligned = this.prevIntervalAligned(intervalAligned);
|
||||
|
||||
if (prevIntervalAligned === intervalAligned) {
|
||||
// not scrolled past interval; abort
|
||||
console.log("aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRow = this.scrollRow(scrollPos);
|
||||
const {adjustedIdx, prevAdjustedIdx} = this.adjustedIdx(maxSourceIdx, scrollRow);
|
||||
|
||||
// positive : scroll up
|
||||
// negative : scroll down
|
||||
const offset = prevAdjustedIdx - adjustedIdx;
|
||||
this.scroll(adjustedIdx, offset);
|
||||
|
||||
// fix scrolling fast
|
||||
return requestAnimationFrame(_ => this.callback());
|
||||
},
|
||||
|
||||
size(conf) {
|
||||
let newElement = () => {
|
||||
const element = document.createElement("button");
|
||||
conf.parent.append(element);
|
||||
this.obs.observe(element);
|
||||
return {
|
||||
element, loadRequest: null,
|
||||
}
|
||||
}
|
||||
|
||||
const oldColumns = this.columns;
|
||||
const oldAbove = this.elementsAboveViewport;
|
||||
|
||||
this.columns = conf.columns;
|
||||
this.scrollInterval = conf.rowInterval * this.columns;
|
||||
this.elementsAboveViewport = conf.rowsAboveViewport * this.columns;
|
||||
this.elementsPerViewport = Math.ceil(conf.viewportHeight / this.rowHeight) * this.columns;
|
||||
this.elementsBelowViewport = conf.rowsBelowViewport * this.columns;
|
||||
this.scrollContainer.style.height = Math.ceil(this.dataSource.length / this.columns) * this.rowHeight + "px";
|
||||
|
||||
const {maxSourceIdx, scrollPos} = this.scrollPos();
|
||||
this.prevIntervalAligned(this.intervalAligned(scrollPos));
|
||||
const scrollRow = this.scrollRow(scrollPos);
|
||||
|
||||
let {adjustedIdx, prevAdjustedIdx} = this.adjustedIdx(maxSourceIdx, scrollRow);
|
||||
|
||||
let prevStart = prevAdjustedIdx - oldAbove;
|
||||
let prevEnd = prevStart + this.elements.length;
|
||||
|
||||
let currStart = adjustedIdx - this.elementsAboveViewport;
|
||||
let currEnd = adjustedIdx + this.elementsPerViewport + this.elementsBelowViewport;
|
||||
|
||||
let finalLen = currEnd - currStart;
|
||||
|
||||
if (adjustedIdx === undefined) {
|
||||
adjustedIdx = this.elementsAboveViewport;
|
||||
currStart = 0;
|
||||
finalLen = this.dataSource.length;
|
||||
currEnd = finalLen;
|
||||
|
||||
this._prevAdjustedIdx = adjustedIdx;
|
||||
this.obs.disconnect();
|
||||
}
|
||||
|
||||
//console.log("prevStart", prevStart, "prevEnd", prevEnd);
|
||||
//console.log("currStart", currStart, "currEnd", currEnd);
|
||||
|
||||
const elements = [...this.elements.splice(this.elementsBase), ...this.elements];
|
||||
|
||||
const toRemoveFromLeft = clamp(currStart - prevStart, -finalLen, elements.length);
|
||||
const toRemoveFromRight = clamp(prevEnd - currEnd, -finalLen, elements.length);
|
||||
|
||||
//console.log("toRemoveFromLeft", toRemoveFromLeft, "toRemoveFromRight", toRemoveFromRight);
|
||||
|
||||
const leftRemoved = elements.splice(0, Math.max(toRemoveFromLeft, 0));
|
||||
const rightRemoved = elements.splice(elements.length - toRemoveFromRight, Math.max(toRemoveFromRight, 0));
|
||||
|
||||
if (this.columns != oldColumns) {
|
||||
let it = 0;
|
||||
for (const {element} of elements) {
|
||||
const sourceIdx =
|
||||
adjustedIdx - this.elementsAboveViewport + -toRemoveFromLeft + it;
|
||||
element.style.transform = `
|
||||
translateY(${((sourceIdx / this.columns) | 0) * rowHeight}px)
|
||||
translateX(calc(var(--column-width) * ${sourceIdx % this.columns}))
|
||||
`;
|
||||
it += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const marker1 = 0;
|
||||
const len1 = Math.max(0, -toRemoveFromLeft);
|
||||
for (let idx = 0; idx < -toRemoveFromLeft; ++idx) {
|
||||
// take from right side
|
||||
let obj = rightRemoved.splice(0, 1)[0];
|
||||
// or create
|
||||
obj ??= newElement();
|
||||
elements.unshift(obj);
|
||||
}
|
||||
|
||||
const marker2 = elements.length;
|
||||
const len2 = finalLen - marker2;
|
||||
for (let idx = 0; idx < -toRemoveFromRight; ++idx) {
|
||||
// take from left side
|
||||
let obj = leftRemoved.splice(0, 1)[0];
|
||||
// or create
|
||||
obj ??= newElement();
|
||||
elements.push(obj);
|
||||
}
|
||||
|
||||
for (const {element} of [...leftRemoved, ...rightRemoved]) {
|
||||
element.remove();
|
||||
this.obs.unobserve(element);
|
||||
}
|
||||
|
||||
this.elements = elements;
|
||||
this.elementsBase = 0;
|
||||
|
||||
this.loadElements(
|
||||
adjustedIdx - this.elementsAboveViewport,
|
||||
marker1,
|
||||
len1,
|
||||
);
|
||||
this.loadElements(
|
||||
adjustedIdx - this.elementsAboveViewport + marker2,
|
||||
marker2,
|
||||
len2,
|
||||
);
|
||||
|
||||
this.prevIntervalAligned(this.intervalAligned(scrollPos));
|
||||
|
||||
return;
|
||||
},
|
||||
async init(conf) {
|
||||
this.obs = new IntersectionObserver(_ => this.callback());
|
||||
|
||||
this.dataSource = {
|
||||
index: conf.dataSource.index.bind(conf.dataSource),
|
||||
length: await conf.dataSource.length(),
|
||||
}
|
||||
|
||||
this.scrollContainer = conf.scrollContainer;
|
||||
this.rowHeight = conf.rowHeight;
|
||||
},
|
||||
}
|
||||
|
||||
const rowHeight = 100;
|
||||
scroller.init({
|
||||
dataSource,
|
||||
scrollContainer: document.body,
|
||||
rowHeight,
|
||||
});
|
||||
|
||||
let columns = 1;
|
||||
const columnWidth = window.innerWidth / columns;
|
||||
document.documentElement.style.setProperty("--row-height", rowHeight);
|
||||
|
||||
function resize() {
|
||||
document.documentElement.style.setProperty("--column-width", `calc(100dvw / ${columns})`);
|
||||
scroller.size({
|
||||
parent: document.body,
|
||||
|
||||
viewportHeight: window.innerHeight,
|
||||
columns,
|
||||
|
||||
rowsAboveViewport: 2,
|
||||
rowsBelowViewport: 2,
|
||||
rowInterval: 1,
|
||||
});
|
||||
}
|
||||
|
||||
window.onresize = resize;
|
||||
|
||||
// chrome resets the scroll position on conf.parent.append without RAF
|
||||
requestAnimationFrame(resize);
|
||||
</script>
|
||||
</body>
|
Loading…
Reference in New Issue