Algorithms

Virtual window

Which rows of a long list to draw: those in view and a few beyond, at the same cost for a million as for forty.

Behaviour

Every row in view is drawn, for rows of one size
fc.assert(
  fc.property(
    fc.nat(5000),
    fc.integer({ min: 16, max: 60 }),
    fc.nat(200_000),
    fc.integer({ min: 100, max: 900 }),
    (count, rowSize, scroll, viewport) => {
      const { first, end } = fixedWindow(count, rowSize, scroll, viewport);
      for (let row = 0; row < count; row += 1) {
        const visible = row * rowSize < scroll + viewport && (row + 1) * rowSize > scroll;
        if (visible && (row < first || row >= end)) return false;
      }
      return first >= 0 && end <= count && first <= end;
    },
  ),
);
Every row in view is drawn, for rows of differing sizes
fc.assert(
  fc.property(
    fc.array(fc.integer({ min: 16, max: 120 }), { maxLength: 400 }),
    fc.nat(20_000),
    fc.integer({ min: 100, max: 900 }),
    (sizes, scroll, viewport) => {
      const { first, end, offset } = variableWindow(fenwickFrom(sizes), scroll, viewport);
      let top = 0;
      for (let row = 0; row < sizes.length; row += 1) {
        const visible = top < scroll + viewport && top + sizes[row]! > scroll;
        if (visible && (row < first || row >= end)) return false;
        if (row === first && top !== offset) return false;
        top += sizes[row]!;
      }
      return first <= end && end <= sizes.length;
    },
  ),
);
The rows drawn depend on the view, not on how many rows there are
const small = fixedWindow(1_000, 32, 16_000, 640);
const large = fixedWindow(1_000_000, 32, 16_000, 640);
expect(large.end - large.first).toBe(small.end - small.first);
expect(large.end - large.first).toBeLessThanOrEqual(640 / 32 + 2 * 4 + 1);
expect(large.size).toBe(32_000_000);
The first drawn row's offset places it where it would be in the whole list
expect(fixedWindow(1000, 32, 3200, 320, 0)).toEqual({ first: 100, end: 110, offset: 3200, size: 32_000 });
An empty list draws nothing
expect(fixedWindow(0, 32, 0, 600)).toEqual({ first: 0, end: 0, offset: 0, size: 0 });
expect(variableWindow(fenwickFrom([]), 0, 600)).toEqual({ first: 0, end: 0, offset: 0, size: 0 });
Revealing a row scrolls by the least that shows it, and not at all if it is in view
expect(scrollToReveal(320, 32, 300, 200)).toBe(300);
expect(scrollToReveal(96, 32, 300, 200)).toBe(96);
expect(scrollToReveal(640, 32, 300, 200)).toBe(472);

Lists too long for a browser to lay out

The scrolling element is never taller than the limit
expect(scrollSize(1_000 * row)).toBe(32_000);
expect(scrollSize(100_000_000 * row)).toBe(SCROLL_LIMIT);
Within the limit, the scroll position is the place in the list
fc.assert(
  fc.property(fc.nat(SCROLL_LIMIT), fc.nat(SCROLL_LIMIT), (size, offset) => {
    return listOffset(offset, size, view) === offset && scrollOffsetFor(offset, size, view) === offset;
  }),
);
Beyond it, the top is the list's start and the last pixel scrolled is its end
const size = 100_000_000 * row;
expect(listOffset(0, size, view)).toBe(0);
expect(listOffset(SCROLL_LIMIT - view, size, view)).toBe(size - view);
Scrolling further never moves back through the list
fc.assert(
  fc.property(
    fc.integer({ min: SCROLL_LIMIT + 1, max: 2 ** 40 }),
    fc.nat(SCROLL_LIMIT),
    fc.nat(SCROLL_LIMIT),
    (size, a, b) => {
      const [low, high] = a < b ? [a, b] : [b, a];
      return listOffset(low, size, view) <= listOffset(high, size, view);
    },
  ),
);
The scroll position that shows a place in the list shows that place, to within a pixel
fc.assert(
  fc.property(
    fc.integer({ min: SCROLL_LIMIT + 1, max: 2 ** 40 }),
    fc.double({ min: 0, max: 1, noNaN: true }),
    (size, t) => {
      const offset = Math.floor(t * (size - view));
      return Math.abs(listOffset(scrollOffsetFor(offset, size, view), size, view) - offset) < 1;
    },
  ),
);
A view as tall as the limit has nowhere to scroll
expect(listOffset(5, 2 * SCROLL_LIMIT, SCROLL_LIMIT)).toBe(0);
expect(scrollOffsetFor(5, 2 * SCROLL_LIMIT, SCROLL_LIMIT)).toBe(0);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/virtual.json
Source

src/algorithms/virtual.ts

/**
 * Which rows of a long list to draw: those in view, and a few beyond so that a
 * scroll reveals rows already there. A million rows cost the same to show as
 * forty.
 *
 * Rows of one size need only arithmetic. Rows of differing sizes are answered
 * by a Fenwick tree over their sizes (see `algorithms/fenwick.ts`).
 *
 * A browser will not lay out an element beyond a certain height โ€” some tens of
 * millions of pixels, differing by engine โ€” and a list longer than that cannot
 * be scrolled to its end. So a list is never made taller than `SCROLL_LIMIT`:
 * past it, the scroll position is a proportion of the way through, and each
 * pixel scrolled moves through more than a pixel of the list.
 */
import { fenwickFind, fenwickPrefix } from "./fenwick.ts";

export interface VirtualWindow {
  /** The first row to draw, and one past the last. */
  readonly first: number;
  readonly end: number;
  /** Where the first drawn row starts, from the top of the list. */
  readonly offset: number;
  /** The size of the whole list. */
  readonly size: number;
}

/** The window over `count` rows that are each `rowSize` tall. */
export function fixedWindow(
  count: number,
  rowSize: number,
  scrollOffset: number,
  viewportSize: number,
  overscan = 4,
): VirtualWindow {
  const first = Math.max(Math.floor(scrollOffset / rowSize) - overscan, 0);
  const end = Math.min(Math.ceil((scrollOffset + viewportSize) / rowSize) + overscan, count);
  return { first: Math.min(first, end), end, offset: Math.min(first, end) * rowSize, size: count * rowSize };
}

/** The window over rows whose sizes are held in a Fenwick tree. */
export function variableWindow(
  sizes: Float64Array,
  scrollOffset: number,
  viewportSize: number,
  overscan = 4,
): VirtualWindow {
  const count = sizes.length - 1;
  const first = Math.max(fenwickFind(sizes, Math.max(scrollOffset, 0)) - overscan, 0);
  const end = Math.min(fenwickFind(sizes, Math.max(scrollOffset + viewportSize, 0)) + 1 + overscan, count);
  const start = Math.min(first, end);
  return { first: start, end, offset: fenwickPrefix(sizes, start), size: fenwickPrefix(sizes, count) };
}

/**
 * The scroll offset that brings a row into view by the least movement: unchanged
 * if it is in view, else just far enough to show it at the nearer edge.
 */
export function scrollToReveal(
  start: number,
  size: number,
  scrollOffset: number,
  viewportSize: number,
): number {
  if (start < scrollOffset) return start;
  if (start + size > scrollOffset + viewportSize) return start + size - viewportSize;
  return scrollOffset;
}

/**
 * No scrolling element is made taller than this: within every engine's limit,
 * the lowest of which โ€” Firefox's โ€” is a little under nine million pixels.
 */
export const SCROLL_LIMIT = 5_000_000;

/** The height to give a scrolling element for a list of `size`. */
export function scrollSize(size: number, limit = SCROLL_LIMIT): number {
  return Math.min(size, limit);
}

/**
 * How far through a list of `size` a scroll position is, where the scrolling
 * element is `scrollSize(size)` tall. The same number until the list passes the
 * limit; a proportion beyond it, so that the last pixel scrolled is the list's end.
 */
export function listOffset(
  scrollOffset: number,
  size: number,
  viewportSize: number,
  limit = SCROLL_LIMIT,
): number {
  if (size <= limit) return scrollOffset;
  const scrollable = limit - viewportSize;
  if (scrollable <= 0) return 0;
  return Math.min(Math.max(scrollOffset / scrollable, 0), 1) * (size - viewportSize);
}

/** The scroll position that shows `listOffset`: the inverse of the above. */
export function scrollOffsetFor(
  offset: number,
  size: number,
  viewportSize: number,
  limit = SCROLL_LIMIT,
): number {
  if (size <= limit) return offset;
  const scrollable = size - viewportSize;
  if (scrollable <= 0) return 0;
  return Math.min(Math.max(offset / scrollable, 0), 1) * (limit - viewportSize);
}