feat(library): lay the ALL THUMBS wall out in justified rows per day, and stop clipping the frames at the pane's edges

This commit is contained in:
2026-10-13 11:10:57 +07:00
parent 326bcb0c5e
commit 1ca8ac6031
4 changed files with 698 additions and 678 deletions
+351 -353
View File
@@ -227,22 +227,29 @@ const WALL_LANES = 2;
// the one they are looking at. The wall mints the rest as it scrolls, which is
// who the queue was for.
const THUMB_AHEAD = 60;
// What `.adm-grid` leaves between its rows. A spacer has to stand the same
// distance past the last row it replaces as the row itself would have.
const WALL_GAP = 12;
// The two bands the wall opens a run of frames with: the month's own line, then
// the day's. Both are heights the style sheet fixes, and these are those two
// numbers (`.adm-month-head` / `.adm-day-head` in styles/app.css): the shelf's
// whole height is built out of them before a single card is drawn, which is what
// lets the spacer stand in for a month nobody has scrolled to.
const MONTH_BAND = 54;
const DAY_BAND = 28;
// How many windows the wall may write in a row before it is talking to itself.
// A window read off the window before it is a loop, and React answers the fifty
// turns of one with #185 and a blank screen; four is more than an honest change
// needs — a re-column takes two passes — and the count is handed back by the
// first scroll, resize or change of shelf the reader makes.
const WALL_CARRIES = 4;
// What the wall leaves between two frames, and between two rows of them: the wall
// is one shelf of pictures and not a grid of cards, so the gap is the one a
// contact sheet leaves — enough to tell two frames apart, little enough that a
// day still reads as a day.
const WALL_GAP = 3;
// The height a row of frames is justified to, and the two bands the wall opens a
// run of frames with: the month's own line, then the day's. The bands are heights
// the style sheet fixes, and these are those two numbers (`.adm-month-head` /
// `.adm-day-head` in styles/app.css); `target` is the wall's own, since a row is
// laid out to the width it is given and not to a height. The shelf's whole height
// is built out of the three before a single frame is drawn, which is what keeps
// the scrollbar the length of the roll.
const ROW_TARGET = 168;
const MONTH_BAND = 44;
const DAY_BAND = 30;
// The shelf's own stacking, read off the layout in one place and nowhere else.
const WALL_STEPS: WallSteps = { head: MONTH_BAND, band: DAY_BAND, target: ROW_TARGET, gap: WALL_GAP };
// The line the eye stands on, a few pixels under the pane's own top edge: the row
// a reader is reading is the row that carries that line, not the row the pane
// happens to cut through, and a band standing a few pixels above the fold is the
// band the pane is in. The ruler's needle reads the month of that row, and a jump
// to a month leaves its band exactly on the line.
const WALL_EYE = 8;
// The hours a frame's own clock can read, and what the time filter offers: the
// whole day in the 24-hour form a camera writes into EXIF.
const HOURS = Array.from({ length: 24 }, (_, i) => i);
@@ -367,7 +374,7 @@ export function Library() {
}
import { useIsMobile } from './ui/useIsMobile';
import { bandRows, dayAt, dayKey, dayLabel, dayRuns, monthKey, monthLabel, monthTitle, photoRow, rowAt, rowPhoto, rowTop, wallLayout, yearKey, type WallSteps } from './ui/dateGroups';
import { dayKey, dayLabel, dayRow, monthKey, monthLabel, monthTitle, rowAt, rowOf, wallLayout, WALL_AR, yearKey, type WallRow, type WallSteps } from './ui/dateGroups';
function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const { t, lang } = useI18n();
@@ -2051,32 +2058,86 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}
return { dayStarts: starts, dayHeads: heads };
}, [listed]);
const wall = useWallWindow(listed.length, dayStarts, dayHeads, view === 'grid');
// A row is justified to the wall's own width at `ROW_TARGET`, which needs the
// shape of every frame in it — and the shape is the one thing the catalogue
// cannot be asked for: a scan reads a frame's date and its size off the file,
// not its width and its height, and decoding a whole roll to find out is a shelf
// that takes an age to stand up. The picture itself says, on the frame the
// browser hands it over (`onAr`), and the wall lays the shelf out again with
// what it has said; a frame still unread stands at the average rectangle of the
// ones that are not, which is as little as a row can be wrong by before its own
// pictures land.
const [arsAt, setArsAt] = useState(0);
const arRaf = useRef(0);
const bumpArs = useCallback(() => {
// One bump a frame, whatever a screenful of pictures says: a set of state per
// load is a render per load, and a row of twenty is twenty of them.
if (arRaf.current) return;
arRaf.current = requestAnimationFrame(() => {
arRaf.current = 0;
setArsAt((n) => n + 1);
});
}, []);
useEffect(() => () => cancelAnimationFrame(arRaf.current), []);
const onAr = useCallback(
(id: string, ar: number) => {
if (!(ar > 0)) return;
const held = wallAr.get(id);
if (held !== undefined && Math.abs(held - ar) < 0.005) return;
wallAr.set(id, ar);
bumpArs();
},
[bumpArs]
);
const ars = useMemo(() => {
let sum = 0;
for (const ar of wallAr.values()) sum += ar;
const fallback = wallAr.size ? sum / wallAr.size : WALL_AR;
return listed.map((photo) => wallAr.get(photo.id) ?? fallback);
// `arsAt` is the shape map having grown: the numbers are read off it and not
// off the list, so nothing else here would make the shelf look again.
}, [listed, arsAt]);
// The width a row is laid in, and the shelf laid in it. `clientWidth` is the
// wall's own width and the scrollbar is not part of it, so a row justified to it
// meets the edge the reader sees.
const wallRef = useRef<HTMLDivElement>(null);
const wallWidth = useWallWidth(wallRef, view === 'grid');
const layout = useMemo(
() => wallLayout(dayStarts, dayHeads, ars, listed.length, wallWidth, WALL_STEPS),
[dayStarts, dayHeads, ars, listed.length, wallWidth]
);
const wall = useWallWindow(wallRef, layout.rows, view === 'grid');
// Memoised like `drawn`, and for a reason of its own: `useThumbUrls` keys its
// effect on the array it is handed, so a fresh slice every render made that
// effect run every render — a `setUrls` per pass, a render per `setUrls`, and
// thousands of passes a second in which the tiles of the rows scrolled to never
// land at all.
const painted = useMemo(
() => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)),
[listed, wall.from, wall.to]
);
// The same slice, cut into the days it holds: a wall is read by the day a frame
// was shot, and the line above each run is that day. Cut off the slice as it
// stands rather than sorted into days of its own, so the sort chip keeps the
// order it names; the first run may be a day that started above the window,
// which is its own header drawn where the eye lands (see `dayRuns`). A run the
// month changes at also carries the month's band, read off the shelf's own days
// (`dayHeads`) rather than off the slice, which begins wherever the window does.
const paintedBlocks = useMemo(() => {
const at = (photo: (typeof listed)[number]) => photo.taken || photo.addedAt;
let i = Math.min(wall.from, listed.length);
return dayRuns(painted, at).map((day) => {
const head = dayHeads[dayAt(dayStarts, i)] === 1;
i += day.photos.length;
return { day, head };
});
}, [painted, listed, wall.from, dayStarts, dayHeads]);
// land at all. The window is cut into the rows it draws, and the frames of those
// rows are the slice the thumbnail cache is handed.
// The same window, cut into the rows it draws: a wall is read by the day a frame
// was shot, and the line above each run of them is that day. A row of the layout
// is already what the DOM draws — a run of frames of one day, or the day's own
// band — so the two are never two answers to the same question, and the row a
// window starts in is a row the layout knows the top of (see `wallLayout`).
const wallRows = useMemo(() => {
const rows: { row: WallRow; at: number; photos: LibraryPhoto[]; head: boolean }[] = [];
const last = Math.min(wall.to, layout.rows.length);
for (let i = Math.min(wall.from, last); i < last; i++) {
const row = layout.rows[i];
const first = listed[row.from];
rows.push({
row,
at: first ? first.taken || first.addedAt : 0,
photos: row.to > row.from ? listed.slice(row.from, row.to) : [],
head: row.band && dayHeads[row.day] === 1,
});
}
return rows;
}, [layout, listed, wall.from, wall.to, dayHeads]);
const painted = useMemo(() => {
if (!wallRows.length) return [];
const first = wallRows[0].row.from;
return listed.slice(first, Math.max(first, wallRows[wallRows.length - 1].row.to));
}, [wallRows, listed]);
// The timeline's own cut of the shelf: a tick a year, and the months of it
// under it, on the same three levels the wall is banded by. Read off the whole
// shelf and not the window — the ruler stands for the roll, not for the screen.
@@ -2100,36 +2161,13 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}
return marks;
}, [listed, lang]);
// The month the needle stands in: the month of the day band the eye is under,
// written by the observer below. `''` before the wall has said anything, which
// leaves the needle at the top of the ruler.
const [eyeMonth, setEyeMonth] = useState('');
useEffect(() => {
const el = wall.ref.current;
if (view !== 'grid' || !el || !timeMarks.length) return;
// The band the eye is under, and not the first one in the DOM: the window
// draws a couple of rows above the fold as well, and a needle that read the
// first band drawn would stand two rows early. `-72%` off the bottom leaves
// the top three tenths of the wall as the band of the eye.
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const month = (entry.target as HTMLElement).dataset.month;
if (month) setEyeMonth(month);
}
},
{ root: el, rootMargin: '0px 0px -72% 0px', threshold: 0 }
);
for (const section of el.querySelectorAll<HTMLElement>('.adm-day')) io.observe(section);
return () => io.disconnect();
}, [view, wall.ref, wall.from, wall.to, timeMarks.length]);
const eyeAt = useMemo(() => {
const mark = timeMarks.find((m) => m.kind === 'month' && m.key === eyeMonth);
if (mark) return mark.at;
const first = listed[0];
return first ? first.taken || first.addedAt : 0;
}, [timeMarks, eyeMonth, listed]);
// The month the needle stands in: the row the eye is at, read off the shelf's
// own layout — the wall says which row the top of its viewport falls in — and
// not off the bands that happen to be drawn, which stand a couple of rows above
// the fold as well. `''` on an empty shelf, which leaves the needle at the top.
const eyePhoto = layout.rows.length ? listed[dayStarts[layout.rows[Math.min(wall.eye, layout.rows.length - 1)].day]] : undefined;
const eyeAt = eyePhoto ? eyePhoto.taken || eyePhoto.addedAt : 0;
const eyeMonth = eyeAt ? monthKey(eyeAt) : '';
// Going where the ruler is pressed: the first day of that month is brought to
// the top of the wall. The day's own top comes off the shelf's layout rather
// than off its box — the day is not in the DOM, which is the whole reason the
@@ -2137,17 +2175,16 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// never drawn.
const jumpToMonth = useCallback(
(at: number) => {
const el = wall.ref.current;
const el = wallRef.current;
if (!el || !listed.length) return;
const key = monthKey(at);
const k = dayStarts.findIndex((start) => monthKey(listed[start].taken || listed[start].addedAt) === key);
if (k < 0) return;
const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
const layout = wallLayout(dayStarts, dayHeads, listed.length, cols, wall.steps.current);
if (!(layout.tops[k] >= 0)) return;
el.scrollTop = Math.max(0, layout.tops[k] - 6);
const row = layout.rows[dayRow(layout.rows, k)];
if (!row) return;
el.scrollTop = Math.max(0, row.top - WALL_EYE);
},
[listed, dayStarts, dayHeads, wall.ref, wall.steps]
[listed, dayStarts, layout, wallRef]
);
// A tile the studio graded while this screen was dark: the copy in hand is the
// negative's, and `onThumbWritten` hands over the one that replaces it. The
@@ -2221,17 +2258,16 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}, [view, frame?.id, listed, node, strip.ref]);
// And the wall's half of it: the frame the strip was on, brought to the middle
// of the wall. The card is not in the DOM to begin with — the wall only paints
// the rows around its viewport — so the first passes go to where the row should
// be and the ones after that read the card itself, which is the only honest
// measure: a row stands as tall as its longest caption. The loop is bounded
// rather than driven by state, because the wall reports its window by
// re-rendering and a scroll set inside a render is a scroll the next render
// undoes.
// of the wall. The card is not in the DOM to begin with — the wall only draws
// the rows around its viewport — so a pass with no card goes to the row the
// frame is drawn in, read off the shelf's own layout, and the pass after that
// reads the card itself. The loop is bounded rather than driven by state,
// because the wall reports its window by re-rendering and a scroll set inside a
// render is a scroll the next render undoes.
useEffect(() => {
if (view !== 'grid') return;
const id = centredRef.current;
const el = wall.ref.current;
const el = wallRef.current;
if (!id || !el) return;
const idx = listed.findIndex((p) => p.id === id);
if (idx < 0) {
@@ -2247,29 +2283,9 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const box = el.getBoundingClientRect();
const own = card.getBoundingClientRect();
el.scrollTop = Math.max(0, el.scrollTop + own.top - box.top + own.height / 2 - el.clientHeight / 2);
} else {
// No card drawn: the wall opens on the first screenful, so aim at the row
// the frame stands in and let the next pass find it. The shelf's own steps
// and the rows its days are counted in are the wall's, read off the same
// layout its window is measured against — a step of this effect's own is a
// second answer to a question the wall has already answered.
const first = el.querySelector<HTMLElement>('.adm-card');
const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
const steps = wall.steps.current;
if (steps.row > 0) {
const layout = wallLayout(dayStarts, dayHeads, listed.length, cols, steps);
const row = photoRow(dayStarts, cols, layout.order, idx);
el.scrollTop = Math.max(0, Math.round(rowTop(layout, row) - (el.clientHeight - steps.row) / 2));
} else {
// A step guessed at, for the one pass before the wall has drawn a card
// to measure: the row the frame stands in is counted over the shelf's own
// bands, not over its own index — every day spends a row above its first
// frame.
const pitch = first ? first.offsetHeight + WALL_GAP : 240;
const order = bandRows(dayStarts, listed.length, cols);
const row = photoRow(dayStarts, cols, order, idx);
el.scrollTop = Math.max(0, Math.round(row * pitch - (el.clientHeight - pitch) / 2));
}
} else if (layout.rows.length) {
const row = layout.rows[rowOf(layout.rows, idx)];
el.scrollTop = Math.max(0, Math.round(row.top - (el.clientHeight - row.height) / 2));
}
if (--left > 0) requestAnimationFrame(settle);
else centredRef.current = null;
@@ -2281,7 +2297,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// `up` is in the list for the frame the studio hands back: it is the one that
// moves when the shelf comes up on another frame, and a wall standing where it
// already was is a wall that would not follow it.
}, [view, listed, dayStarts, wall.ref, up]);
}, [view, listed, layout, wallRef, up]);
// This screen's own comings and goings: it never unmounts, so `here` is the
// only sign of the shelf being the screen up again (see `here` above).
@@ -2323,7 +2339,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// looked up in `listed` too, because a pick made in another folder is not a
// frame this shelf can raise.
const openFromWall = () => {
const el = wall.ref.current;
const el = wallRef.current;
const picked =
lastSelectedId && selectedIds.has(lastSelectedId) && listed.some((p) => p.id === lastSelectedId)
? lastSelectedId
@@ -2633,13 +2649,14 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// mint them), and the browser already keeps what it fetched for the day
// `/api/immich/thumb` says it may — so the wall scrolls on and the visit after
// draws without asking twice.
const tile = (photo: LibraryPhoto, onPick?: () => void) => {
const tile = (photo: LibraryPhoto, onPick?: () => void, onAr?: (ar: number) => void) => {
const src = urls[photo.id] ?? (photo.remoteId ? remoteUrl('thumbnail', photo) : null);
return src ? (
<TileImg
src={src}
rot={photo.rot ?? 0}
onPick={onPick}
onAr={onAr}
title={onPick ? t('lib.open') : undefined}
/>
) : (
@@ -3243,99 +3260,101 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
<>
{/* The wall is the shelf's own length, so the scrollbar on
its right edge still reaches every frame; what is drawn
is the rows around the eye, with a spacer standing in
for the rows above and below. A card is a card, an
<img> and an object URL of its own, and twenty thousand
of them is a dead screen.
The wall is read the way the phone's own grid reads a
shelf and the way a cloud library does: the months down
it, and inside a month the days, one under the other —
the band of a day over the frames of that day, so a roll
is scanned by the month it was shot. The rows the eye
is not in stand as two spacers, which is what the ruler
beside the wall is pressed against. */}
is the rows around the eye. The rows stand at their own
tops inside a body that holds the shelf's whole height —
a height that comes off the layout and not off the DOM —
so the shelf never moves under the reader, the frames at
the top and the bottom of the window are never stood
over, and the last row of the roll is a row the
scrollbar can reach.
A roll is read the way a contact sheet is: a day is a
line with the frames of that day under it, packed into
rows justified to the wall's own width, so the frames of
a day stand together, meet both edges, and keep their
own proportions. */}
<div className="lib-wall-row">
<div className="adm-grid" ref={wall.ref}>
{wall.above > 0 ? <span className="adm-wall-gap" style={{ height: wall.above }} aria-hidden="true" /> : null}
{paintedBlocks.map(({ day, head }) => (
<section
className="adm-day"
key={day.key}
data-key={`lib-day-${day.key}`}
data-month={monthKey(day.at)}
>
{/* The month above the first day it holds, and the
day above its frames. The day's own line still
says the whole date — a card is read on its own
once picked — but the month is named once. */}
{head ? (
<h2 className="adm-month-head" data-key={`lib-month-${monthKey(day.at)}`}>
<span>{monthTitle(day.at, lang)}</span>
</h2>
) : null}
<h3 className="adm-day-head">
<span>{dayLabel(day.at, lang)}</span>
<span className="adm-day-line" aria-hidden="true" />
</h3>
<div className="adm-day-grid">
{day.photos.map((photo) => (
<article
className={`adm-card${selectedIds.has(photo.id) ? ' selected' : ''}`}
key={photo.id}
data-key={`lib-photo-${photo.id}`}
onClick={(e) => handleSelectPhoto(photo, e)}
onDoubleClick={() => {
setUp(photo.id);
scrolledFrameRef.current = null;
setView('preview');
}}
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
>
{tile(photo)}
{/* The score under the picture, the same five stars the
stage gives: the wall is where a roll is gone
through, and going back to the stage for every
number is the trip this saves. The click stops at
the star — a score is not a pick, and the card's
own click is what selects the frame. */}
<div
className="lib-stars"
data-key={`lib-stars-${photo.id}`}
role="group"
aria-label={t('lib.rating')}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
data-key={`lib-star-${photo.id}-${n}`}
aria-pressed={(photo.star ?? 0) >= n}
aria-label={t('lib.starHint', { n })}
title={t('lib.starHint', { n })}
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
<div className="adm-grid" ref={wallRef}>
<div className="adm-wall-body" style={{ height: layout.height }}>
{wallRows.map(({ row, at, photos, head }) => (
<div
className={`adm-row${row.band ? ' adm-band' : ''}`}
key={row.band ? `h${row.day}` : `r${row.day}-${row.from}`}
data-key={row.band ? `lib-day-${dayKey(at)}` : undefined}
data-day={dayKey(at)}
style={{ top: row.top, height: row.height }}
>
{row.band ? (
<>
{/* The month above the first day it holds, and
the day over its frames: the month is
named once, and the day under it is the
line its frames are read by. */}
{head ? (
<h2 className="adm-month-head" data-key={`lib-month-${monthKey(at)}`}>
<span>{monthTitle(at, lang)}</span>
</h2>
) : null}
<h3 className="adm-day-head">
<i className="adm-day-dot" aria-hidden="true" />
<span>{dayLabel(at, lang)}</span>
</h3>
</>
) : (
photos.map((photo, j) => (
<article
className={`lib-tile${selectedIds.has(photo.id) ? ' selected' : ''}`}
key={photo.id}
data-key={`lib-photo-${photo.id}`}
style={{ width: ars[row.from + j] * row.height }}
title={`${photo.name} · ${new Date(photo.taken || photo.addedAt).toLocaleDateString()} · ${t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}`}
onClick={(e) => handleSelectPhoto(photo, e)}
onDoubleClick={() => {
setUp(photo.id);
scrolledFrameRef.current = null;
setView('preview');
}}
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
>
{tile(photo, undefined, (ar) => onAr(photo.id, ar))}
{/* The score over the picture, the same five
stars the stage gives: the wall is where a
roll is gone through, and going back to the
stage for every number is the trip this
saves. It stands on the frame the pointer
is on, and stays on a frame that has a
score. The click stops at the star — a
score is not a pick, and the tile's own
click is what selects the frame. */}
<div
className={`lib-stars${(photo.star ?? 0) > 0 ? ' on' : ''}`}
data-key={`lib-stars-${photo.id}`}
role="group"
aria-label={t('lib.rating')}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
★
</button>
))}
</div>
<div className="adm-meta">
<b>{photo.name}</b>
<span>
{photo.star ? `★${photo.star} · ` : ''}
{new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
</span>
</div>
</article>
))}
</div>
</section>
))}
{wall.below > 0 ? <span className="adm-wall-gap" style={{ height: wall.below }} aria-hidden="true" /> : null}
</div>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
data-key={`lib-star-${photo.id}-${n}`}
aria-pressed={(photo.star ?? 0) >= n}
aria-label={t('lib.starHint', { n })}
title={t('lib.starHint', { n })}
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
>
★
</button>
))}
</div>
</article>
))
)}
</div>
))}
</div>
</div>
{/* The shelf's own time, down its right edge: a year is the
tick that is named, the months of it are the ticks under
it, and the band the eye is in is the needle. */}
@@ -4063,10 +4082,49 @@ function useTurned(src: string | null, deg: number): string | null {
// turn the stage does, so a frame the reader has stood upright is not lying on
// its side two rows down. One canvas per turned frame and none at all for the
// rest — a tile at 0 hands its URL straight on.
function TileImg({ src, rot, onPick, title }: { src: string; rot: number; onPick?: () => void; title?: string }) {
return <img src={useTurned(src, rot) ?? src} alt="" loading="lazy" onClick={onPick} title={title} />;
// The picture is also the one thing that knows how wide a frame is against its
// own height, which is what a justified row is laid out with: `onAr` is handed
// the ratio on the frame the browser decodes it, and the wall lays the shelf out
// again with it (`onAr` in the catalogue).
function TileImg({
src,
rot,
onPick,
onAr,
title,
}: {
src: string;
rot: number;
onPick?: () => void;
onAr?: (ar: number) => void;
title?: string;
}) {
return (
<img
src={useTurned(src, rot) ?? src}
alt=""
loading="lazy"
onClick={onPick}
title={title}
onLoad={
onAr
? (e) => {
const img = e.currentTarget;
if (img.naturalWidth > 0 && img.naturalHeight > 0) onAr(img.naturalWidth / img.naturalHeight);
}
: undefined
}
/>
);
}
// The shape of the frames the wall has been shown, by the frame's own id, for the
// length of the visit: the catalogue cannot be asked for one — a scan reads a date
// and a size off a file, not a width and a height — and a row is justified to the
// wall's width, which is a sum of shapes. Kept past the row that measured it, so a
// frame scrolled out of the wall and back is not measured again.
const wallAr = new Map<string, number>();
// One object URL per tile, made when the row does and dropped with it: a folder
const globalThumbUrls = new Map<string, { url: string; blob: Blob }>();
const GLOBAL_URL_MAX = 600;
@@ -4222,179 +4280,119 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject<HTMLDivEle
}
// Which rows of the wall are drawn: the ones under its viewport and `WALL_ROWS`
// either side, with a spacer standing in for the rest so the scrollbar still spans
// the shelf. The wall holds every frame the open node does — a filter is a way of
// looking, not a second shelf — and a card is a card, an `<img>` and an object URL
// of its own; twenty thousand of them is a dead screen for as long as they take to
// build. `on` is the wall being up, since the element measured is its own.
function useWallWindow(
count: number,
dayStarts: number[],
heads: number[],
on: boolean
): {
ref: RefObject<HTMLDivElement>;
from: number;
to: number;
above: number;
below: number;
// The steps the shelf is stacked in, which the wall reads off the cards it has
// drawn and keeps. Handed back as the ref itself, not its value: the settle
// effect aims a scroll with them on the pass after the one that measured them.
steps: { current: WallSteps };
} {
const ref = useRef<HTMLDivElement>(null);
// The first window is a screenful: the wall has not been measured yet.
const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, 24), above: 0, below: 0 }));
// The window last written, because the reader of a `setWin` in a layout effect
// is not the state: a set made from a layout effect renders again whether or
// not the value moved, and this one is remeasured after every render — so a set
// it did not need is the screen's own render loop (React #185, "Maximum update
// depth exceeded"). The ref is the guard: nothing is set that the wall already
// stands at.
const winRef = useRef(win);
const measureRef = useRef<() => void>(() => {});
// The two steps the shelf is stacked in, taken once per column count and kept.
// They are refs rather than values read on every pass because the pass reads
// them off the rows the window drew — and a step that moves with the window is
// a window that moves with the step: the two trade places forever (see
// `measure`).
const stepsRef = useRef<WallSteps>({ head: MONTH_BAND, band: DAY_BAND, row: 0, gap: WALL_GAP });
const colsRef = useRef(0);
// The window over a scroll container is taken rather than kept, so it is taken
// after every render: the first pass draws a screenful, the next reads the row's
// step off it and lays out the spacers, and the pass after that leaves both
// alone. The listeners are for the two things no render can see coming — a
// scroll of the wall, and the window itself changing size.
useEffect(() => {
// The width a row of the wall is laid in: the wall's own `clientWidth`, which is
// the box inside the scrollbar and not the one past it, so a row justified to it
// meets the edge the reader sees. Measured and not assumed — the pane is resized
// by the window and by the tree beside it, and a shelf laid in a width it no
// longer has is a row that runs past the wall or stops short of it.
function useWallWidth(ref: RefObject<HTMLDivElement>, on: boolean): number {
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
if (!on) return;
const el = ref.current;
if (!el) return;
const read = () => setWidth((prev) => (prev === el.clientWidth ? prev : el.clientWidth));
read();
const ro = new ResizeObserver(read);
ro.observe(el);
return () => ro.disconnect();
}, [ref, on]);
return width;
}
// Which rows of the wall are drawn: the ones under its viewport and `WALL_ROWS`
// either side. The rows not drawn are not in the DOM at all — the shelf's own
// height stands in the body above them, off the layout (`wallLayout`), so the
// scrollbar still spans the roll — and a card is a card, an `<img>` and an object
// URL of its own; twenty thousand of them is a dead screen for as long as they
// take to build. `on` is the wall being up, since the element measured is its own.
//
// The window is reported in rows and not as a slice of the shelf, because a row is
// what the wall draws and the row a scroll position falls in is a question the
// layout answers (`rowAt`). `eye` is that same row at the top of the viewport,
// which is the row the ruler's needle reads off.
function useWallWindow(
ref: RefObject<HTMLDivElement>,
rows: WallRow[],
on: boolean
): { from: number; to: number; eye: number } {
const [win, setWin] = useState({ from: 0, to: 0 });
const [eye, setEye] = useState(0);
// What was last written, because the reader of a `setWin` in a layout effect is
// not the state: a set made from one renders again whether or not the value
// moved, and this one is measured after every render — so a set it did not need
// is the screen's own render loop (React #185, "Maximum update depth exceeded").
// The refs are the guard: nothing is set that the wall already stands at.
const winRef = useRef(win);
const eyeRef = useRef(0);
const measureRef = useRef<() => void>(() => {});
useLayoutEffect(() => {
if (!on) return;
const el = ref.current;
if (!el) return;
// The window is only ever set through here: a `setWin` the wall already
// stands at is a render bought for nothing, and this is measured after every
// render — that is the loop (React #185).
// And the second guard, for a reader the first one cannot see: a window
// written off the window before it is a window that can write itself, and
// whatever the shelf does between the two there are only so many turns of
// that before React gives up with #185 — a blank screen, this app having no
// error boundary between it and the floor. `ponytail:` past the count the
// wall stands still, one window behind, until something outside moves — the
// next scroll, resize, or count hands the pen straight back.
let carries = 0;
const show = (next: { from: number; to: number; above: number; below: number }) => {
const prev = winRef.current;
if (prev.from === next.from && prev.to === next.to && prev.above === next.above && prev.below === next.below) return;
if (carries >= WALL_CARRIES) return;
carries++;
winRef.current = next;
setWin(next);
};
const measure = () => {
// The window is measured off the wall that is up, not off the one this
// effect was set up on: the stage is drawn while a node is open, so a visit
// that loses its nodes and gets them back draws a new wall, and a window
// measured off the old node — a box of nothing, a scroll always nought — is
// a window that never moves again for the rest of the visit.
// The wall that is up, and not the one this effect was set up on: the stage
// is drawn while a node is open, so a visit that loses its nodes and gets
// them back draws a new wall, and a window measured off the box it replaced
// is a window read off nothing.
const now = ref.current;
if (!now) return;
// A wall that is not standing has no box to measure: the library is
// `display:none` while the studio is up, and a window read off it would be
// a window read off nothing — every card reports `offsetTop` 0 and every
// scroll 0 — which threw the shelf back to its top every time the studio
// handed back to it. It is read on the frame it comes back up, which is a
// box change.
// A wall that is not standing has no box: the library is `display: none`
// while the studio is up, and every scroll read off it is nought — which
// threw the shelf back to its top every time the studio handed back to it.
// It is read again on the frame it comes back up, which is a box change.
if (!now.clientHeight) return;
// And a window that is nowhere on the shelf stands nowhere: outside a shelf
// that shrank under it — another folder, a filter, a shorter roll — or empty
// while the shelf is not, which is the first paint, before the catalogue has
// landed. This is the one reset, and it is written off the shelf's own
// length rather than off the cards the last window drew, which is what made
// the reset a window the window fed.
const held = winRef.current.to - winRef.current.from;
if (held <= 0 || winRef.current.from >= count || winRef.current.to > count) {
show({ from: 0, to: Math.min(count, 24), above: 0, below: 0 });
if (!rows.length) {
if (winRef.current.from || winRef.current.to) {
winRef.current = { from: 0, to: 0 };
setWin(winRef.current);
}
return;
}
// How many columns is the grid's own business, read off its layout and not
// counted off the cards the window drew: the drawn run is a whole number of
// rows, those rows were read back into the count, and a count read off the
// run is a count the window feeds itself with. This is the same read the
// settle effect above scrolls the wall by.
const cols = Math.max(1, getComputedStyle(now).gridTemplateColumns.split(' ').length);
// The rows the window drew, counted the way the wall lays them out: a day's
// band is a row of its own (`grid-column: 1 / -1`) and its frames fill the
// rows under it. A row count taken off the cards alone is a count that
// leaves out every band — and the bands are what the rows are counted in.
const sections = [...now.querySelectorAll<HTMLElement>('.adm-day')];
// The one step left to read off the drawn days: a card and the grid's own
// gap — the two bands are heights the style sheet fixes (`MONTH_BAND`,
// `DAY_BAND`), so a shelf is measured exactly from the first card on and
// the spacers do not move with the window. Read the first time the wall
// stands at this many columns, and kept until it stands at another: a step
// re-read on every pass made the window depend on itself — `to` traded
// places with a second sum a row away, sixty times a second, until React
// gave up with #185 and the screen went blank. One step, one window. It is
// re-read when the wall re-columns, the only thing that changes a card's
// own height (a caption wraps at the width it is given); a resize that does
// not re-column keeps the step, and a spacer a few pixels out is a
// scrollbar a few pixels out, not a blank screen.
if (colsRef.current !== cols) {
const card = sections[0]?.querySelector<HTMLElement>('.adm-card');
if (card) {
const row = card.offsetHeight + WALL_GAP;
if (row > 0) {
colsRef.current = cols;
stepsRef.current = { head: MONTH_BAND, band: DAY_BAND, row, gap: WALL_GAP };
}
}
// A window that is nowhere on the shelf — a shorter roll under it, or the
// first paint, before the catalogue has landed — stands nowhere: it opens at
// the top. Written off the shelf's own rows and not off the window before it,
// which is what made the reset a window the window fed.
if (winRef.current.from >= rows.length || winRef.current.to > rows.length) {
winRef.current = { from: 0, to: 0 };
setWin(winRef.current);
}
const from = Math.max(0, rowAt(rows, now.scrollTop) - WALL_ROWS);
const to = Math.min(rows.length, rowAt(rows, now.scrollTop + now.clientHeight) + 1 + WALL_ROWS);
if (winRef.current.from !== from || winRef.current.to !== to) {
winRef.current = { from, to };
setWin({ from, to });
}
const at = Math.max(0, Math.min(rows.length - 1, rowAt(rows, now.scrollTop + WALL_EYE)));
if (eyeRef.current !== at) {
eyeRef.current = at;
setEye(at);
}
// And a run with no step to read — a shelf of one day whose only card is
// not in the DOM yet, a wall of four frames — has none to measure with. It
// is left to the pass after this one, which has the cards: returning here
// instead is a window that cannot grow, and there is a shelf it strands —
// the reset above puts a year of four frames at {0, 4}, and every pass after
// it reads that one row and leaves, so the shelf stands at four cards for
// the rest of the visit.
const steps = stepsRef.current;
if (!(steps.row > 0) || !dayStarts.length) return;
// The shelf measured in those steps: the two spacers, the rows drawn, and
// the scrollbar between them are all read off the same layout, so the
// shelf's own height does not move with the window.
const layout = wallLayout(dayStarts, heads, count, cols, steps);
const row0 = Math.max(0, rowAt(layout, now.scrollTop) - WALL_ROWS);
const row1 = Math.min(layout.rows, rowAt(layout, now.scrollTop + now.clientHeight) + WALL_ROWS);
const from = rowPhoto(dayStarts, count, cols, layout.order, row0);
const to = Math.max(from, rowPhoto(dayStarts, count, cols, layout.order, row1));
// What the two spacers stand for: the days above the window, and the days
// under it. A spacer's own height keeps the gap the grid would have left
// under the last day it stands in for, which is why the gap comes off both.
const first = dayAt(dayStarts, from);
const last = dayAt(dayStarts, Math.max(from, to - 1));
const above = first ? Math.max(0, layout.tops[first] - WALL_GAP) : 0;
const below = Math.max(0, layout.height - layout.ends[last] - WALL_GAP);
show({ from, to, above, below });
};
measureRef.current = measure;
const onOutside = () => {
carries = 0;
measure();
};
el.addEventListener('scroll', onOutside, { passive: true });
const ro = new ResizeObserver(onOutside);
// Read here as well as after every render: this runs before the first paint,
// so the wall opens on the rows the eye is in and not on a screen of nothing.
measure();
el.addEventListener('scroll', measure, { passive: true });
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => {
measureRef.current = () => {};
el.removeEventListener('scroll', onOutside);
el.removeEventListener('scroll', measure);
ro.disconnect();
};
}, [count, dayStarts, heads, on]);
}, [rows, ref, on]);
useLayoutEffect(() => {
// And again after every render: the shelf is laid out again when the wall is
// resized, when a frame's shape lands, and when the roll itself changes, and
// none of the three is a thing this effect can see coming. What is set is only
// what the wall is not already at (above).
measureRef.current();
});
return { ref, from: win.from, to: win.to, above: win.above, below: win.below, steps: stepsRef };
return { from: win.from, to: win.to, eye };
}
// One tick of the wall's own time: a year, or a month of one. Built off the
// shelf and not off a calendar — a month the roll holds nothing in is a month
// with no tick, which is what keeps the ruler the length of the roll.
+64 -31
View File
@@ -1249,9 +1249,6 @@ form[data-key='immich-modal'] {
It is given the width they would have taken, and it must not shrink, or the
scrollbar would measure the window instead of the shelf. */
.adm-thumb-gap { flex: 0 0 auto; }
/* The wall's stand-in for the rows it is not drawing: a run of cards' worth of
height, spanning the whole grid so nothing lands beside it. */
.adm-wall-gap { grid-column: 1 / -1; }
.adm-thumb {
flex: 0 0 132px;
margin: 0;
@@ -1312,44 +1309,36 @@ form[data-key='immich-modal'] {
.adm-note { margin: 12px 0; }
.adm-sub { margin: 0 0 14px; }
.adm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
/* A day of the wall: the band that names it and the rows under it. It spans the
whole grid — the wall is one column of days, not a day per cell — and the rows
inside are the wall's own grid again, so the cards keep the columns they had
when the wall drew them flat. */
.adm-day { grid-column: 1 / -1; }
.adm-day-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
/* The month's own band, over the first day it holds: a heavier line than a day's,
and the only line of the wall that is not a date — so a shelf is read by the
month it was shot and only then by the day. Its height is fixed (54px, the
`MONTH_BAND` the wall measures its shelf with): the wall stands in spacers for
the months it has not drawn, and a band that grew with its own text is a shelf
whose end moves. */
month it was shot and only then by the day. Its height is fixed (44px, the
`MONTH_BAND` the wall lays its shelf out with): the shelf's whole height is
built out of it before a frame is drawn, and a band that grew with its own text
is a shelf whose end moves. */
.adm-month-head {
height: 54px;
height: 44px;
display: flex;
align-items: flex-end;
margin: 0;
padding-bottom: 8px;
font-size: 19px;
padding-bottom: 6px;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.01em;
color: var(--text);
}
/* A day's own line under it, at the height the wall counts it in (`DAY_BAND`). */
/* The day a run of frames was shot on, over the frames of it: the small line and
the dot it is read by, at the height the wall lays it in (`DAY_BAND`). */
.adm-day-head {
height: 28px;
height: 30px;
display: flex;
align-items: center;
gap: 10px;
gap: 7px;
margin: 0;
font-size: 12px;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-dim, #6b7280);
}
/* The rule that carries the eye from the date to the edge of the wall. */
.adm-day-line { flex: 1 1 auto; height: 1px; background: var(--border); }
.adm-day-dot { flex: 0 0 auto; width: 5px; height: 5px; border-radius: 50%; background: currentColor; opacity: 0.7; }
.adm-card {
display: flex;
flex-direction: column;
@@ -1498,12 +1487,56 @@ form[data-key='immich-modal'] {
.lib-root .adm-stage > .chip-row { display: flex; justify-content: center; }
}
/* All thumbs: the wall scrolls down inside the stage, and its own scrollbar on
the right edge is what reaches the frames below. */
/* `align-content: start`, because the grid is stretched to the stage: a wall with
fewer frames than there are columns is one row, and a stretched row makes every
card's border that much taller than the picture inside it. Rows keep the height
their own frames give them however few of them there are. */
.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; overflow-anchor: none; align-content: start; }
the right edge is what reaches the frames below. It is a plain scroll box and
not a grid of its own: the rows inside it are laid out at the tops the shelf's
own layout gives them (see `wallLayout` in ui/dateGroups.ts), and the box's
height is that layout's, so the scrollbar spans the roll and not the screen. */
.lib-root .adm-grid { display: block; position: relative; flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0; overflow-anchor: none; }
/* The shelf's whole height, which the rows are laid in. The rows are out of the
flow — absolute, at the top the layout gave each of them — so this element is
what the scrollbar measures, and it stands the layout's height exactly: a height
read off the rows drawn is a scrollbar that moves with the window, and the last
frames of a roll are then frames nobody can scroll to. */
.adm-wall-body { position: relative; width: 100%; }
/* A row of the wall: a run of frames of one day, meeting both edges — the widths
come off the layout, which packed them to this wall — with the gap a contact
sheet leaves (the `WALL_GAP` in Library.tsx, the same 3px). */
.adm-row { position: absolute; left: 0; right: 0; display: flex; gap: 3px; }
/* The month's band and the day's, which are rows of the shelf too, and stand one
over the other inside the band row. */
.adm-row.adm-band { display: block; }
/* A frame on the wall: the picture and nothing else. The wall is read as one shelf
of photographs, so a tile carries no frame of its own, no padding and no caption
— the name and the date are on the tile's own title — and the picture fills the
box the layout gave it, cropped to it rather than letter-boxed, since the box is
the frame's own proportions to within a pixel or two. */
.lib-tile {
position: relative;
margin: 0;
height: 100%;
overflow: hidden;
border-radius: 4px;
background: var(--bg-sunken);
cursor: pointer;
}
.lib-tile img { display: block; width: 100%; height: 100%; object-fit: cover; }
.lib-tile .lib-noimg { width: 100%; height: 100%; aspect-ratio: auto; border-radius: 0; }
.lib-tile.selected { outline: 2px solid var(--accent); outline-offset: -2px; }
/* The score, over the picture: on the frame the pointer is on, and left standing
on a frame that has one. The click stops at the star, so the tile's own click —
the pick — is not what a score costs. */
.lib-tile .lib-stars {
position: absolute;
right: 2px;
bottom: 2px;
padding: 0 3px;
border-radius: 6px;
background: color-mix(in srgb, var(--bg) 72%, transparent);
opacity: 0;
}
.lib-tile:hover .lib-stars,
.lib-tile .lib-stars.on { opacity: 1; }
.lib-tile .lib-star { font-size: 12px; padding: 1px 2px; }
/* The wall and the ruler beside it: the wall takes what is left, the ruler is
the fixed strip down the right edge. The row is the stage's own middle track —
it carries the height the grid used to carry alone, since the grid now shares
@@ -1521,7 +1554,7 @@ form[data-key='immich-modal'] {
/* A year's own field, the months of it standing inside it: what makes the ruler
read by the year first and the month under it after. */
.lib-ruler-band { position: absolute; left: 0; right: 0; background: var(--bg-elev); border-radius: 3px; }
.lib-ruler-tick { position: absolute; left: 0; width: 10px; height: 1px; background: var(--border); }
.lib-ruler-tick { position: absolute; left: 0; width: 10px; height: 1px; background: color-mix(in srgb, var(--text-dim) 30%, transparent); }
.lib-ruler-year { width: 100%; height: 2px; background: var(--text-dim); }
.lib-ruler-year i { position: absolute; top: 2px; left: 14px; font-size: 9px; font-style: normal; font-weight: 700; letter-spacing: 0.06em; color: var(--text-dim); }
.lib-ruler-tick.on { width: 22px; background: var(--accent); }
+87 -167
View File
@@ -46,203 +46,123 @@ export function monthTitle(timestamp: number, lang: Lang): string {
return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
}
export type DayRun<T> = { key: string; at: number; photos: T[] };
// The steps a shelf is stacked in: the month's own band (`head`) and the day's
// (`band`) above the frames of a day, the height a row of frames is justified to
// (`target`), and what stands between two rows (`gap`). The bands are heights the
// style sheet fixes (`MONTH_BAND` / `DAY_BAND` in Library.tsx are the same two
// numbers), and `target` is a number of the wall's own, so the shelf's whole
// height is known before a frame is drawn — which is what lets the scrollbar
// reach the frames nobody has scrolled to yet.
export type WallSteps = { head: number; band: number; target: number; gap: number };
// The runs of one day, cut off the list as it stands rather than sorted into
// days of their own: the wall carries the reader's sort, and a shelf ordered by
// name is a shelf whose days interleave — a header is put where the day changes,
// and the order of the frames is never touched.
export function dayRuns<T>(photos: T[], at: (photo: T) => number): DayRun<T>[] {
const runs: DayRun<T>[] = [];
for (const photo of photos) {
const time = at(photo);
const key = dayKey(time);
const last = runs[runs.length - 1];
if (last?.key === key) last.photos.push(photo);
else runs.push({ key, at: time, photos: [photo] });
}
return runs;
}
// The rows a shelf of days stands in, which is not `count / cols`: a day is a
// band of its own above its frames — the band spans the grid (`grid-column:
// 1 / -1`) and the frames start on the row after it — so every day spends one
// whole row on its band, and a day whose frames do not come out even over the
// columns leaves the rest of its last row empty, since the next day's band
// starts a row of its own. Counted flat, the model stands for fewer rows than
// the DOM draws, and what stands in for the rows not drawn is a spacer over the
// frames below the fold that were never drawn: the reader scrolls to a screen of
// nothing and stops there.
//
// `bandRows` is that count, day by day: the row each day's band stands in, and
// the row after the last day — the shelf's own height in rows. Every row below a
// band is `cols` frames of that day, so a row is turned back into a frame
// through the band above it and the frames of the day before it.
export function bandRows(dayStarts: number[], count: number, cols: number): number[] {
const rows = new Array<number>(dayStarts.length + 1);
let row = 0;
for (let k = 0; k < dayStarts.length; k++) {
rows[k] = row;
const frames = (k + 1 < dayStarts.length ? dayStarts[k + 1] : count) - dayStarts[k];
row += 1 + Math.ceil(frames / cols);
}
rows[dayStarts.length] = row;
return rows;
}
// The steps a shelf is stacked in, and what the grid leaves between them: a day
// opens with a band of `head` when it is the first day of its month, then a band
// of `band`, then rows of frames of `row`, and `gap` stands under each of them.
// They are not one step. A step taken as their average — read off the rows the
// window happens to be drawing — is a step that is wrong for all of them, and a
// shelf whose days do not come out even over the columns lays them in a different
// proportion from one screen to the next. What that costs is the end of the
// shelf: the spacers standing in for the rows not drawn add up to a height that
// moves with the window, the scrollbar ends above the frames that were never
// drawn, and the screen under the last card drawn is a screen of nothing.
//
// The bands are heights the style sheet fixes (`MONTH_BAND` / `DAY_BAND` in
// Library.tsx are the same two numbers), so the shelf's own height is known
// before a card is drawn: only `row` — a card and the grid's gap — is measured,
// and only once per column count.
export type WallSteps = { head: number; band: number; row: number; gap: number };
// The shelf measured in those steps: the row every day's band stands in
// (`order`, the same rows `rowPhoto` reads), the top and the bottom of every
// day's own box, and the height of the whole shelf, which is the bottom of the
// last day's box. `heads[k]` is 1 on a day that opens a month, which is what the
// wall draws above it.
export type WallLayout = {
steps: WallSteps;
cols: number;
rows: number;
order: number[];
heads: number[];
tops: number[];
ends: number[];
// One row of the shelf: a run of frames of one day, or the day's own band over
// them. `top` and `height` are the row's box in the shelf, `from`/`to` the frames
// it draws (a band draws none), and `day` the day it belongs to.
export type WallRow = {
day: number;
band: boolean;
top: number;
height: number;
from: number;
to: number;
};
export type WallLayout = { rows: WallRow[]; height: number };
// What a frame is worth before its own picture has said: the wall lays a shelf out
// before a file has been read, and a row measured at the average rectangle is a
// row that only moves by as much as the frames in it are unlike it.
export const WALL_AR = 1.5;
// The shelf measured as rows of pictures, the way a contact sheet is laid out and
// not the way a grid of cards is: a day opens with its band, and under it its
// frames are packed into rows that are justified to the wall's own width at
// `target` — a row holds frames until it is at least as wide as the wall, and is
// then stretched (or shrunk) to exactly that width, so every row in a day shares
// its top and its height and the frames keep their own proportions inside it. A
// day's last row is left justified to nothing: it stands at `target` and as wide
// as its frames come out, which is what tells the eye where one day ends and the
// next begins.
//
// Laid out here rather than read off the DOM so the shelf's whole height is known
// from the list: the wall draws only the rows around the eye, and a height it had
// to read off the rows it drew is a scrollbar that shrinks with the window — the
// frames under the fold are then frames nobody can scroll to, and the ones at the
// top of the window are hidden behind a shelf that moved.
export function wallLayout(
dayStarts: number[],
heads: number[],
ars: number[],
count: number,
cols: number,
width: number,
steps: WallSteps
): WallLayout {
const order = bandRows(dayStarts, count, cols);
const tops: number[] = [];
const ends: number[] = [];
const rows: WallRow[] = [];
const wall = Math.max(1, width);
const gap = steps.gap;
let y = 0;
for (let k = 0; k < dayStarts.length; k++) {
const frames = (k + 1 < dayStarts.length ? dayStarts[k + 1] : count) - dayStarts[k];
// The last row of a day leaves no gap under it: the grid inside the day ends
// with the card, and the gap between two days is the one the wall's own grid
// draws between their boxes (counted once, past the block).
const height = (heads[k] ? steps.head : 0) + steps.band + Math.ceil(frames / cols) * steps.row - steps.gap;
tops.push(y);
ends.push(y + height);
y += height + steps.gap;
const start = dayStarts[k];
const end = k + 1 < dayStarts.length ? dayStarts[k + 1] : count;
const band = (heads[k] ? steps.head : 0) + steps.band;
rows.push({ day: k, band: true, top: y, height: band, from: start, to: start });
y += band + gap;
let i = start;
while (i < end) {
let sum = 0;
let n = 0;
// One frame in, then one more for as long as the row is still narrower than
// the wall at `target`: what the last frame added is what takes it past.
while (i + n < end && (n === 0 || sum * steps.target + (n - 1) * gap < wall)) {
const ar = ars[i + n];
sum += ar > 0 ? ar : WALL_AR;
n++;
}
const short = sum * steps.target + (n - 1) * gap < wall;
const height = short ? steps.target : (wall - (n - 1) * gap) / sum;
rows.push({ day: k, band: false, top: y, height, from: i, to: i + n });
y += height + gap;
i += n;
}
}
return {
steps,
cols,
rows: order[dayStarts.length],
order,
heads,
tops,
ends,
height: ends.length ? ends[ends.length - 1] : 0,
};
return { rows, height: rows.length ? y - gap : 0 };
}
// The day a row stands in, off the rows every day's band stands in. The last
// entry of `order` is the row after the last day, which is not a day's own.
function dayOfRow(order: number[], row: number): number {
// The row a scroll position stands in: the last row whose own top is above it.
export function rowAt(rows: WallRow[], y: number): number {
if (!rows.length || y <= 0) return 0;
let lo = 0;
let hi = Math.max(0, order.length - 2);
let hi = rows.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (order[mid] <= row) lo = mid;
if (rows[mid].top <= y) lo = mid;
else hi = mid - 1;
}
return lo;
}
// The top of a row's own box: a day's band stands at the top of the day — the
// month's own band above it on the first day of a month — and the frame rows
// under it start where the bands' own height ends.
export function rowTop(layout: WallLayout, row: number): number {
if (!layout.tops.length) return 0;
const k = dayOfRow(layout.order, row);
const bands = (layout.heads[k] ? layout.steps.head : 0) + layout.steps.band;
return row <= layout.order[k]
? layout.tops[k]
: layout.tops[k] + bands + (row - layout.order[k] - 1) * layout.steps.row;
}
// And the other way: the row a scroll position falls in, so the window the wall
// draws is the window the reader is standing in.
export function rowAt(layout: WallLayout, y: number): number {
if (!layout.tops.length || y <= 0) return 0;
// And the other way: the row a frame is drawn in — the first row whose own run
// reaches it, bands drawn none and passed over.
export function rowOf(rows: WallRow[], index: number): number {
let lo = 0;
let hi = layout.tops.length - 1;
let hi = Math.max(0, rows.length - 1);
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (layout.tops[mid] <= y) lo = mid;
else hi = mid - 1;
}
const bands = (layout.heads[lo] ? layout.steps.head : 0) + layout.steps.band;
const rel = y - layout.tops[lo];
if (rel < bands) return layout.order[lo];
const under = Math.floor((rel - bands) / layout.steps.row) + 1;
return Math.min(layout.order[lo + 1] - 1, layout.order[lo] + under);
}
// The day a frame is filed under, on the same cut the days were begun at.
export function dayAt(dayStarts: number[], index: number): number {
if (!dayStarts.length) return 0;
let lo = 0;
let hi = dayStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (dayStarts[mid] <= index) lo = mid;
else hi = mid - 1;
const mid = (lo + hi) >> 1;
if (rows[mid].to > index) hi = mid;
else lo = mid + 1;
}
return lo;
}
// The first frame drawn in `row` — where a row starts drawing, since a frame is
// drawn in the row its own slot over the columns falls in. A band is a row of
// its own and draws its day's first frame; the rows under it draw that day's
// frames `cols` at a time, and a row past a day's frames draws the first frame
// of the day after it, whose band is the row it lands on. Rows meet where one
// answers and the next takes up from there, which is what makes a run of them a
// window over the shelf with no frame left out between two of them.
export function rowPhoto(dayStarts: number[], count: number, cols: number, order: number[], row: number): number {
if (row <= 0 || !dayStarts.length) return 0;
// The row a day opens with, which is its own band: where the ruler takes the wall
// when a month is pressed.
export function dayRow(rows: WallRow[], day: number): number {
let lo = 0;
let hi = dayStarts.length - 1;
let hi = Math.max(0, rows.length - 1);
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (order[mid] <= row) lo = mid;
else hi = mid - 1;
const mid = (lo + hi) >> 1;
if (rows[mid].day < day || (rows[mid].day === day && !rows[mid].band)) lo = mid + 1;
else hi = mid;
}
const end = lo + 1 < dayStarts.length ? dayStarts[lo + 1] : count;
const drawn = row - order[lo] - 1;
return drawn <= 0 ? dayStarts[lo] : Math.min(end, dayStarts[lo] + drawn * cols);
}
// The row the frame at `index` is drawn in — the settle effect's own way of
// putting a scroll where a frame is, off the same rows as the window above.
export function photoRow(dayStarts: number[], cols: number, order: number[], index: number): number {
if (!dayStarts.length) return 0;
let lo = 0;
let hi = dayStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (dayStarts[mid] <= index) lo = mid;
else hi = mid - 1;
}
return order[lo] + 1 + Math.floor((index - dayStarts[lo]) / cols);
return lo;
}