perf: row-virtualize Timeline instead of per-month

Each month was a single virtual item that rendered all of its tiles, so
scrolling into a busy month mounted thousands of ImageTiles at once. Flatten
months into a fixed-height row list (header rows + tile rows of `cols` images),
mirroring the Gallery grid, so only on-screen rows render and thumbnails stream
in as you scroll. Active-month tracking and scrubber jump-to-month are remapped
to the flat row model.
This commit is contained in:
2026-06-21 08:47:48 +01:00
parent 1df75fd490
commit 21f6c30d25
+59 -35
View File
@@ -14,6 +14,11 @@ interface TimelineGroup {
images: ImageRecord[];
}
// One virtualized row: either a month header or a row of up to `cols` tiles.
type TimelineRow =
| { type: "header"; group: TimelineGroup }
| { type: "tiles"; images: ImageRecord[] };
interface ScrubberMonth {
monthNum: number;
label: string;
@@ -123,27 +128,46 @@ export function Timeline() {
[containerWidth, tileSize],
);
// Flatten the month groups into a single list of fixed-height rows — one
// header row per group, then one tile-row per `cols` images. This lets the
// virtualizer render only the on-screen rows, exactly like the Gallery.
// Previously each *group* was one virtual item that rendered ALL of its
// images, so scrolling into a busy month mounted thousands of tiles at once.
const { rows, rowToGroupIndex, groupFirstRow } = useMemo(() => {
const rows: TimelineRow[] = [];
const rowToGroupIndex: number[] = [];
const groupFirstRow: number[] = [];
groups.forEach((group, groupIndex) => {
groupFirstRow[groupIndex] = rows.length;
rows.push({ type: "header", group });
rowToGroupIndex.push(groupIndex);
for (let i = 0; i < group.images.length; i += cols) {
rows.push({ type: "tiles", images: group.images.slice(i, i + cols) });
rowToGroupIndex.push(groupIndex);
}
});
return { rows, rowToGroupIndex, groupFirstRow };
}, [groups, cols]);
const estimateSize = useCallback(
(index: number): number => {
const group = groups[index];
if (!group) return HEADER_HEIGHT;
const rowCount = Math.ceil(group.images.length / cols);
return HEADER_HEIGHT + rowCount * (tileSize + GAP);
},
[groups, cols, tileSize],
(index: number): number =>
rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP,
[rows, tileSize],
);
const virtualizer = useVirtualizer({
count: groups.length,
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 2,
overscan: 6,
paddingStart: GAP,
});
const groupsRef = useRef(groups);
groupsRef.current = groups;
const estimateSizeRef = useRef(estimateSize);
estimateSizeRef.current = estimateSize;
// Refs so the scroll handler can read the latest mappings without re-binding.
const rowToGroupIndexRef = useRef(rowToGroupIndex);
rowToGroupIndexRef.current = rowToGroupIndex;
const groupFirstRowRef = useRef(groupFirstRow);
groupFirstRowRef.current = groupFirstRow;
useEffect(() => {
virtualizer.measure();
@@ -153,28 +177,24 @@ export function Timeline() {
const el = parentRef.current;
if (!el) return;
// Active month = the group owning the first row still visible at the top.
const scrollTop = el.scrollTop;
const g = groupsRef.current;
const es = estimateSizeRef.current;
let cumHeight = 0;
let newActive = 0;
for (let i = 0; i < g.length; i++) {
const h = es(i);
if (cumHeight + h > scrollTop + HEADER_HEIGHT / 2) {
newActive = i;
const items = virtualizer.getVirtualItems();
let activeRow = items.length > 0 ? items[0].index : 0;
for (const item of items) {
if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) {
activeRow = item.index;
break;
}
cumHeight += h;
newActive = i;
}
setActiveGroupIndex(newActive);
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0);
if (el.scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (scrollTop < 24) return;
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
}
}, [images.length, loadMoreImages, loadingImages, totalImages]);
}, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => {
const el = parentRef.current;
@@ -200,8 +220,9 @@ export function Timeline() {
}, []);
const scrollToGroup = useCallback(
(index: number) => {
virtualizer.scrollToIndex(index, { align: "start" });
(groupIndex: number) => {
const row = groupFirstRowRef.current[groupIndex] ?? 0;
virtualizer.scrollToIndex(row, { align: "start" });
},
[virtualizer],
);
@@ -250,8 +271,8 @@ export function Timeline() {
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const group = groups[virtualItem.index];
if (!group) return null;
const row = rows[virtualItem.index];
if (!row) return null;
return (
<div
key={virtualItem.key}
@@ -262,19 +283,20 @@ export function Timeline() {
height: virtualItem.size,
}}
>
{row.type === "header" ? (
<div
className="flex items-center gap-3 px-4"
style={{ height: HEADER_HEIGHT }}
>
<span className="text-sm font-semibold text-white/80 shrink-0">
{group.label}
{row.group.label}
</span>
<span className="text-xs text-white/25 shrink-0 tabular-nums">
{group.images.length}
{row.group.images.length}
</span>
<div className="flex-1 h-px bg-white/[0.06]" />
</div>
) : (
<div
style={{
display: "grid",
@@ -283,9 +305,10 @@ export function Timeline() {
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
}}
>
{group.images.map((image) => (
{row.images.map((image) => (
<ImageTile
key={image.id}
image={image}
@@ -297,6 +320,7 @@ export function Timeline() {
/>
))}
</div>
)}
</div>
);
})}