From fcded40c65656e37ff3126cdb41b1d1190783b9c Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 13 Oct 2026 09:15:59 +0700 Subject: [PATCH] fix(library): measure the wall's shelf in two steps so the scroll reaches its end --- docker/frontend/scripts/wall-check.mjs | 106 ++++++++++-- docker/frontend/scripts/wall-slot-check.mjs | 146 ++++++++++++++++ docker/frontend/src/Library.tsx | 179 ++++++++++++-------- docker/frontend/src/ui/dateGroups.ts | 154 +++++++++++++++++ 4 files changed, 503 insertions(+), 82 deletions(-) create mode 100644 docker/frontend/scripts/wall-slot-check.mjs diff --git a/docker/frontend/scripts/wall-check.mjs b/docker/frontend/scripts/wall-check.mjs index 3bf2c50..a5cd2de 100644 --- a/docker/frontend/scripts/wall-check.mjs +++ b/docker/frontend/scripts/wall-check.mjs @@ -37,14 +37,36 @@ const FOLDER = 'WallRoll'; // half of what this check is for. const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`); const DAY_MS = 86400000; -const PER_DAY = Number(process.env.PER_DAY ?? 10); const DAY0 = Date.UTC(2026, 0, 1); +// The days the roll was shot on, and they are not equal: a roll is an afternoon +// of three frames and a wedding of four hundred, and the wall's own model of its +// rows is the thing that has to survive that. A shelf of equal days is a shelf a +// flat count is right about — `count / cols` rows of `cols` cards, with the step +// between them read off the run in hand standing for the rest — and a check of +// equal days says nothing about the shelf that is not. And the order matters as +// much as the sizes: the head of the roll is what a step read off the drawn rows +// comes from, so the roll is sixty days of two under four days of forty — and the +// head of it is a screen of bands with a frame or two under each, which is what +// puts a step taken as one average, over a band's row and a row of frames both, +// furthest out of true. (One small day among runs of a hundred and twenty was +// tried and does not show it; a first cut of this check missed the shape and had +// to be walked straight instead of jumped.) +const SMALL_DAY = 2; +const BIG_DAY = 40; +const DAY_PATTERN = [...Array(60).fill(SMALL_DAY), ...Array(4).fill(BIG_DAY)]; // The day a frame is filed under, off its own name — the same rule on both sides // of the wire, so the check reads the wall the way the wall was told to sort it. -const dayOfName = (name) => { - const n = Number((name.match(/\d+/) ?? ['1'])[0]); - return DAY0 + Math.floor(Math.max(0, n - 1) / PER_DAY) * DAY_MS; +const dayIndex = (n) => { + let left = Math.max(0, n); + for (let cycle = 0; cycle < 1000; cycle++) { + for (let i = 0; i < DAY_PATTERN.length; i++) { + if (left < DAY_PATTERN[i]) return cycle * DAY_PATTERN.length + i; + left -= DAY_PATTERN[i]; + } + } + return 0; }; +const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_MS; let failures = 0; function step(name, ok, detail = '') { @@ -262,20 +284,80 @@ step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < F step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls'); const days1 = await dayAudit(); -step('the wall is cut into the days its frames were shot', days1.sections > 1, `${days1.sections} headers over ${days1.cards} cards`); +// 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))); -step('and every header carries its day in words', days1.labelled === days1.sections, `${days1.labelled} of ${days1.sections}`); // 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. -await page.evaluate(() => { +// 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); +} +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 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. +const walked = await page.evaluate(async () => { const el = document.querySelector('.adm-grid'); - el.scrollTop = Math.round(el.scrollHeight * 0.7); - el.dispatchEvent(new Event('scroll')); + const seen = new Set(); + const collect = () => { + for (const card of el.querySelectorAll('.adm-card')) seen.add(card.getAttribute('data-key')); + }; + const breathe = () => new Promise((r) => setTimeout(r, 150)); + el.scrollTop = 0; + await breathe(); + let guard = 0; + while (guard++ < 400) { + collect(); + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 2) break; + el.scrollTop = el.scrollTop + Math.round(el.clientHeight * 0.8); + await breathe(); + } + collect(); + const cards = [...el.querySelectorAll('.adm-card')]; + const last = cards[cards.length - 1]; + const box = el.getBoundingClientRect(); + 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, + seen: [...seen], + }; }); -await page.waitForTimeout(1500); -const days2 = await dayAudit(); -step('a scrolled wall still files every card under its own day', days2.sections > 0 && days2.bad.length === 0, JSON.stringify(days2.bad.slice(0, 2))); +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`); +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'}`); // The class of trigger, made to happen: one input the wall reads back off itself, // moving on every pass. diff --git a/docker/frontend/scripts/wall-slot-check.mjs b/docker/frontend/scripts/wall-slot-check.mjs new file mode 100644 index 0000000..4c724ba --- /dev/null +++ b/docker/frontend/scripts/wall-slot-check.mjs @@ -0,0 +1,146 @@ +// The wall's own model of its rows (src/ui/dateGroups.ts, `bandRows` / +// `rowPhoto` / `photoRow`), checked where it can go wrong without a browser. +// +// The wall draws the rows around the reader and a spacer stands in for the rest, +// so a model of the rows that is out of step with the shelf drawn is a reader +// stopping at a screen of nothing with frames below them never drawn — which is +// what a flat `count / cols` count of rows is the moment the shelf is cut into +// days: every day spends a 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. +// +// What has to hold is that the rows tile the shelf: one window per row, in +// order, meeting at both ends and never leaving a frame between two of them, and +// that a frame stands in the row the browser will draw it in. +// +// node scripts/wall-slot-check.mjs +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import ts from 'typescript'; + +const src = readFileSync(new URL('../src/ui/dateGroups.ts', import.meta.url), 'utf8'); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const { bandRows, dayAt, rowPhoto, photoRow, rowAt, rowTop, wallLayout } = await import( + `data:text/javascript,${encodeURIComponent(js)}` +); + +// The frames per day, cycling — and with them the shelves the check stands on: a +// shelf of equal days, a shelf of single-frame days, one day of everything, and +// the rolls in between, which is what a camera roll is and where a flat count of +// rows stops being right. +const SHELVES = [ + [300, [10]], + [200, [1]], + [120, [120]], + [300, [1, 40, 2, 3, 60, 1, 8, 120, 4, 2, 25, 7]], + [1, [1]], + [7, [2, 3]], + [0, []], +]; +const COLUMNS = [1, 2, 3, 6, 7, 13]; +// The two steps a shelf is stacked in, in the two shapes a measurement of them can +// come out: a band of words a good deal shorter than a row of pictures, which is +// the shelf on screen, and the two equal, which is what the model looked like when +// it was one step and has to keep working. +const STEPS = [ + { band: 44, row: 208, gap: 12 }, + { band: 208, row: 208, gap: 12 }, +]; + +let checked = 0; +for (const [count, pattern] of SHELVES) { + // The first frame of every day, off the pattern — the same list the wall cuts + // its own `dayStarts` into. + const days = []; + for (let i = 0, day = 0; i < count; day++) { + days.push(i); + i += pattern[day % pattern.length]; + } + assert.equal(days[0] ?? 0, 0); + + for (const cols of COLUMNS) { + const order = bandRows(days, count, cols); + const rows = order[days.length]; + // One band per day, and its frames under it in rows of `cols`: the rows the + // shelf is drawn in, which is what the model has to come to. + let drawn = 0; + for (let k = 0; k < days.length; k++) { + drawn += 1 + Math.ceil(((days[k + 1] ?? count) - days[k]) / cols); + } + assert.equal(rows, drawn, `${count} frames in ${days.length} days at ${cols} columns: ${rows} rows modelled, ${drawn} drawn`); + + // The rows tile the shelf: row `r` draws from `rowPhoto(r)` and the next row + // takes it up from there, the first row starts at the first frame, and the + // row past the last answers with the end of the shelf. + let from = rowPhoto(days, count, cols, order, 0); + assert.equal(from, 0, `${count}/${cols}: the shelf starts at its first frame`); + for (let r = 1; r <= rows; r++) { + const to = rowPhoto(days, count, cols, order, r); + assert.ok(to >= from, `${count}/${cols}: row ${r} answers ${to}, behind ${from}`); + assert.ok(to <= count, `${count}/${cols}: row ${r} answers ${to} of ${count}`); + if (r === rows) assert.equal(to, count, `${count}/${cols}: the last row reaches the end`); + from = to; + } + + // And a frame's own row is the row it is drawn in: that row's window starts + // at or above it, and the row after starts below it. + for (let i = 0; i < count; i++) { + const row = photoRow(days, cols, order, i); + assert.ok(row >= 1 && row < rows, `${count}/${cols}: frame ${i} is drawn in row ${row} of ${rows}`); + assert.ok(rowPhoto(days, count, cols, order, row) <= i, `${count}/${cols}: frame ${i} stands past the start of row ${row}`); + assert.ok(rowPhoto(days, count, cols, order, row + 1) > i, `${count}/${cols}: frame ${i} stands past the end of row ${row}`); + checked++; + } + + // The shelf's own height and the two spacers that stand in for the days not + // drawn, in the two steps it is actually stacked in. What has to hold is that + // the shelf comes out the same height whichever window is over it: a height + // that moves with the window is the reader walking down to the end of the + // scroll and stopping a screen short of the frames it was standing in for. + for (const steps of STEPS) { + const at = `${count} frames in ${days.length} days at ${cols} columns, band ${steps.band}/row ${steps.row}`; + const layout = wallLayout(days, count, cols, steps); + const heights = days.map( + (start, k) => steps.band + Math.ceil(((days[k + 1] ?? count) - start) / cols) * steps.row + ); + const drawnHeight = heights.reduce((sum, h) => sum + h, 0); + assert.equal(layout.height, days.length ? drawnHeight + (days.length - 1) * steps.gap : 0, `${at}: the shelf is ${layout.height} tall`); + assert.equal(layout.rows, rows, `${at}: ${layout.rows} rows of the two steps, ${rows} drawn`); + if (!days.length) continue; + assert.equal(layout.ends[days.length - 1], layout.height, `${at}: the last day ends at the shelf's own height`); + + // A row stands below the row before it, and the top it stands at reads back + // as the row it is — which is what puts a scroll where a frame is. + let last = -1; + for (let r = 0; r < layout.rows; r++) { + const top = rowTop(layout, r); + assert.ok(top > last, `${at}: row ${r} stands at ${top}, behind ${last}`); + last = top; + assert.equal(rowAt(layout, top), r, `${at}: row ${r} at ${top} reads back as ${rowAt(layout, top)}`); + } + assert.equal(rowTop(layout, layout.rows - 1) + steps.row, layout.height, `${at}: the last row reaches the shelf's own height`); + assert.equal(dayAt(days, count - 1), days.length - 1, `${at}: the last frame is filed under the last day`); + + // And every window of days lays out the same shelf: the spacer over it, the + // days drawn, the spacer under it and the grid's own gaps add up to one + // height, whichever days are drawn. + for (let a = 0; a < days.length; a++) { + for (let b = a; b < days.length; b++) { + // Laid out the way the DOM lays it: a spacer where the wall draws one + // (a height of nought is no spacer), the days drawn, the grid's gap + // between one item and the next. + const above = a ? Math.max(0, layout.tops[a] - steps.gap) : 0; + const below = Math.max(0, layout.height - layout.ends[b] - steps.gap); + const items = b - a + 1 + (above > 0 ? 1 : 0) + (below > 0 ? 1 : 0); + let sum = 0; + for (let k = a; k <= b; k++) sum += heights[k]; + const total = above + sum + below + (items - 1) * steps.gap; + assert.equal(total, layout.height, `${at}: days ${a}..${b} stand ${total} tall, the shelf ${layout.height}`); + } + } + checked++; + } + } +} +console.log(`ok ${checked} frames placed on ${SHELVES.length} shelves`); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 81461cc..2cb6456 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -360,7 +360,7 @@ export function Library() { } import { useIsMobile } from './ui/useIsMobile'; -import { dayLabel, dayRuns } from './ui/dateGroups'; +import { bandRows, dayAt, dayKey, dayLabel, dayRuns, photoRow, rowAt, rowPhoto, rowTop, wallLayout, type WallSteps } from './ui/dateGroups'; function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { const { t, lang } = useI18n(); @@ -2028,7 +2028,18 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // is seven seconds of dead screen — the URL is only a tenth of it; the rest is // the DOM and the number the browser formats for each date. The frame on the // stage is given a picture wherever it sits — the stage is not the wall. - const wall = useWallWindow(listed.length, view === 'grid'); + // Where each day of the shelf begins, cut by the same rule the bands are drawn + // by (`dayRuns`): the wall stands in rows for the days it is not drawing, so it + // is the shelf's own days it counts and not the window's. + const dayStarts = useMemo(() => { + const starts: number[] = []; + const at = (photo: (typeof listed)[number]) => photo.taken || photo.addedAt; + for (let i = 0; i < listed.length; i++) { + if (!i || dayKey(at(listed[i])) !== dayKey(at(listed[i - 1]))) starts.push(i); + } + return starts; + }, [listed]); + const wall = useWallWindow(listed.length, dayStarts, 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 @@ -2144,12 +2155,27 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { 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 - // and let the next pass find it. The row's step is read off a drawn card - // when there is one, and guessed at when there is not. + // 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 pitch = first ? first.offsetHeight + WALL_GAP : 240; - el.scrollTop = Math.max(0, Math.floor(idx / cols) * pitch - (el.clientHeight - pitch) / 2); + const steps = wall.steps.current; + if (steps.row > 0) { + const layout = wallLayout(dayStarts, 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)); + } } if (--left > 0) requestAnimationFrame(settle); else centredRef.current = null; @@ -2161,7 +2187,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, wall.ref, up]); + }, [view, listed, dayStarts, wall.ref, 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). @@ -4079,7 +4105,21 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject` 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, on: boolean): { ref: RefObject; from: number; to: number; above: number; below: number } { +function useWallWindow( + count: number, + dayStarts: number[], + on: boolean +): { + ref: RefObject; + 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(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 })); @@ -4091,11 +4131,12 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject void>(() => {}); - // The step from one row to the next, taken once per column count and kept. It - // is a ref rather than a value read on every pass because the pass reads it 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 pitchRef = useRef(0); + // 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({ band: 0, 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 @@ -4157,64 +4198,62 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject('.adm-card'); - const rowsDrawn = Math.floor(cards.length / cols); - // The step from one row to the next, taken as the drawn run's own average. - // The cards do NOT all stand the same height — a caption that wraps makes - // its row taller — and `offsetTop` is rounded to whole pixels, so the - // smallest step would be a pixel short on every one of thousands of rows. - // The average of the rows in hand is the honest guess at the ones that are - // not in hand. - // The step is read off the drawn rows the first time the wall stands at this - // many columns, and kept until it stands at another: the average of a run of - // rows moves as the run grows, so 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 + // 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('.adm-day')]; + // The two steps the shelf is stacked in, read off the drawn days: the band's + // own height, from a day's own box less the rows of frames it stands over, + // and the step from one row of frames to the next, which is a card and the + // grid's own gap. A band is a line of words and a row of frames is a + // picture: one step taken as their average is a step that is wrong for both, + // and the spacers standing in for the rows not drawn come out short or long + // by however much of the shelf is bands — which moves as the window does. + // 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 && rowsDrawn >= 2) { - const drawn = (cards[(rowsDrawn - 1) * cols].getBoundingClientRect().top - cards[0].getBoundingClientRect().top) / (rowsDrawn - 1); - if (drawn > 0) { - colsRef.current = cols; - pitchRef.current = drawn; + // 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 first = sections[0]; + const card = first?.querySelector('.adm-card'); + if (first && card) { + const row = card.offsetHeight + WALL_GAP; + const band = first.getBoundingClientRect().height - Math.ceil(first.querySelectorAll('.adm-card').length / cols) * row; + if (row > 0 && band > 0) { + colsRef.current = cols; + stepsRef.current = { band, row, gap: WALL_GAP }; + } } } - // And the one-row run, which has no average to take: its own height is the - // step — one card plus the gap, the same fallback the settle effect takes. - // 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}, one row - // of five columns, no average — and every pass after it reads that one row - // and leaves, so the shelf stands at four cards for the rest of the visit; - // another year, another folder, a reset, all four cards under a count of six - // hundred. The step is a guess, and a spacer a few pixels out is a scrollbar - // a few pixels out; a window left alone is the wall. - let pitch = pitchRef.current; - if (!(pitch > 0) || colsRef.current !== cols) { - const first = cards[0]; - if (!first) return; - pitch = first.offsetHeight + WALL_GAP; - if (!(pitch > 0)) return; - colsRef.current = cols; - pitchRef.current = pitch; - } - const rows = Math.ceil(count / cols); - const row0 = Math.max(0, Math.floor(now.scrollTop / pitch) - WALL_ROWS); - const row1 = Math.min(rows, Math.ceil((now.scrollTop + now.clientHeight) / pitch) + WALL_ROWS); - const from = row0 * cols; - const to = Math.min(count, row1 * cols); - // A spacer's own height keeps the gap the grid would have left under the - // last row it stands in for. - const above = row0 ? row0 * pitch - WALL_GAP : 0; - const below = row1 < rows ? (rows - row1) * pitch - WALL_GAP : 0; + // And a run with no steps 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.band > 0 && 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, 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; @@ -4230,9 +4269,9 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject { measureRef.current(); }); - return { ref, from: win.from, to: win.to, above: win.above, below: win.below }; + return { ref, from: win.from, to: win.to, above: win.above, below: win.below, steps: stepsRef }; } diff --git a/docker/frontend/src/ui/dateGroups.ts b/docker/frontend/src/ui/dateGroups.ts index 4ba4433..1b0adbf 100644 --- a/docker/frontend/src/ui/dateGroups.ts +++ b/docker/frontend/src/ui/dateGroups.ts @@ -38,3 +38,157 @@ export function dayRuns(photos: T[], at: (photo: T) => number): DayRun[] { } 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(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 two steps a shelf is stacked in, and what the grid leaves between them: a +// day is a band of `band` over 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 both, +// and a shelf whose days do not come out even over the columns lays the two 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. +export type WallSteps = { 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. +export type WallLayout = { + steps: WallSteps; + cols: number; + rows: number; + order: number[]; + tops: number[]; + ends: number[]; + height: number; +}; + +export function wallLayout(dayStarts: number[], count: number, cols: number, steps: WallSteps): WallLayout { + const order = bandRows(dayStarts, count, cols); + const tops: number[] = []; + const ends: number[] = []; + let y = 0; + for (let k = 0; k < dayStarts.length; k++) { + const frames = (k + 1 < dayStarts.length ? dayStarts[k + 1] : count) - dayStarts[k]; + const height = steps.band + Math.ceil(frames / cols) * steps.row; + tops.push(y); + ends.push(y + height); + y += height + steps.gap; + } + return { steps, cols, rows: order[dayStarts.length], order, tops, ends, height: ends.length ? ends[ends.length - 1] : 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 { + let lo = 0; + let hi = Math.max(0, order.length - 2); + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (order[mid] <= row) 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, and the +// frame rows under it start where the band's own height ends. +export function rowTop(layout: WallLayout, row: number): number { + if (!layout.tops.length) return 0; + const k = dayOfRow(layout.order, row); + return row <= layout.order[k] + ? layout.tops[k] + : layout.tops[k] + layout.steps.band + (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; + let lo = 0; + let hi = layout.tops.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (layout.tops[mid] <= y) lo = mid; + else hi = mid - 1; + } + const rel = y - layout.tops[lo]; + if (rel < layout.steps.band) return layout.order[lo]; + const under = Math.floor((rel - layout.steps.band) / 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; + } + 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; + let lo = 0; + let hi = dayStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (order[mid] <= row) lo = mid; + else hi = mid - 1; + } + 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); +}