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[]; 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 { interface ScrubberMonth {
monthNum: number; monthNum: number;
label: string; label: string;
@@ -123,27 +128,46 @@ export function Timeline() {
[containerWidth, tileSize], [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( const estimateSize = useCallback(
(index: number): number => { (index: number): number =>
const group = groups[index]; rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP,
if (!group) return HEADER_HEIGHT; [rows, tileSize],
const rowCount = Math.ceil(group.images.length / cols);
return HEADER_HEIGHT + rowCount * (tileSize + GAP);
},
[groups, cols, tileSize],
); );
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: groups.length, count: rows.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize, estimateSize,
overscan: 2, overscan: 6,
paddingStart: GAP,
}); });
const groupsRef = useRef(groups); // Refs so the scroll handler can read the latest mappings without re-binding.
groupsRef.current = groups; const rowToGroupIndexRef = useRef(rowToGroupIndex);
const estimateSizeRef = useRef(estimateSize); rowToGroupIndexRef.current = rowToGroupIndex;
estimateSizeRef.current = estimateSize; const groupFirstRowRef = useRef(groupFirstRow);
groupFirstRowRef.current = groupFirstRow;
useEffect(() => { useEffect(() => {
virtualizer.measure(); virtualizer.measure();
@@ -153,28 +177,24 @@ export function Timeline() {
const el = parentRef.current; const el = parentRef.current;
if (!el) return; if (!el) return;
// Active month = the group owning the first row still visible at the top.
const scrollTop = el.scrollTop; const scrollTop = el.scrollTop;
const g = groupsRef.current; const items = virtualizer.getVirtualItems();
const es = estimateSizeRef.current; let activeRow = items.length > 0 ? items[0].index : 0;
let cumHeight = 0; for (const item of items) {
let newActive = 0; if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) {
for (let i = 0; i < g.length; i++) { activeRow = item.index;
const h = es(i);
if (cumHeight + h > scrollTop + HEADER_HEIGHT / 2) {
newActive = i;
break; break;
} }
cumHeight += h;
newActive = i;
} }
setActiveGroupIndex(newActive); setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0);
if (el.scrollTop < 24) return; if (scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) { if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages(); void loadMoreImages();
} }
}, [images.length, loadMoreImages, loadingImages, totalImages]); }, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => { useEffect(() => {
const el = parentRef.current; const el = parentRef.current;
@@ -200,8 +220,9 @@ export function Timeline() {
}, []); }, []);
const scrollToGroup = useCallback( const scrollToGroup = useCallback(
(index: number) => { (groupIndex: number) => {
virtualizer.scrollToIndex(index, { align: "start" }); const row = groupFirstRowRef.current[groupIndex] ?? 0;
virtualizer.scrollToIndex(row, { align: "start" });
}, },
[virtualizer], [virtualizer],
); );
@@ -250,8 +271,8 @@ export function Timeline() {
) : ( ) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}> <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => { {virtualizer.getVirtualItems().map((virtualItem) => {
const group = groups[virtualItem.index]; const row = rows[virtualItem.index];
if (!group) return null; if (!row) return null;
return ( return (
<div <div
key={virtualItem.key} key={virtualItem.key}
@@ -262,19 +283,20 @@ export function Timeline() {
height: virtualItem.size, height: virtualItem.size,
}} }}
> >
{row.type === "header" ? (
<div <div
className="flex items-center gap-3 px-4" className="flex items-center gap-3 px-4"
style={{ height: HEADER_HEIGHT }} style={{ height: HEADER_HEIGHT }}
> >
<span className="text-sm font-semibold text-white/80 shrink-0"> <span className="text-sm font-semibold text-white/80 shrink-0">
{group.label} {row.group.label}
</span> </span>
<span className="text-xs text-white/25 shrink-0 tabular-nums"> <span className="text-xs text-white/25 shrink-0 tabular-nums">
{group.images.length} {row.group.images.length}
</span> </span>
<div className="flex-1 h-px bg-white/[0.06]" /> <div className="flex-1 h-px bg-white/[0.06]" />
</div> </div>
) : (
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -283,9 +305,10 @@ export function Timeline() {
paddingLeft: GAP, paddingLeft: GAP,
paddingRight: GAP, paddingRight: GAP,
paddingBottom: GAP, paddingBottom: GAP,
boxSizing: "border-box",
}} }}
> >
{group.images.map((image) => ( {row.images.map((image) => (
<ImageTile <ImageTile
key={image.id} key={image.id}
image={image} image={image}
@@ -297,6 +320,7 @@ export function Timeline() {
/> />
))} ))}
</div> </div>
)}
</div> </div>
); );
})} })}