Files
2026-07-08 10:43:39 -04:00

70 lines
2.5 KiB
TypeScript

/** Windowing math for the virtualized dashboard task table (500+ tasks).
*
* Pure functions only — the panel measures the scroll container / row height
* and renders `rows.slice(start, end)` between two grid-spanning spacers whose
* heights keep the scrollbar honest. The window start/end snap to a `step`
* grid so a 1-row scroll doesn't rebind the whole slice on every frame.
*
* Why windowing at all: the table's cross-row column alignment comes from CSS
* subgrid, which is incompatible with `content-visibility`/`size` containment
* (the cheap skip-offscreen-paint trick used elsewhere). So for large installs
* the initial O(n) render is paid on every keystroke/filter change unless the
* DOM itself is windowed.
*/
/** Rows below this count render normally — windowing has bookkeeping cost and
* only pays off when the O(n) render actually hurts. */
export const VIRTUAL_MIN_ROWS = 120;
export interface VirtualWindowInput {
/** Scroll offset of the scrolling container. */
scrollTop: number;
/** Visible (client) height of the scrolling container. */
viewportHeight: number;
/** Top of the table relative to the container's content (scrollTop space). */
listTop: number;
/** Measured uniform row height in px (clamped to >= 1). */
rowHeight: number;
/** Total row count. */
total: number;
/** Extra rows rendered above/below the viewport. Default 12. */
overscan?: number;
/** Start/end snap to this grid to avoid re-render churn. Default 6. */
step?: number;
}
export interface VirtualWindow {
start: number;
end: number;
padTop: number;
padBottom: number;
}
export function computeWindow(i: VirtualWindowInput): VirtualWindow {
if (i.total <= 0) return { start: 0, end: 0, padTop: 0, padBottom: 0 };
const overscan = i.overscan ?? 12;
const step = Math.max(1, i.step ?? 6);
const rh = Math.max(1, i.rowHeight);
const firstVisible = Math.floor((i.scrollTop - i.listTop) / rh);
const visibleCount = Math.ceil(i.viewportHeight / rh) + 1;
let start = Math.max(0, firstVisible - overscan);
start = Math.floor(start / step) * step;
let end = Math.min(i.total, Math.max(firstVisible, 0) + visibleCount + overscan);
end = Math.min(i.total, Math.ceil(end / step) * step);
// Degenerate inputs (table far above/below viewport): keep a sane window.
if (start >= end) {
start = Math.min(start, Math.max(0, i.total - 1));
end = Math.min(i.total, start + Math.max(visibleCount, 1));
}
return {
start,
end,
padTop: start * rh,
padBottom: (i.total - end) * rh,
};
}