mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-15 07:36:27 -05:00
123 lines
3.7 KiB
TypeScript
123 lines
3.7 KiB
TypeScript
export interface VirtualizerOptions {
|
|
count: number;
|
|
/** Row height used until a row reports its measured size. */
|
|
estimatedSize: number;
|
|
/** Vertical gap between rows. */
|
|
gap?: number;
|
|
/** Rows rendered beyond the visible range in both directions. */
|
|
overscan?: number;
|
|
}
|
|
|
|
export interface VirtualItem {
|
|
index: number;
|
|
/** Offset of the row's top edge from the top of the scroll content. */
|
|
start: number;
|
|
}
|
|
|
|
/**
|
|
* Framework-agnostic windowing logic for a vertical list with variable row
|
|
* heights: rows start at an estimated size and refine as they report
|
|
* measurements. The UI layer owns scrolling and rendering; this class only
|
|
* answers "how tall is the content" and "which rows are visible".
|
|
*
|
|
* Row offsets are cached as prefix sums, recomputed from the first row whose
|
|
* size changed, so offsets are O(1) and the visible range a binary search.
|
|
*/
|
|
export class VirtualizerCore {
|
|
private count: number;
|
|
private readonly estimatedSize: number;
|
|
private readonly gap: number;
|
|
private readonly overscan: number;
|
|
private readonly measuredSizes = new Map<number, number>();
|
|
private readonly offsets: number[] = [];
|
|
/** Entries of `offsets` before this index are up to date. */
|
|
private validOffsets = 0;
|
|
|
|
constructor(options: VirtualizerOptions) {
|
|
this.count = options.count;
|
|
this.estimatedSize = options.estimatedSize;
|
|
this.gap = options.gap ?? 0;
|
|
this.overscan = options.overscan ?? 3;
|
|
}
|
|
|
|
/** Updates the row count, dropping measurements that no longer apply. */
|
|
setCount(count: number) {
|
|
if (count < this.count) {
|
|
for (const index of this.measuredSizes.keys()) {
|
|
if (index >= count) this.measuredSizes.delete(index);
|
|
}
|
|
this.offsets.length = count;
|
|
this.validOffsets = Math.min(this.validOffsets, count);
|
|
}
|
|
this.count = count;
|
|
}
|
|
|
|
/** Records a row's measured size. Returns true when the size changed. */
|
|
measure(index: number, size: number) {
|
|
if (this.measuredSizes.get(index) === size) return false;
|
|
this.measuredSizes.set(index, size);
|
|
this.validOffsets = Math.min(this.validOffsets, index + 1);
|
|
return true;
|
|
}
|
|
|
|
sizeOf(index: number) {
|
|
return this.measuredSizes.get(index) ?? this.estimatedSize;
|
|
}
|
|
|
|
startOf(index: number) {
|
|
this.computeOffsetsThrough(index);
|
|
return this.offsets[index];
|
|
}
|
|
|
|
totalSize() {
|
|
if (this.count === 0) return 0;
|
|
return this.startOf(this.count - 1) + this.sizeOf(this.count - 1);
|
|
}
|
|
|
|
/** The rows overlapping the viewport, plus overscan on both sides. */
|
|
range(scrollTop: number, viewportSize: number): VirtualItem[] {
|
|
if (this.count === 0) return [];
|
|
this.computeOffsetsThrough(this.count - 1);
|
|
|
|
const firstVisible = this.lowerBound(
|
|
(index) => this.offsets[index] + this.sizeOf(index) >= scrollTop,
|
|
);
|
|
if (firstVisible === this.count) return [];
|
|
const lastVisible =
|
|
this.lowerBound(
|
|
(index) => this.offsets[index] > scrollTop + viewportSize,
|
|
) - 1;
|
|
|
|
const from = Math.max(0, firstVisible - this.overscan);
|
|
const to = Math.min(this.count - 1, lastVisible + this.overscan);
|
|
const items: VirtualItem[] = [];
|
|
for (let i = from; i <= to; i++) {
|
|
items.push({ index: i, start: this.offsets[i] });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
private computeOffsetsThrough(index: number) {
|
|
for (let i = this.validOffsets; i <= index; i++) {
|
|
this.offsets[i] =
|
|
i === 0 ? 0 : this.offsets[i - 1] + this.sizeOf(i - 1) + this.gap;
|
|
}
|
|
this.validOffsets = Math.max(this.validOffsets, index + 1);
|
|
}
|
|
|
|
/** First index for which `holds` is true (`count` when none); `holds` must be false-then-true over the rows. */
|
|
private lowerBound(holds: (index: number) => boolean) {
|
|
let low = 0;
|
|
let high = this.count;
|
|
while (low < high) {
|
|
const middle = (low + high) >>> 1;
|
|
if (holds(middle)) {
|
|
high = middle;
|
|
} else {
|
|
low = middle + 1;
|
|
}
|
|
}
|
|
return low;
|
|
}
|
|
}
|