diff --git a/docker/frontend/scripts/wall-check.mjs b/docker/frontend/scripts/wall-check.mjs index 4169ead..217be7c 100644 --- a/docker/frontend/scripts/wall-check.mjs +++ b/docker/frontend/scripts/wall-check.mjs @@ -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(' // ')); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 7b6bec5..4f064a8 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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(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('.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('.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 ? ( ) : ( @@ -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 - 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. */}
-
- {wall.above > 0 ?