fix(library): measure the wall's shelf in two steps so the scroll reaches its end
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`);
|
||||
+109
-70
@@ -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<HTMLElement>('.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<HTMLDivEle
|
||||
// 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, on: boolean): { ref: RefObject<HTMLDivElement>; from: number; to: number; above: number; below: number } {
|
||||
function useWallWindow(
|
||||
count: number,
|
||||
dayStarts: 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 }));
|
||||
@@ -4091,11 +4131,12 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElem
|
||||
// stands at.
|
||||
const winRef = useRef(win);
|
||||
const measureRef = useRef<() => 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<WallSteps>({ 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<HTMLDivElem
|
||||
// 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 run the step below is read off. A wall that has drawn nothing is
|
||||
// nothing to read — the state's own first screenful stands — but a run of a
|
||||
// single row is not nothing: it is the last row of a shelf scrolled to its
|
||||
// end, or the whole wall of a shelf of four frames. It has no average, and
|
||||
// the step it gets instead is its own height (below).
|
||||
// The cards are the day sections' own now: the header above a run is not a
|
||||
// card, and a row count taken with it would be a row count that drifts.
|
||||
const cards = now.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLDivElem
|
||||
el.removeEventListener('scroll', onOutside);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [count, on]);
|
||||
}, [count, dayStarts, on]);
|
||||
useLayoutEffect(() => {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -38,3 +38,157 @@ export function dayRuns<T>(photos: T[], at: (photo: T) => number): DayRun<T>[] {
|
||||
}
|
||||
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 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user