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
+196 -127
View File
@@ -243,12 +243,73 @@ await context.addInitScript(
const page = await context.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200)));
// The day a frame of this roll was shot on, on the reader's own clock — the same
// cut the wall files it under.
const dayKeyOf = (name) => {
const d = new Date(dayOfName(name));
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
// What the wall is drawing, read off the boxes: every row it has drawn, the frames
// in them, and the pane and body they are drawn in. The whole check is read off
// this, so a row standing at a top the layout did not give it is a row this sees.
const audit = () =>
page.evaluate(() => {
const el = document.querySelector('.adm-grid');
if (!el) return null;
const box = el.getBoundingClientRect();
const body = el.querySelector('.adm-wall-body');
const rows = [...el.querySelectorAll('.adm-row')].map((row) => {
const rb = row.getBoundingClientRect();
const month = row.querySelector('.adm-month-head');
return {
band: row.classList.contains('adm-band'),
day: row.getAttribute('data-day') ?? '',
label: row.querySelector('.adm-day-head')?.textContent?.trim() ?? '',
month: month ? (month.getAttribute('data-key') ?? '').replace('lib-month-', '') : '',
top: Math.round(rb.top - box.top),
bottom: Math.round(rb.bottom - box.top),
tiles: [...row.querySelectorAll('.lib-tile')].map((t) => {
const tb = t.getBoundingClientRect();
const img = t.querySelector('img');
return {
name: (t.getAttribute('data-key') ?? '').split('/').pop(),
// Kept as the browser laid them out: a row that is justified to the wall
// is a sum of widths, and rounding each of them is a sum off by up to
// half a frame a piece.
w: Number(tb.width.toFixed(2)),
h: Number(tb.height.toFixed(2)),
ar: img && img.naturalWidth > 0 ? Number((img.naturalWidth / img.naturalHeight).toFixed(3)) : null,
};
}),
};
});
const run = document.querySelector('.adm-row:not(.adm-band)');
return {
scrollTop: Math.round(el.scrollTop),
clientH: Math.round(el.clientHeight),
clientW: Math.round(el.clientWidth),
scrollH: el.scrollHeight,
bodyH: body ? Math.round(body.getBoundingClientRect().height) : null,
gap: run ? Number.parseFloat(getComputedStyle(run).columnGap) || 0 : 0,
rows,
};
});
// A stop on the shelf: a fraction of the room under it, or its own end at -1.
const scrollTo = (f) =>
page.evaluate((at) => {
const el = document.querySelector('.adm-grid');
el.scrollTop = at < 0 ? el.scrollHeight : Math.round((el.scrollHeight - el.clientHeight) * at);
el.dispatchEvent(new Event('scroll'));
}, f);
const read = () =>
page.evaluate(() => {
const el = document.querySelector('.adm-grid');
return {
cards: el ? el.querySelectorAll('.adm-card').length : 0,
wall: el ? el.scrollHeight > el.clientHeight : false,
tiles: el ? el.querySelectorAll('.lib-tile').length : 0,
// The white screen itself: no rail, no words, nothing drawn.
blank: document.body.innerText.trim().length < 40 && !document.querySelector('nav.rail'),
};
@@ -267,88 +328,103 @@ await page
const counted = await page.$eval('[data-key="lib-count"]', (e) => e.textContent?.trim() ?? '');
step('the roll is read whole', counted.includes(String(FRAMES)), counted);
// The day the header of a card stands under, read off the sections themselves:
// every header must name the day of every card filed under it. A day whose first
// rows are above the window still gets its header where the eye lands (see
// `dayRuns`), so the rule is "a header names its own cards" and not "one header
// per day in the shelf".
const dayAudit = () =>
page.evaluate(() => {
const key = (ts) => {
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const out = { sections: 0, cards: 0, labelled: 0, bad: [] };
for (const section of document.querySelectorAll('.adm-day')) {
out.sections++;
const own = (section.getAttribute('data-key') ?? '').replace('lib-day-', '');
const head = section.querySelector('.adm-day-head')?.textContent?.trim() ?? '';
if (head) out.labelled++;
const names = [...section.querySelectorAll('[data-key^="lib-photo-"]')].map(
(el) => (el.getAttribute('data-key') ?? '').split('/').pop()
);
const days = new Set(names.map((name) => key(window.__dayOf(name))));
if (!names.length || days.size !== 1 || !days.has(own)) out.bad.push({ own, head, days: [...days], names: names.slice(0, 2) });
}
out.cards = document.querySelectorAll('.adm-card').length;
return out;
});
await page.click('[data-key="lib-view-grid"]');
await page.waitForSelector('.adm-grid', { timeout: 15_000 });
await page.waitForFunction(() => document.querySelectorAll('.adm-card').length > 0, null, { timeout: 30_000 });
await page.waitForTimeout(2000);
const wall = await read();
step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < FRAMES, `${wall.cards} of ${FRAMES} cards drawn`);
step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls');
await page.waitForSelector('.adm-row', { timeout: 15_000 });
await page.waitForFunction(() => document.querySelectorAll('.lib-tile').length > 0, null, { timeout: 30_000 });
await page.waitForTimeout(2500);
const days1 = await dayAudit();
// The first screenful is one day of a mixed roll often as not — a day big enough
// to fill it is a day at all — so what is read here is that a band stands over
// the cards and names its day, and the cut into several days is read below, on a
// wall scrolled off the fold.
step('the wall draws its days over the cards', days1.sections >= 1 && days1.labelled === days1.sections, `${days1.sections} headers over ${days1.cards} cards`);
step('every header names the day of the cards under it', days1.bad.length === 0, JSON.stringify(days1.bad.slice(0, 2)));
// The same wall, scrolled to where no day starts at the fold: the headers have to
// move with it, which is the whole risk of cutting a virtualised wall into days.
// Read down the shelf rather than at one stop: a stop of any one fraction lands
// inside a day big enough to fill the screen as often as it lands on a fold, so
// what is read is that no band ever files a card under another day's header, and
// that the shelf's own cuts move through the window as it travels.
const stops = [0.15, 0.35, 0.55, 0.75, 0.95];
const seenDays = new Set();
let scrolledBad = [];
for (const at of stops) {
await page.evaluate((f) => {
const el = document.querySelector('.adm-grid');
el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) * f);
el.dispatchEvent(new Event('scroll'));
}, at);
await page.waitForTimeout(900);
const audit = await dayAudit();
for (const section of await page.$$eval('.adm-day', (els) => els.map((e) => e.getAttribute('data-key')))) seenDays.add(section);
scrolledBad = scrolledBad.concat(audit.bad);
}
const opened = await audit();
const openedTiles = opened.rows.reduce((n, r) => n + r.tiles.length, 0);
step('the wall draws a screenful of the shelf', openedTiles > 0 && openedTiles < FRAMES, `${openedTiles} of ${FRAMES} frames drawn`);
step('and stands in for the rows it is not drawing', opened.scrollH > opened.clientH, `a shelf of ${opened.scrollH}px in a pane of ${opened.clientH}px`);
// The shelf's own height, which is the layout's and not the DOM's: a body standing
// shorter than the layout puts the frames under the fold out of reach, and one
// standing taller leaves the reader at the end of the scroll with a screen of
// nothing under the last frame drawn.
step(
'a scrolled wall still files every card under its own day',
scrolledBad.length === 0 && seenDays.size > 1,
`${seenDays.size} days through the window, ${JSON.stringify(scrolledBad.slice(0, 2))}`
'the body of the wall stands the shelf the layout counted',
opened.bodyH !== null && Math.abs(opened.scrollH - opened.bodyH) <= 1,
`body ${opened.bodyH}px, scroll ${opened.scrollH}px`
);
// The two frames the pane is drawn for, and the ones a shelf that moves under the
// reader hides: the row over the top edge of the pane and the row under its bottom
// edge are rows the wall has to have drawn, at every stop of the roll, its two
// ends included. Read down the shelf and not at one stop, since a stop of any one
// fraction lands inside a day often as it lands on a fold.
const stops = [0, 0.15, 0.35, 0.55, 0.75, 0.95, -1];
// Read down the drawn rows, from the top edge of the pane to its bottom one, and
// the holes between them: two rows of the wall stand a gap apart, and a gap is
// not a hole — anything wider than the gap is a row the window left out.
const holes = [];
const SLIT = 4;
const unjustified = [];
const dayBad = [];
const seenDays = new Set();
for (const at of stops) {
await scrollTo(at);
await page.waitForTimeout(900);
const a = await audit();
let cursor = 0;
for (const r of [...a.rows].sort((x, y) => x.top - y.top)) {
if (r.bottom <= 0) continue;
if (r.top > cursor + SLIT) holes.push({ at, slit: [cursor, r.top] });
if (r.bottom > cursor) cursor = r.bottom;
}
if (cursor < a.clientH - 1) holes.push({ at, open: [cursor, a.clientH] });
// A row of frames is justified to the wall: the frames of a day packed into a row
// until it is at least as wide as the wall, and the row then stretched to exactly
// that width, so every frame keeps its own proportions and meets its neighbours.
// Every row of a day but the last is that width; the last one is as wide as the
// day's frames come out, which is what tells one day from the next.
const run = a.rows.filter((r) => !r.band);
for (let i = 0; i < run.length; i++) {
const row = run[i];
if (!row.tiles.length) continue;
const sum = row.tiles.reduce((n, t) => n + t.w, 0) + a.gap * (row.tiles.length - 1);
const last = i + 1 >= run.length || run[i + 1].day !== row.day;
if (Math.abs(sum - a.clientW) > 1 && !(last && sum < a.clientW)) {
unjustified.push({ day: row.day, sum, wall: a.clientW, last });
}
for (const t of row.tiles) {
if (t.ar !== null && Math.abs(t.w / t.h - t.ar) > 0.02) {
unjustified.push({ day: row.day, name: t.name, ar: t.ar, drawn: Number((t.w / t.h).toFixed(3)) });
}
}
}
// And the bands: a band names the day it stands over, and the frames under it are
// the frames of that day.
let head = null;
for (const row of a.rows) {
if (row.band) {
head = row.day;
if (head) seenDays.add(head);
continue;
}
// The first row drawn may begin under a band the window left above the fold.
if (!head) continue;
if (row.day !== head) dayBad.push({ head, row: row.day });
for (const t of row.tiles) {
if (t.name && head !== dayKeyOf(t.name)) dayBad.push({ head, name: t.name, own: dayKeyOf(t.name) });
}
}
}
step('the frames fill the pane from its top edge to its bottom one', holes.length === 0, JSON.stringify(holes.slice(0, 2)));
step('a row of frames is justified to the wall, and a day ends in a row of its own', unjustified.length === 0, JSON.stringify(unjustified.slice(0, 2)));
step(
'every row of frames stands under the band that names its day',
dayBad.length === 0 && seenDays.size > 1,
`${seenDays.size} days through the window, ${JSON.stringify(dayBad.slice(0, 2))}`
);
// Back to the head of the roll, where the month's own band is drawn.
await page.evaluate(() => {
const el = document.querySelector('.adm-grid');
el.scrollTop = 0;
el.dispatchEvent(new Event('scroll'));
});
await scrollTo(0);
await page.waitForTimeout(1200);
// The two bands, at the heights the wall counts its shelf in. The month's and the
// day's band are both fixed numbers in the style sheet and the shelf's whole
// length — and with it every spacer standing in for the rows not drawn — is
// counted from them, so a band that grew with its own text is a shelf whose end
// moves.
// The two bands, at the heights the wall lays its shelf out in: both are fixed
// numbers in the style sheet, and the shelf's whole height — and with it every
// frame's own top — is counted from them, so a band that grew with its own text is
// a shelf whose end moves.
const bands = await page.evaluate(() => {
const h = (sel) => {
const el = document.querySelector(sel);
@@ -356,34 +432,27 @@ const bands = await page.evaluate(() => {
};
return { month: h('.adm-month-head'), day: h('.adm-day-head') };
});
step('the month and the day bands stand at the heights the wall counts them at', bands.month === 54 && bands.day === 28, JSON.stringify(bands));
step('the month and the day bands stand at the heights the wall lays them in', bands.month === 44 && bands.day === 30, JSON.stringify(bands));
// The month's own band, over the first day of the month it names and over no
// other: the wall is read by the month it was shot and only then by the day, and
// a band left standing over a day of the month above it is a month named twice.
const monthAudit = () =>
page.evaluate(() => {
const out = { sections: 0, heads: 0, bad: [] };
const sections = [...document.querySelectorAll('.adm-day')];
out.sections = sections.length;
let prev = null;
for (const section of sections) {
const month = section.getAttribute('data-month') ?? '';
const head = section.querySelector('.adm-month-head');
const named = head ? (head.getAttribute('data-key') ?? '').replace('lib-month-', '') : '';
if (head) out.heads++;
// The window's own first day is not read: it is a day the wall began in the
// middle of, and the band above it was drawn above the fold or not at all.
if (prev !== null) {
if ((month !== prev) !== Boolean(head)) out.bad.push({ month, prev, head: Boolean(head) });
else if (head && named !== month) out.bad.push({ month, named });
}
prev = month;
}
return out;
});
const months1 = await monthAudit();
step('the wall names a month over the first day of it, and only there', months1.heads >= 1 && months1.bad.length === 0, `${months1.heads} month bands over ${months1.sections} days, ${JSON.stringify(months1.bad.slice(0, 2))}`);
// other: the wall is read by the month it was shot and only then by the day, and a
// band left standing over a day of the month above it is a month named twice.
const head = await audit();
const monthBad = [];
let prev = null;
let monthBands = 0;
for (const row of head.rows) {
if (!row.band) continue;
const month = row.day.slice(0, 7);
if (row.month) monthBands++;
// The window's own first day is not read: it is a day the wall began in the
// middle of, and the band above it was drawn above the fold or not at all.
if (prev !== null && ((month !== prev) !== Boolean(row.month) || (row.month && row.month !== month))) {
monthBad.push({ day: row.day, named: row.month, month });
}
prev = month;
}
step('the wall names a month over the first day of it, and only there', monthBands >= 1 && monthBad.length === 0, `${monthBands} month bands, ${JSON.stringify(monthBad.slice(0, 2))}`);
// The ruler down the right edge: a tick a month of the roll and a named tick a
// year of it, on the shelf's own months and not on a calendar — a month nothing
@@ -410,11 +479,11 @@ await page.click(`[data-key="${press.tick}"]`);
await page.waitForTimeout(1500);
const landed = await page.evaluate((key) => {
const grid = document.querySelector('.adm-grid');
const head = document.querySelector(`[data-key="lib-month-${key}"]`);
const band = document.querySelector(`[data-key="lib-month-${key}"]`);
const on = document.querySelector('.lib-ruler-tick.on');
return {
drawn: Boolean(head),
offset: head && grid ? Math.round(head.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null,
drawn: Boolean(band),
offset: band && grid ? Math.round(band.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null,
value: document.querySelector('[data-key="lib-ruler-value"]')?.textContent?.trim() ?? '',
on: on ? on.getAttribute('data-at') : '',
};
@@ -426,18 +495,18 @@ step(
`on=${landed.on} of ${press.at}, " ${landed.value}"`
);
// A notch of the wheel, over the ruler: one month, whichever way the roll is
// read, and never off the end of it.
// A notch of the wheel, over the ruler: one month, whichever way the roll is read,
// and never off the end of it.
const notch = () =>
page.evaluate(() => {
const ticksAll = [...document.querySelectorAll('.lib-ruler-tick')];
const months = ticksAll.filter((t) => t.getAttribute('data-kind') === 'month');
const months = [...document.querySelectorAll('.lib-ruler-tick')].filter((t) => t.getAttribute('data-kind') === 'month');
const on = months.find((t) => t.classList.contains('on'));
const needle = document.querySelector('[data-key="lib-ruler-needle"]');
return {
on: months.findIndex((t) => t.classList.contains('on')),
count: months.length,
needle: needle ? parseFloat(needle.style.top) || 0 : 0,
tick: months.find((t) => t.classList.contains('on')) ? parseFloat(months.find((t) => t.classList.contains('on')).style.top) || 0 : 0,
needle: needle ? Number.parseFloat(needle.style.top) || 0 : 0,
tick: on ? Number.parseFloat(on.style.top) || 0 : 0,
};
});
const beforeNotch = await notch();
@@ -452,16 +521,16 @@ step('and the needle stands in the month the eye is in', Math.abs(afterNotch.nee
// The shelf walked to its own end, a screen at a time — a jump straight to the
// bottom is a different question, and the one this check exists for: the wall
// stands in for the frames it is not drawing, and a spacer standing in for more
// rows than those frames take leaves the reader at the end of the scroll with a
// screen of nothing under the last card drawn and the rest of the roll never
// drawn at all. What the walk is read for is that it *gets* there, standing on
// the last frame, and that every frame of the roll was drawn on the way.
// draws the rows around the eye and stands in for the rest with its own height, so
// a body standing shorter than the layout leaves the reader at the end of the
// scroll on a screen of nothing and the rest of the roll never drawn at all. What
// the walk is read for is that it *gets* there, standing on the last frame, and
// that every frame of the roll was drawn on the way.
const walked = await page.evaluate(async () => {
const el = document.querySelector('.adm-grid');
const seen = new Set();
const collect = () => {
for (const card of el.querySelectorAll('.adm-card')) seen.add(card.getAttribute('data-key'));
for (const tile of el.querySelectorAll('.lib-tile')) seen.add(tile.getAttribute('data-key'));
};
const breathe = () => new Promise((r) => setTimeout(r, 150));
el.scrollTop = 0;
@@ -474,21 +543,21 @@ const walked = await page.evaluate(async () => {
await breathe();
}
collect();
const cards = [...el.querySelectorAll('.adm-card')];
const last = cards[cards.length - 1];
const box = el.getBoundingClientRect();
const body = el.querySelector('.adm-wall-body');
const tiles = [...el.querySelectorAll('.lib-tile')];
const last = tiles[tiles.length - 1];
return {
top: Math.round(el.scrollTop),
reached: el.scrollTop + el.clientHeight >= el.scrollHeight - 2,
room: Math.round(el.scrollHeight - el.clientHeight),
// What is left under the last card drawn: the spacer that has nothing left
// to stand in for. A few pixels is the grid's own gap.
gap: last ? Math.round(el.scrollHeight - (last.getBoundingClientRect().bottom - box.top + el.scrollTop)) : null,
// What is left under the last frame drawn: the body that has nothing left to
// stand in for.
gap: last && body ? Math.round(body.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom) : null,
seen: [...seen],
};
});
step('the shelf is walked to its own end', walked.reached, `stopped at ${walked.top} of ${walked.room}`);
step('and stands on nothing there', walked.gap !== null && walked.gap <= 16, `${walked.gap}px of nothing under the last card`);
step('and stands on nothing there', walked.gap !== null && walked.gap <= 2, `${walked.gap}px of nothing under the last frame`);
const missed = names.filter((name) => !walked.seen.includes(`lib-photo-${FOLDER}/${name}`));
step('every frame of the roll is drawn on the way', missed.length === 0, `${walked.seen.length} of ${names.length} drawn, first missed ${missed[0] ?? 'none'}`);
@@ -506,10 +575,10 @@ const after = await read();
const looped = errors.filter((e) => e.includes('185') || e.includes('update depth'));
step(
'a wall fed its own window does not take the screen down',
!after.blank && after.cards > 0 && looped.length === 0,
`blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}`
!after.blank && after.tiles > 0 && looped.length === 0,
`blank=${after.blank}, tiles=${after.tiles}, errors=${looped.length ? looped[0] : 'none'}`
);
step('the wall is still the wall', after.cards > 0, `${after.cards} cards after the feed`);
step('the wall is still the wall', after.tiles > 0, `${after.tiles} frames after the feed`);
await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {});
if (errors.length) console.log('page errors:', errors.slice(0, 6).join(' // '));
+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;
}