feat(library): cut the wall by month and day, and stand a ruler timeline on its right edge

This commit is contained in:
2026-10-13 10:37:57 +07:00
parent f8cac67506
commit 822833c1ef
6 changed files with 1044 additions and 626 deletions
+189 -281
View File
@@ -1,23 +1,17 @@
// The wall, checked where it can actually go wrong: in a browser, on a shelf long // The wall, checked where it can actually go wrong: in a browser, on a shelf long
// enough that a day of it is a screen and the shelf is many screens of them. // enough that the wall is drawing a few of its rows and standing in for the rest.
// //
// What it guards is the wall *being* the shelf, and saying so: every frame the open // One failure is what this guards, and it is the one that has already cost a
// node holds is a card (the wall is drawn whole — no window over it, so no card // white screen: a window written from the window before it. The wall measures
// arriving late and no spacer standing in for one), every card sits under the band // itself after every render, writes what it measured, and renders again — so an
// of its own day, the last frame is reachable by scrolling with nothing left under // input the measure takes off the window it just wrote feeds itself, pass after
// it, and the rail down the wall's right edge — the shelf's own time, by the year // pass, until React gives up with #185, unmounts the tree, and leaves the reader
// and the month and nothing finer — scrolls under the reader's hand, reads the month // with nothing (there is no error boundary under it).
// the eye is in and takes the wall to the month or the year it is pressed for.
// //
// The wall is also the one screen that reads the whole shelf, so what it mints is // The trigger is not spelled out — no browser reproduces the reader's own,
// watched here too: a roll of years is a rail of months, and a rail whose every entry // which is the point — so the check drives the *class*: the wall's own box
// is in the box it is read through. // reported at two different heights on every pass, plus one scroll to start it.
// // That is what any read-back is, and the wall must survive it.
// It is drawn whole for a reason this check keeps an eye on: the wall used to be a
// window over the shelf, and a window measured off the window before it cost a
// white screen once (React #185, no error boundary under it). None of that state is
// left — but a resize under the wall's own eye is the same class of input, and the
// wall re-columns and the rail re-reads through it without taking the screen down.
// //
// npm run build && node scripts/wall-check.mjs // npm run build && node scripts/wall-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/wall-check.mjs // PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/wall-check.mjs
@@ -36,12 +30,6 @@ const APP_PORT = Number(process.env.APP_PORT ?? 5033);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034); const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034);
const FRAMES = Number(process.env.FRAMES ?? 300); const FRAMES = Number(process.env.FRAMES ?? 300);
const FOLDER = 'WallRoll'; const FOLDER = 'WallRoll';
// The second roll: a shelf of a single month, which the rail reads with one head and
// one month under it — the other half of what the rail is (`rail` in Library.tsx),
// and the shape a folder per event has. Its frames are two days of twenty.
const SMALL_FOLDER = 'OneDayRoll';
const SMALL_EACH = 20;
const SMALL_DAYS = 2;
// Every frame is the same bytes under a different name: what is being checked is // Every frame is the same bytes under a different name: what is being checked is
// how many cards the wall will draw, not what is on them. The bytes carry no // how many cards the wall will draw, not what is on them. The bytes carry no
// EXIF, so a frame's date is its own `lastModified` — spread over the days below, // EXIF, so a frame's date is its own `lastModified` — spread over the days below,
@@ -49,11 +37,9 @@ const SMALL_DAYS = 2;
// half of what this check is for. // half of what this check is for.
const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`); const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`);
const DAY_MS = 86400000; const DAY_MS = 86400000;
// The roll is spread over years, a shooting day every forty days or so: a shelf of // Midday, so the day a frame is filed under on the reader's own clock is the day
// months is what the rail is for, and a rail that holds three of them is a rail that // the check counts, whatever the box is set to.
// never has to be scrolled — the years are what make it longer than the wall is tall. const DAY0 = Date.UTC(2023, 0, 12, 12);
const DAY_SPREAD = 40;
const DAY0 = Date.UTC(2018, 0, 1);
// The days the roll was shot on, and they are not equal: a roll is an afternoon // 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 // 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 // rows is the thing that has to survive that. A shelf of equal days is a shelf a
@@ -82,45 +68,24 @@ const dayIndex = (n) => {
} }
return 0; return 0;
}; };
const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_SPREAD * DAY_MS; // The shooting days are laid out on the calendar rather than end to end: what the
// ruler beside the wall is read for is the months and the years of the roll, and
// The second roll's own names and days: `one-0001` onwards, twenty to a day, over // a roll shot on consecutive days is a roll of one month and one tick. Eleven
// the fourth and fifth of March 2026 — one month, two days, which is the shelf the // days between one shooting day and the next leaves it two years long with whole
// rail reads by the day. // months nothing was shot in standing between them — and a month with no frame is
const smallNames = Array.from( // a month with no tick, which is the ruler's own claim.
{ length: SMALL_DAYS * SMALL_EACH }, const DAY_STEP = 11;
(_, i) => `one-${String(i + 1).padStart(4, '0')}.jpg` const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_STEP * DAY_MS;
); const yearOf = (ts) => String(new Date(ts).getFullYear());
const smallDayOfName = (name) =>
Date.UTC(2026, 2, 4 + Math.floor((Number((name.match(/\d+/) ?? ['1'])[0]) - 1) / SMALL_EACH));
const allNames = [...names, ...smallNames];
// What the tree is asked for and the sample server serves: both rolls.
const servedNames = new Set(allNames);
// Which of the two rolls a name belongs to: the big one is the shelf this check
// walks, the small one is a shelf of a single month.
const dayOf = (name) => (smallNames.includes(name) ? smallDayOfName(name) : dayOfName(name));
// The months of the big roll, and the days of the small one: what the rail has to
// hold, read off the roll rather than written down twice.
const monthOf = (ts) => { const monthOf = (ts) => {
const d = new Date(ts); const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}; };
const keyOf = (ts) => { const rollDays = names.map(dayOfName);
const d = new Date(ts); const rollYears = [...new Set(rollDays.map(yearOf))];
return `${monthOf(ts)}-${String(d.getDate()).padStart(2, '0')}`; // The months the roll was shot in, in the shelf's own order: the ruler is a tick
}; // a month of the roll, and the months between them are the ones it skips.
const yearOf = (ts) => String(new Date(ts).getFullYear()); const rollMonths = [...new Set(rollDays.map(monthOf))];
const MONTHS = [...new Set(names.map((name) => monthOf(dayOf(name))))];
const YEARS = [...new Set(names.map((name) => yearOf(dayOf(name))))];
const SMALL_DAY_KEYS = [...new Set(smallNames.map((name) => keyOf(dayOf(name))))];
// The days the big roll is cut into, which is what the wall draws bands for.
const BIG_DAYS = (() => {
let day = 0;
for (let i = 0; i < FRAMES; i += 1) {
if (!i || dayOfName(names[i]) !== dayOfName(names[i - 1])) day += 1;
}
return day;
})();
let failures = 0; let failures = 0;
function step(name, ok, detail = '') { function step(name, ok, detail = '') {
@@ -139,7 +104,7 @@ if (!playwright) {
const jpg = await readFile(SAMPLE_FILE); const jpg = await readFile(SAMPLE_FILE);
const sampleServer = createServer((req, res) => { const sampleServer = createServer((req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (!servedNames.has(name)) { if (!names.includes(name)) {
res.writeHead(404).end(); res.writeHead(404).end();
return; return;
} }
@@ -213,13 +178,10 @@ await context.route('**/api/**', (route) => {
// they are put back where the catalogue reads them, because a cloned handle is a // they are put back where the catalogue reads them, because a cloned handle is a
// plain object with no `getFile`. // plain object with no `getFile`.
await context.addInitScript( await context.addInitScript(
({ origin, names, folder, days, smallNames, smallFolder, smallDays }) => { ({ origin, names, folder, days }) => {
// `days` is the day of each frame, index-aligned with `names`: the init // `days` is the day of each frame, index-aligned with `names`: the init
// script is cloned into the page and cannot close over the check's own scope. // script is cloned into the page and cannot close over the check's own scope.
const dayOfNameIn = (name) => { const dayOfNameIn = (name) => days[names.indexOf(name)] ?? Date.UTC(2026, 0, 1);
const at = names.indexOf(name);
return at >= 0 ? days[at] : smallDays[smallNames.indexOf(name)] ?? Date.UTC(2026, 0, 1);
};
const fileHandle = (name) => { const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true }; const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', { Object.defineProperty(handle, 'getFile', {
@@ -247,21 +209,15 @@ await context.addInitScript(
}); });
return handle; return handle;
}; };
// The folder the reader picks: the first press is the shelf this check walks, const root = () => dirHandle(folder, { files: names });
// the second is the shelf of a single month (the rail's other reading). A window.showDirectoryPicker = async () => root();
// picker is one folder at a time, and a check that needs two picks is a check
// that hands them over one after the other.
const root = (label) =>
label === smallFolder ? dirHandle(smallFolder, { files: smallNames }) : dirHandle(folder, { files: names });
let picked = 0;
window.showDirectoryPicker = async () => root(picked++ === 0 ? folder : smallFolder);
// The day each frame was given, for the header audit below: the page's own // The day each frame was given, for the header audit below: the page's own
// clock reads the label, so the day is read back here the same way. // clock reads the label, so the day is read back here the same way.
window.__dayOf = (name) => dayOfNameIn(name); window.__dayOf = (name) => dayOfNameIn(name);
const revive = (row) => { const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) { if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? root(row.handle.name) : fileHandle(row.handle.name); row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
} }
return row; return row;
}; };
@@ -281,15 +237,7 @@ await context.addInitScript(
}; };
} }
}, },
{ { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER, days: names.map(dayOfName) }
origin: `http://127.0.0.1:${SAMPLE_PORT}`,
names,
folder: FOLDER,
days: names.map(dayOfName),
smallNames,
smallFolder: SMALL_FOLDER,
smallDays: smallNames.map(smallDayOfName),
}
); );
const page = await context.newPage(); const page = await context.newPage();
@@ -346,69 +294,31 @@ const dayAudit = () =>
return out; return out;
}); });
// --- the wall, drawn whole ---------------------------------------------------
await page.click('[data-key="lib-view-grid"]'); await page.click('[data-key="lib-view-grid"]');
await page.waitForSelector('.adm-grid', { timeout: 15_000 }); await page.waitForSelector('.adm-grid', { timeout: 15_000 });
// The wall is the shelf: what the open node holds is what has to be on it. A window await page.waitForFunction(() => document.querySelectorAll('.adm-card').length > 0, null, { timeout: 30_000 });
// over the shelf is a shelf that tears itself open under the reader — a day drawn a
// couple of rows deep, growing as the eye walks down it, and a frame scrolled back
// up to built again — so this is the first thing read.
await page.waitForFunction(
(n) => document.querySelectorAll('.adm-grid .adm-card').length >= n,
FRAMES,
{ timeout: 120_000, polling: 250 }
);
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
const wall = await read(); const wall = await read();
step('the wall draws every frame of the roll', wall.cards === FRAMES, `${wall.cards} of ${FRAMES} cards drawn`); step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < FRAMES, `${wall.cards} of ${FRAMES} cards drawn`);
step('and it is a shelf that scrolls', wall.wall, 'the wall scrolls'); step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls');
const days1 = await dayAudit(); const days1 = await dayAudit();
step('the wall draws a band for every day of the roll', days1.sections === BIG_DAYS, `${days1.sections} days of ${BIG_DAYS}`); // 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('every header names the day of the cards under it', days1.bad.length === 0, JSON.stringify(days1.bad.slice(0, 2)));
// What the rail is read for: the entries it holds, the entry the eye is in, and where // The same wall, scrolled to where no day starts at the fold: the headers have to
// a press on one takes the wall. The day at the fold is read off the sections // move with it, which is the whole risk of cutting a virtualised wall into days.
// themselves — the first one crossing the wall's top band, which is the rule the rail // Read down the shelf rather than at one stop: a stop of any one fraction lands
// itself reads by — and the month the wall says it is in is read off the accent. // inside a day big enough to fill the screen as often as it lands on a fold, so
const railOf = () => // what is read is that no band ever files a card under another day's header, and
page.evaluate(() => { // that the shelf's own cuts move through the window as it travels.
const grid = document.querySelector('.adm-grid');
const rail = document.querySelector('.lib-rail');
const items = [...(rail?.querySelectorAll('.lib-rail-item') ?? [])];
const top = grid.getBoundingClientRect().top;
const band = top + grid.clientHeight * 0.12;
let fold = null;
for (const section of grid.querySelectorAll('.adm-day')) {
const box = section.getBoundingClientRect();
if (box.top <= band && box.bottom > top) {
fold = (section.getAttribute('data-key') ?? '').replace('lib-day-', '');
break;
}
}
const on = document.querySelector('.lib-rail-item.on');
return {
kinds: items.map((b) => b.getAttribute('data-kind') ?? ''),
keys: items.map((b) => (b.getAttribute('data-key') ?? '').replace('lib-rail-', '')),
// The day a press on the entry sends the wall to, which the entry carries.
ats: items.map((b) => b.getAttribute('data-at') ?? ''),
labels: items.map((b) => b.textContent.trim()),
onKind: on?.getAttribute('data-kind') ?? null,
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
fold,
scroll: Math.round(rail?.scrollTop ?? 0),
tops: items.map((b) => b.offsetTop),
heights: items.map((b) => b.offsetHeight),
box: rail?.clientHeight ?? 0,
whole: rail?.scrollHeight ?? 0,
};
});
const monthsOf = (rail) => rail.keys.filter((_, i) => rail.kinds[i] === 'month');
const yearsOf = (rail) => rail.keys.filter((_, i) => rail.kinds[i] === 'year');
const stops = [0.15, 0.35, 0.55, 0.75, 0.95]; const stops = [0.15, 0.35, 0.55, 0.75, 0.95];
const seenMonths = new Set(); const seenDays = new Set();
let scrolledBad = []; let scrolledBad = [];
let railBad = [];
for (const at of stops) { for (const at of stops) {
await page.evaluate((f) => { await page.evaluate((f) => {
const el = document.querySelector('.adm-grid'); const el = document.querySelector('.adm-grid');
@@ -417,98 +327,136 @@ for (const at of stops) {
}, at); }, at);
await page.waitForTimeout(900); await page.waitForTimeout(900);
const audit = await dayAudit(); 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); scrolledBad = scrolledBad.concat(audit.bad);
const rail = await railOf();
// The month the rail says the eye is in has to own the day at the fold, and the
// entry has to be inside the rail's own box: a rail read for the whole shelf and
// standing on its own top is a rail that says nothing.
if (!rail.fold || !rail.on || rail.onKind !== 'month' || rail.fold.slice(0, 7) !== rail.on.slice(0, 7)) {
railBad.push({ at, fold: rail.fold, on: rail.on, kind: rail.onKind });
} else {
seenMonths.add(rail.on);
const i = rail.keys.indexOf(rail.on);
const y = (rail.tops[i] ?? 0) - rail.scroll;
if (y < -2 || y + (rail.heights[i] ?? 0) > rail.box + 2) railBad.push({ at, on: rail.on, y, box: rail.box });
}
} }
step( step(
'a scrolled wall still files every card under its own day', 'a scrolled wall still files every card under its own day',
scrolledBad.length === 0, scrolledBad.length === 0 && seenDays.size > 1,
JSON.stringify(scrolledBad.slice(0, 2)) `${seenDays.size} days through the window, ${JSON.stringify(scrolledBad.slice(0, 2))}`
);
const rail1 = await railOf();
const months1 = monthsOf(rail1);
// The rail carries the shelf's own order — the reader's sort, months down the wall as
// the wall holds them — so what is compared here is the set of months, not the order
// twice over.
step(
'the rail holds the shelf by the year and the month, and by nothing finer',
[...months1].sort().join() === [...MONTHS].sort().join() &&
[...yearsOf(rail1)].sort().join() === [...YEARS].sort().join() &&
rail1.kinds.every((k) => k === 'year' || k === 'month'),
`${yearsOf(rail1).length} years, ${months1.length} months of ${MONTHS.length}`
);
step(
'and the entry it reads follows the eye down the shelf',
railBad.length === 0 && seenMonths.size >= 3,
`${seenMonths.size} of ${MONTHS.length} months read, ${JSON.stringify(railBad.slice(0, 2))}`
);
step(
'the rail is longer than the wall is tall, and scrolls under the reader',
rail1.whole > rail1.box,
`${rail1.whole}px of rail in ${rail1.box}px of box`
); );
// And the press: the shelf's own first day of the month is where the wall has to // Back to the head of the roll, where the month's own band is drawn.
// stand, its header just under the wall's top edge, the entry pressed is the month await page.evaluate(() => {
// read afterwards, and the rail brings that entry into its own box. const el = document.querySelector('.adm-grid');
const press = async (key) => { el.scrollTop = 0;
await page.click(`[data-key="lib-rail-${key}"]`); el.dispatchEvent(new Event('scroll'));
await page.waitForTimeout(700); });
return page.evaluate((k) => { await page.waitForTimeout(1200);
const grid = document.querySelector('.adm-grid');
const rail = document.querySelector('.lib-rail'); // The two bands, at the heights the wall counts its shelf in. The month's and the
const button = document.querySelector(`[data-key="lib-rail-${k}"]`); // day's band are both fixed numbers in the style sheet and the shelf's whole
const at = button?.getAttribute('data-at') ?? null; // length — and with it every spacer standing in for the rows not drawn — is
const section = at ? grid.querySelector(`[data-key="lib-day-${at}"]`) : null; // counted from them, so a band that grew with its own text is a shelf whose end
const on = document.querySelector('.lib-rail-item.on'); // moves.
const y = button ? button.offsetTop - rail.scrollTop : null; const bands = await page.evaluate(() => {
const h = (sel) => {
const el = document.querySelector(sel);
return el ? Math.round(el.getBoundingClientRect().height) : null;
};
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));
// 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))}`);
// 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
// was shot in is a month with no tick.
const ticks = await page.evaluate(() => ({
name: document.querySelector('.lib-ruler-name')?.textContent?.trim() ?? '',
years: [...document.querySelectorAll('.lib-ruler-tick[data-kind="year"]')].map((e) => (e.textContent ?? '').trim()),
months: [...document.querySelectorAll('.lib-ruler-tick[data-kind="month"]')].map((e) => e.getAttribute('data-at')),
}));
step('the ruler stands beside the wall', ticks.name.length > 0, ticks.name);
step('a tick a month of the roll', ticks.months.length === rollMonths.length, `${ticks.months.length} of ${rollMonths.length} months`);
step('and a named tick a year of it', [...ticks.years].sort().join() === [...rollYears].sort().join(), `${ticks.years.join()} of ${rollYears.join()}`);
// Pressing the ruler: a month's tick brings the band of that month to the top of
// the wall, which is a month the wall had never drawn — the whole reason the
// shelf's own layout is counted rather than measured off the boxes in the DOM.
const press = await page.evaluate(() => {
const list = [...document.querySelectorAll('.lib-ruler-tick[data-kind="month"]')];
const mid = list[Math.floor(list.length / 2)];
const tick = mid.getAttribute('data-key') ?? '';
return { tick, key: tick.replace('lib-ruler-', ''), at: Number(mid.getAttribute('data-at')) };
});
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 on = document.querySelector('.lib-ruler-tick.on');
return {
drawn: Boolean(head),
offset: head && grid ? Math.round(head.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null,
value: document.querySelector('[data-key="lib-ruler-value"]')?.textContent?.trim() ?? '',
on: on ? on.getAttribute('data-at') : '',
};
}, press.key);
step('a press on the ruler brings that month to the top', landed.drawn && landed.offset !== null && landed.offset >= -2 && landed.offset <= 18, `band at ${landed.offset}px`);
step(
'and the ruler reads the month it was taken to',
Number(landed.on) === press.at && landed.value.length > 0,
`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.
const notch = () =>
page.evaluate(() => {
const ticksAll = [...document.querySelectorAll('.lib-ruler-tick')];
const months = ticksAll.filter((t) => t.getAttribute('data-kind') === 'month');
const needle = document.querySelector('[data-key="lib-ruler-needle"]');
return { return {
at, on: months.findIndex((t) => t.classList.contains('on')),
top: section ? Math.round(section.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null, count: months.length,
cards: section ? section.querySelectorAll('.adm-card').length : 0, needle: needle ? parseFloat(needle.style.top) || 0 : 0,
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null, tick: months.find((t) => t.classList.contains('on')) ? parseFloat(months.find((t) => t.classList.contains('on')).style.top) || 0 : 0,
onKind: on?.getAttribute('data-kind') ?? null,
seen: y !== null && y >= -2 && y + (button?.offsetHeight ?? 0) <= rail.clientHeight + 2,
}; };
}, key); });
}; const beforeNotch = await notch();
const jumped = months1[Math.floor(months1.length / 2)]; await page.evaluate(() => {
const landed = await press(jumped); const el = document.querySelector('[data-key="lib-ruler"]');
step( el.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
'a press on a month stands the wall on that month, and the rail on its entry', });
landed.top !== null && await page.waitForTimeout(1500);
Math.abs(landed.top - 6) <= 6 && const afterNotch = await notch();
landed.on === jumped && step('a notch of the wheel is one month', afterNotch.on === beforeNotch.on + 1, `${beforeNotch.on} -> ${afterNotch.on} of ${beforeNotch.count}`);
landed.onKind === 'month' && step('and the needle stands in the month the eye is in', Math.abs(afterNotch.needle - afterNotch.tick) < 0.6, `needle ${afterNotch.needle}% of tick ${afterNotch.tick}%`);
landed.seen,
`header at ${landed.top} of ${landed.at}, entry ${landed.on}, in rail ${landed.seen}`
);
// The year is a head and not a month of the shelf: a press on it stands the wall on // The shelf walked to its own end, a screen at a time — a jump straight to the
// the first month the shelf holds of that year, and the eye then reads that month. // bottom is a different question, and the one this check exists for: the wall
const yearRow = yearsOf(rail1)[1]; // stands in for the frames it is not drawing, and a spacer standing in for more
const overYear = await press(yearRow); // rows than those frames take leaves the reader at the end of the scroll with a
step( // screen of nothing under the last card drawn and the rest of the roll never
'a press on a year stands the wall on the first month it holds', // drawn at all. What the walk is read for is that it *gets* there, standing on
overYear.top !== null && Math.abs(overYear.top - 6) <= 6 && overYear.at?.slice(0, 4) === yearRow && overYear.on === overYear.at?.slice(0, 7), // the last frame, and that every frame of the roll was drawn on the way.
`year ${yearRow}: header at ${overYear.top} of ${overYear.at}, entry ${overYear.on}`
);
// The shelf walked to its own end, a screen at a time — a jump straight to the bottom
// is a different question, and this is the one that stops a reader at a screen of
// nothing: what the walk is read for is that it *gets* there, standing on the last
// frame with no room left under it.
const walked = await page.evaluate(async () => { const walked = await page.evaluate(async () => {
const el = document.querySelector('.adm-grid'); const el = document.querySelector('.adm-grid');
const seen = new Set(); const seen = new Set();
@@ -533,75 +481,35 @@ const walked = await page.evaluate(async () => {
top: Math.round(el.scrollTop), top: Math.round(el.scrollTop),
reached: el.scrollTop + el.clientHeight >= el.scrollHeight - 2, reached: el.scrollTop + el.clientHeight >= el.scrollHeight - 2,
room: Math.round(el.scrollHeight - el.clientHeight), room: Math.round(el.scrollHeight - el.clientHeight),
// What is left under the last card drawn. A few pixels is the grid's own gap. // 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, gap: last ? Math.round(el.scrollHeight - (last.getBoundingClientRect().bottom - box.top + el.scrollTop)) : null,
seen: [...seen],
}; };
}); });
step('the shelf is walked to its own end', walked.reached, `stopped at ${walked.top} of ${walked.room}`); 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 <= 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'}`);
// What the wall mints, read at the far end of the shelf: the tiles are the frames of // The class of trigger, made to happen: one input the wall reads back off itself,
// the days around the eye, and a shelf walked end to end is not a shelf of tiles held // moving on every pass.
// end to end. A frame the wall holds no tile for draws nothing at all — a folder off await page.evaluate(() => {
// the disk is a file only this page can open — so the count of blob-backed images is const el = document.querySelector('.adm-grid');
// the count of tiles the wall is holding. const h = el.clientHeight;
const held = await page.evaluate(() => ({ let n = 0;
cards: document.querySelectorAll('.adm-grid .adm-card').length, Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => (n++ % 2 ? h : h + 300) });
tiles: document.querySelectorAll('.adm-grid img[src^="blob:"]').length, el.dispatchEvent(new Event('scroll'));
})); });
step( await page.waitForTimeout(8000);
'the wall mints the tiles around the eye and not the whole shelf',
held.tiles > 0 && held.tiles < held.cards / 2,
`${held.tiles} tiles held of ${held.cards} cards`
);
// A wall resized under its own eye: the class of input that cost the screen once, when
// the wall measured itself off itself. It re-columns, the rail re-reads, and the reader
// is still standing in the library after it.
await page.setViewportSize({ width: 1180, height: 820 });
await page.waitForTimeout(700);
await page.setViewportSize({ width: 1500, height: 950 });
await page.waitForTimeout(700);
const after = await read(); const after = await read();
const looped = errors.filter((e) => e.includes('185') || e.includes('update depth')); const looped = errors.filter((e) => e.includes('185') || e.includes('update depth'));
step( step(
'a wall resized under its own eye does not take the screen down', 'a wall fed its own window does not take the screen down',
!after.blank && after.cards === FRAMES && looped.length === 0, !after.blank && after.cards > 0 && looped.length === 0,
`blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}` `blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}`
); );
const rail2 = await railOf(); step('the wall is still the wall', after.cards > 0, `${after.cards} cards after the feed`);
step(
'and its rail still reads the day at the fold',
rail2.on !== null && rail2.fold !== null && rail2.fold.slice(0, 7) === rail2.on.slice(0, 7),
`fold ${rail2.fold}, entry ${rail2.on}`
);
// The rail's other reading: a shelf of a single month is a shelf read by the day. The
// second folder picked is that shelf — two days of twenty frames — and the wall on it
// is a wall of two bands.
await page.click('[data-key="lib-add"]');
await page.waitForSelector(`[data-key="lib-node-${SMALL_FOLDER}"]`, { timeout: 60_000 });
await page.click(`[data-key="lib-node-${SMALL_FOLDER}"]`);
await page.waitForFunction(
(n) => (document.querySelector('[data-key="lib-count"]')?.textContent ?? '').includes(String(n)),
smallNames.length,
{ timeout: 240_000, polling: 250 }
);
await page.click('[data-key="lib-view-grid"]');
await page.waitForTimeout(1500);
const rail3 = await railOf();
const smallMonth = SMALL_DAY_KEYS[0].slice(0, 7);
step(
'a shelf of one month is still read by the year and the month',
rail3.kinds.join() === 'year,month' && yearsOf(rail3).length === 1 && monthsOf(rail3).join() === smallMonth,
`${rail3.labels.join(' ')} / ${rail3.keys.join(' ')}`
);
const overMonth = await press(smallMonth);
step(
'and a press on it stands the wall on that month, whole',
overMonth.top !== null && Math.abs(overMonth.top - 6) <= 6 && overMonth.cards === SMALL_EACH && overMonth.on === smallMonth,
`header at ${overMonth.top}, ${overMonth.cards} cards, entry ${overMonth.on}`
);
await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {}); await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {});
if (errors.length) console.log('page errors:', errors.slice(0, 6).join(' // ')); if (errors.length) console.log('page errors:', errors.slice(0, 6).join(' // '));
+611 -281
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react';
import { useI18n } from './i18n/I18nProvider'; import { useI18n } from './i18n/I18nProvider';
import { go } from './nav'; import { go } from './nav';
import { api, type User } from './api'; import { api, type User } from './api';
@@ -207,24 +207,42 @@ const TILE_STEP = 140;
// meets pictures rather than placeholders, few enough that the count does not // meets pictures rather than placeholders, few enough that the count does not
// matter: what is drawn is this many, whatever the shelf holds. // matter: what is drawn is this many, whatever the shelf holds.
const STRIP_KEEP = 8; const STRIP_KEEP = 8;
// How many rows of the wall past each edge of its viewport are drawn. A row of
// cards is a couple of hundred pixels tall, so this is the row under the eye and
// two beyond it — enough that a flick lands on pictures.
const WALL_ROWS = 2;
// How many tiles the wall reads and decodes at once when it is the one asking. // How many tiles the wall reads and decodes at once when it is the one asking.
// The wall's own frames come first and they come visibly: two at a time, the // The wall's own frames come first and they come visibly: two at a time, the
// thread handed back between them (`breathe`), is a shelf that fills in front of // thread handed back between them (`breathe`), is a shelf that fills in front of
// the reader — a screenful of frames read end to end is a dead screen. // the reader — a screenful of frames read end to end is a dead screen.
const WALL_LANES = 2; const WALL_LANES = 2;
// A folder is walked into with a screenful in sight, not the whole roll: the tiles // A folder is walked into with a screenful in sight, not the whole roll: the tiles
// asked for when a folder opens are the ones about to be drawn. A job over every // asked for when a folder opens are the ones about to be drawn, and the wall mints
// frame of the roll is a decode of every frame of the roll — half an hour of disk // the rest as it scrolls. A job over every frame of the roll is a decode of every
// for a folder nobody has scrolled past the first screen of. // frame of the roll — half an hour of disk for a folder nobody has scrolled past
// the first screen of.
// Sixty and not four hundred: a tile is a decode and a JPEG encode on the main // Sixty and not four hundred: a tile is a decode and a JPEG encode on the main
// thread (`makeThumbs`, two lanes), and four hundred of them queued behind one // thread (`makeThumbs`, two lanes), and four hundred of them queued behind one
// folder opening is the reader waiting on tiles for frames three screens below // folder opening is the reader waiting on tiles for frames three screens below
// the one they are looking at. // 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; const THUMB_AHEAD = 60;
// What the wall mints before it has been looked at: the head of the shelf, which is // What `.adm-grid` leaves between its rows. A spacer has to stand the same
// the order it drew the days in. A first paint with no tiles at all is a rack of // distance past the last row it replaces as the row itself would have.
// empty cards, and the first look lands a frame later. const WALL_GAP = 12;
const THUMB_HEAD = 120; // 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;
// The hours a frame's own clock can read, and what the time filter offers: the // 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. // whole day in the 24-hour form a camera writes into EXIF.
const HOURS = Array.from({ length: 24 }, (_, i) => i); const HOURS = Array.from({ length: 24 }, (_, i) => i);
@@ -349,14 +367,7 @@ export function Library() {
} }
import { useIsMobile } from './ui/useIsMobile'; import { useIsMobile } from './ui/useIsMobile';
import { dayLabel, dayRuns, monthKey, monthLabel, yearKey } from './ui/dateGroups'; import { bandRows, dayAt, dayKey, dayLabel, dayRuns, monthKey, monthLabel, monthTitle, photoRow, rowAt, rowPhoto, rowTop, wallLayout, yearKey, type WallSteps } from './ui/dateGroups';
// One entry of the rail down the wall's right edge: a year (`2026`) with the
// months under it (`2026-10`). `at` is the day key the wall is sent to when the
// entry is pressed — the shelf's own first day of that month, and for a year the
// first day of its first month on the shelf. The rail names the time by the year
// and the month and stops there: a day is what the wall's own bands are for.
type RailMark = { kind: 'year' | 'month'; key: string; at: string; label: string; title: string };
function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
@@ -2018,130 +2029,126 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}); });
}, []); }, []);
// The wall is the other list, and it is drawn whole. It used to be drawn the way // The wall is the other list, and it draws its rows the way the strip draws its
// the strip is — the rows around the eye, with a spacer standing in for the rest // tiles: the ones around the eye, with a spacer standing in for the rest. A card
// — and what the reader sees in that is a shelf tearing itself open: the frames // is a card, an `<img>` and an object URL of its own, and twenty thousand of them
// below the fold are not in the DOM to be scrolled to, so a day is drawn a couple // is seven seconds of dead screen — the URL is only a tenth of it; the rest is
// of rows deep and grows as the eye walks down it, a frame scrolled back up to is // the DOM and the number the browser formats for each date. The frame on the
// a frame the wall has to build again, and the shelf's own end is a spacer that // stage is given a picture wherever it sits — the stage is not the wall.
// has to be right to the pixel or the scroll stops in a screen of nothing. // 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
// What makes it safe to draw whole is that the wall now holds no state: no // is the shelf's own days it counts and not the window's. `dayHeads` is the
// measure, no spacer, no model of where a row stands — the browser lays the grid // month above it: a day the shelf's own month changes at opens with one, and
// out once and scrolls it, and there is no second answer to be at odds with the // the rest do not.
// shelf drawn. `loading="lazy"` on the tiles (see `TileImg`) is what keeps the const { dayStarts, dayHeads } = useMemo(() => {
// frames below the fold from being read before the eye is near them, and the const starts: number[] = [];
// tiles behind them are minted off the whole list, a couple of lanes at a time const heads: number[] = [];
// (`useThumbUrls`), so a day scrolled to has its pictures rather than a rack of const at = (photo: (typeof listed)[number]) => photo.taken || photo.addedAt;
// empty cards. for (let i = 0; i < listed.length; i++) {
// if (i && dayKey(at(listed[i])) === dayKey(at(listed[i - 1]))) continue;
// ponytail: the DOM is the whole shelf — twenty thousand frames is twenty starts.push(i);
// thousand cards built up front, which is seconds of dead screen. When a roll heads.push(i && monthKey(at(listed[i])) === monthKey(at(listed[i - 1])) ? 0 : 1);
// that size is real, the wall goes back to a window, cut by the day rather than }
// by the row: a day is a height the browser can be told in advance return { dayStarts: starts, dayHeads: heads };
// (`content-visibility` and the frames under it), which is what a window of rows }, [listed]);
// could not be. const wall = useWallWindow(listed.length, dayStarts, dayHeads, view === 'grid');
// // Memoised like `drawn`, and for a reason of its own: `useThumbUrls` keys its
// The wall is read by the day a frame was shot, and the line above each run is // effect on the array it is handed, so a fresh slice every render made that
// that day. Cut off the list as it stands rather than sorted into days of its // effect run every render — a `setUrls` per pass, a render per `setUrls`, and
// own, so the sort chip keeps the order it names. // thousands of passes a second in which the tiles of the rows scrolled to never
const days = useMemo(() => dayRuns(listed, (photo) => photo.taken || photo.addedAt), [listed]); // land at all.
// The wall's own box, and the rail is read off it: the scroll that reaches every const painted = useMemo(
// frame is the grid's, since the grid is the whole shelf. () => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)),
const gridRef = useRef<HTMLDivElement>(null); [listed, wall.from, wall.to]
);
// The rail down the wall's right edge: the shelf's own time, a year and the // The same slice, cut into the days it holds: a wall is read by the day a frame
// months under it — and nothing finer, since the day a frame belongs to is what // was shot, and the line above each run is that day. Cut off the slice as it
// the wall's bands already say. It is cut off the same runs the wall is drawn in, // stands rather than sorted into days of its own, so the sort chip keeps the
// so an entry and the header it jumps to are one cut of the shelf, not two. // order it names; the first run may be a day that started above the window,
const rail = useMemo(() => { // which is its own header drawn where the eye lands (see `dayRuns`). A run the
const all: RailMark[] = []; // 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]);
// 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.
const timeMarks = useMemo(() => {
const marks: TimelineMark[] = [];
let year = ''; let year = '';
let month = ''; let month = '';
for (const day of days) { for (const photo of listed) {
const key = monthKey(day.at); const time = photo.taken || photo.addedAt;
const yr = yearKey(day.at); const y = yearKey(time);
// The year stands over its months, and the first day the shelf holds of it if (y !== year) {
// is where a press on the year lands. year = y;
if (yr !== year) {
year = yr;
month = ''; month = '';
all.push({ kind: 'year', key: yr, at: day.key, label: yr, title: yr }); marks.push({ kind: 'year', key: y, at: time, label: y });
} }
if (key !== month) { const m = monthKey(time);
month = key; if (m !== month) {
const label = monthLabel(day.at, lang); month = m;
all.push({ kind: 'month', key, at: day.key, label, title: `${label} ${year}` }); marks.push({ kind: 'month', key: m, at: time, label: monthLabel(time, lang) });
} }
} }
return all; return marks;
}, [days, lang]); }, [listed, lang]);
const [railAt, setRailAt] = useState(0); // The month the needle stands in: the month of the day band the eye is under,
const railRef = useRef<HTMLElement>(null); // written by the observer below. `''` before the wall has said anything, which
// leaves the needle at the top of the ruler.
// Which entry the eye is on. The days are watched from the wall's own box and const [eyeMonth, setEyeMonth] = useState('');
// the one crossing its top band is the day being read; the entry that owns it is
// the month (or the day) its key begins with. Nothing is measured here and
// nothing is written back to the wall — the browser is the one that knows, and it
// says so. A flick can leave the band empty; the rail keeps the entry it had
// rather than blinking back to the top of the shelf.
useEffect(() => { useEffect(() => {
if (view !== 'grid') return; const el = wall.ref.current;
const el = gridRef.current; if (view !== 'grid' || !el || !timeMarks.length) return;
if (!el) return; // The band the eye is under, and not the first one in the DOM: the window
const nodes = [...el.querySelectorAll<HTMLElement>('.adm-day')]; // draws a couple of rows above the fold as well, and a needle that read the
if (!nodes.length) return; // first band drawn would stand two rows early. `-72%` off the bottom leaves
const place = new Map(nodes.map((node, i) => [node.dataset.key ?? '', i])); // the top three tenths of the wall as the band of the eye.
const seen = new Set<string>();
const io = new IntersectionObserver( const io = new IntersectionObserver(
(entries) => { (entries) => {
for (const entry of entries) { for (const entry of entries) {
const key = (entry.target as HTMLElement).dataset.key ?? ''; if (!entry.isIntersecting) continue;
if (entry.isIntersecting) seen.add(key); const month = (entry.target as HTMLElement).dataset.month;
else seen.delete(key); if (month) setEyeMonth(month);
} }
let best = -1;
for (const key of seen) {
const i = place.get(key) ?? -1;
if (i >= 0 && (best < 0 || i < best)) best = i;
}
if (best < 0) return;
const key = (nodes[best].dataset.key ?? '').replace('lib-day-', '');
// The month the day belongs to is the entry, not the year over it: the
// year is a head, and a head lit for every day of it says less than the
// month does.
let found = rail.findIndex((mark) => mark.kind === 'month' && mark.key === key.slice(0, 7));
if (found < 0) found = rail.findIndex((mark) => mark.kind === 'year' && mark.key === key.slice(0, 4));
setRailAt(found < 0 ? 0 : found);
}, },
{ root: el, rootMargin: '0px 0px -88% 0px' } { root: el, rootMargin: '0px 0px -72% 0px', threshold: 0 }
); );
for (const node of nodes) io.observe(node); for (const section of el.querySelectorAll<HTMLElement>('.adm-day')) io.observe(section);
return () => io.disconnect(); return () => io.disconnect();
}, [rail, days, view]); }, [view, wall.ref, wall.from, wall.to, timeMarks.length]);
const eyeAt = useMemo(() => {
// The rail's own scroll: the entry being read is kept by the middle of the const mark = timeMarks.find((m) => m.kind === 'month' && m.key === eyeMonth);
// column, so a shelf of years is not a rail standing on its own top. if (mark) return mark.at;
useEffect(() => { const first = listed[0];
const el = railRef.current; return first ? first.taken || first.addedAt : 0;
const item = el?.children[railAt] as HTMLElement | undefined; }, [timeMarks, eyeMonth, listed]);
if (!el || !item) return; // Going where the ruler is pressed: the first day of that month is brought to
const want = Math.max(0, item.offsetTop - el.clientHeight / 2 + item.offsetHeight / 2); // the top of the wall. The day's own top comes off the shelf's layout rather
if (Math.abs(el.scrollTop - want) > 8) el.scrollTop = want; // than off its box — the day is not in the DOM, which is the whole reason the
}, [railAt]); // layout exists (see `wallLayout`) — so the jump lands on a month the wall has
// never drawn.
// A press on an entry takes the wall to the day it stands for: the header is at const jumpToMonth = useCallback(
// a place the grid already knows, and the scroll is put where it stands — a jump (at: number) => {
// and not a step, and the highlight follows the eye from there like any other const el = wall.ref.current;
// scroll. if (!el || !listed.length) return;
const jumpToTime = (key: string) => { const key = monthKey(at);
const el = gridRef.current; const k = dayStarts.findIndex((start) => monthKey(listed[start].taken || listed[start].addedAt) === key);
const day = el?.querySelector<HTMLElement>(`[data-key="lib-day-${CSS.escape(key)}"]`); if (k < 0) return;
if (!el || !day) return; const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
const box = el.getBoundingClientRect(); const layout = wallLayout(dayStarts, dayHeads, listed.length, cols, wall.steps.current);
el.scrollTop = Math.max(0, el.scrollTop + day.getBoundingClientRect().top - box.top - 6); if (!(layout.tops[k] >= 0)) return;
}; el.scrollTop = Math.max(0, layout.tops[k] - 6);
},
[listed, dayStarts, dayHeads, wall.ref, wall.steps]
);
// A tile the studio graded while this screen was dark: the copy in hand is the // 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 // negative's, and `onThumbWritten` hands over the one that replaces it. The
// counter is what makes the rows look again — the list is unchanged, so nothing // counter is what makes the rows look again — the list is unchanged, so nothing
@@ -2160,49 +2167,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}), }),
[] []
); );
// Which days the eye is near, and the only ones whose tiles are read. A shelf of const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame, tilesAt);
// twenty thousand frames is twenty thousand files to open and decode; held all at
// once that is the memory of the machine, and the wall ever shows a screen of it.
// Nothing here is measured — the browser says which day bands its own box holds,
// a screen above and a screen below, and leaves the rest alone.
const [seenDays, setSeenDays] = useState<ReadonlySet<string>>(() => new Set());
useEffect(() => {
if (view !== 'grid') return;
const el = gridRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
setSeenDays((prev) => {
const next = new Set(prev);
let touched = false;
for (const entry of entries) {
const key = (entry.target as HTMLElement).dataset.key ?? '';
if (entry.isIntersecting) {
if (!next.has(key)) {
next.add(key);
touched = true;
}
} else if (next.delete(key)) {
touched = true;
}
}
return touched ? next : prev;
});
},
{ root: el, rootMargin: '100% 0px 100% 0px' }
);
for (const node of el.querySelectorAll('.adm-day')) io.observe(node);
return () => io.disconnect();
}, [days, view]);
// What the wall mints for: the frames of the days around the eye, and the head of
// the shelf until the first look has landed.
const nearby = useMemo(() => {
if (view !== 'grid') return drawn;
const seen = days.filter((day) => seenDays.has(`lib-day-${day.key}`));
return seen.length ? seen.flatMap((day) => day.photos) : listed.slice(0, THUMB_HEAD);
}, [view, days, seenDays, listed, drawn]);
const urls = useThumbUrls(nearby, frame, tilesAt);
// Auto-scroll & focus tree sidebar to the active node selected in last session // Auto-scroll & focus tree sidebar to the active node selected in last session
const treeRef = useRef<HTMLElement>(null); const treeRef = useRef<HTMLElement>(null);
@@ -2256,16 +2221,20 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}, [view, frame?.id, listed, node, strip.ref]); }, [view, frame?.id, listed, node, strip.ref]);
// And the wall's half of it: the frame the strip was on, brought to the middle // And the wall's half of it: the frame the strip was on, brought to the middle
// of the wall. The card is in the DOM — the wall draws the whole shelf — so this // of the wall. The card is not in the DOM to begin with — the wall only paints
// is one scroll of the grid and no model of where a row stands. The loop is for // the rows around its viewport — so the first passes go to where the row should
// the pass the list has not landed on yet, when there is no card to measure and // be and the ones after that read the card itself, which is the only honest
// what has to run this again is the list itself, not a frame of waiting. // 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.
useEffect(() => { useEffect(() => {
if (view !== 'grid') return; if (view !== 'grid') return;
const id = centredRef.current; const id = centredRef.current;
const el = gridRef.current; const el = wall.ref.current;
if (!id || !el) return; if (!id || !el) return;
if (!listed.some((p) => p.id === id)) { const idx = listed.findIndex((p) => p.id === id);
if (idx < 0) {
centredRef.current = null; centredRef.current = null;
return; return;
} }
@@ -2278,8 +2247,29 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const box = el.getBoundingClientRect(); const box = el.getBoundingClientRect();
const own = card.getBoundingClientRect(); const own = card.getBoundingClientRect();
el.scrollTop = Math.max(0, el.scrollTop + own.top - box.top + own.height / 2 - el.clientHeight / 2); el.scrollTop = Math.max(0, el.scrollTop + own.top - box.top + own.height / 2 - el.clientHeight / 2);
centredRef.current = null; } else {
return; // No card drawn: the wall opens on the first screenful, so aim at the row
// the frame stands in and let the next pass find it. The shelf's own steps
// and the rows its days are counted in are the wall's, read off the same
// layout its window is measured against — a step of this effect's own is a
// second answer to a question the wall has already answered.
const first = el.querySelector<HTMLElement>('.adm-card');
const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
const steps = wall.steps.current;
if (steps.row > 0) {
const layout = wallLayout(dayStarts, dayHeads, listed.length, cols, steps);
const row = photoRow(dayStarts, cols, layout.order, idx);
el.scrollTop = Math.max(0, Math.round(rowTop(layout, row) - (el.clientHeight - steps.row) / 2));
} else {
// A step guessed at, for the one pass before the wall has drawn a card
// to measure: the row the frame stands in is counted over the shelf's own
// bands, not over its own index — every day spends a row above its first
// frame.
const pitch = first ? first.offsetHeight + WALL_GAP : 240;
const order = bandRows(dayStarts, listed.length, cols);
const row = photoRow(dayStarts, cols, order, idx);
el.scrollTop = Math.max(0, Math.round(row * pitch - (el.clientHeight - pitch) / 2));
}
} }
if (--left > 0) requestAnimationFrame(settle); if (--left > 0) requestAnimationFrame(settle);
else centredRef.current = null; else centredRef.current = null;
@@ -2291,7 +2281,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 // `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 // 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. // already was is a wall that would not follow it.
}, [view, listed, up]); }, [view, listed, dayStarts, wall.ref, up]);
// This screen's own comings and goings: it never unmounts, so `here` is the // 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). // only sign of the shelf being the screen up again (see `here` above).
@@ -2333,7 +2323,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 // looked up in `listed` too, because a pick made in another folder is not a
// frame this shelf can raise. // frame this shelf can raise.
const openFromWall = () => { const openFromWall = () => {
const el = gridRef.current; const el = wall.ref.current;
const picked = const picked =
lastSelectedId && selectedIds.has(lastSelectedId) && listed.some((p) => p.id === lastSelectedId) lastSelectedId && selectedIds.has(lastSelectedId) && listed.some((p) => p.id === lastSelectedId)
? lastSelectedId ? lastSelectedId
@@ -3251,104 +3241,110 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
{view === 'grid' ? ( {view === 'grid' ? (
<> <>
{/* The wall is the shelf itself — every day of it, drawn — so {/* The wall is the shelf's own length, so the scrollbar on
its scrollbar on the right edge reaches every frame and a its right edge still reaches every frame; what is drawn
day scrolled to is a day that was already there. The rail is the rows around the eye, with a spacer standing in
beside it is the shelf's time: the month the eye is in, for the rows above and below. A card is a card, an
and a press on one takes the wall to it. */} <img> and an object URL of its own, and twenty thousand
<div className="lib-wall"> of them is a dead screen.
<div className="adm-grid" data-key="lib-wall" ref={gridRef}> The wall is read the way the phone's own grid reads a
{days.map((day) => ( shelf and the way a cloud library does: the months down
<section className="adm-day" key={day.key} data-key={`lib-day-${day.key}`}> it, and inside a month the days, one under the other —
{/* The day above its frames, the way the phone's own grid the band of a day over the frames of that day, so a roll
reads a shelf: a card still says its date, and the band is scanned by the month it was shot. The rows the eye
is what makes a roll scannable by the month it was shot. */} is not in stand as two spacers, which is what the ruler
<h3 className="adm-day-head"> beside the wall is pressed against. */}
<span>{dayLabel(day.at, lang)}</span> <div className="lib-wall-row">
<span className="adm-day-line" aria-hidden="true" /> <div className="adm-grid" ref={wall.ref}>
</h3> {wall.above > 0 ? <span className="adm-wall-gap" style={{ height: wall.above }} aria-hidden="true" /> : null}
<div className="adm-day-grid"> {paintedBlocks.map(({ day, head }) => (
{day.photos.map((photo) => ( <section
<article className="adm-day"
className={`adm-card${selectedIds.has(photo.id) ? ' selected' : ''}`} key={day.key}
key={photo.id} data-key={`lib-day-${day.key}`}
data-key={`lib-photo-${photo.id}`} data-month={monthKey(day.at)}
onClick={(e) => handleSelectPhoto(photo, e)} >
onDoubleClick={() => { {/* The month above the first day it holds, and the
setUp(photo.id); day above its frames. The day's own line still
scrolledFrameRef.current = null; says the whole date — a card is read on its own
setView('preview'); once picked — but the month is named once. */}
}} {head ? (
onContextMenu={(e) => handlePhotoContextMenu(photo, e)} <h2 className="adm-month-head" data-key={`lib-month-${monthKey(day.at)}`}>
<span>{monthTitle(day.at, lang)}</span>
</h2>
) : null}
<h3 className="adm-day-head">
<span>{dayLabel(day.at, lang)}</span>
<span className="adm-day-line" aria-hidden="true" />
</h3>
<div className="adm-day-grid">
{day.photos.map((photo) => (
<article
className={`adm-card${selectedIds.has(photo.id) ? ' selected' : ''}`}
key={photo.id}
data-key={`lib-photo-${photo.id}`}
onClick={(e) => handleSelectPhoto(photo, e)}
onDoubleClick={() => {
setUp(photo.id);
scrolledFrameRef.current = null;
setView('preview');
}}
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
>
{tile(photo)}
{/* The score under the picture, the same five stars the
stage gives: the wall is where a roll is gone
through, and going back to the stage for every
number is the trip this saves. The click stops at
the star — a score is not a pick, and the card's
own click is what selects the frame. */}
<div
className="lib-stars"
data-key={`lib-stars-${photo.id}`}
role="group"
aria-label={t('lib.rating')}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
> >
{tile(photo)} {[1, 2, 3, 4, 5].map((n) => (
{/* The score under the picture, the same five stars the <button
stage gives: the wall is where a roll is gone key={n}
through, and going back to the stage for every type="button"
number is the trip this saves. The click stops at className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
the star — a score is not a pick, and the card's data-key={`lib-star-${photo.id}-${n}`}
own click is what selects the frame. */} aria-pressed={(photo.star ?? 0) >= n}
<div aria-label={t('lib.starHint', { n })}
className="lib-stars" title={t('lib.starHint', { n })}
data-key={`lib-stars-${photo.id}`} onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
role="group" >
aria-label={t('lib.rating')} ★
onClick={(e) => e.stopPropagation()} </button>
onDoubleClick={(e) => e.stopPropagation()} ))}
> </div>
{[1, 2, 3, 4, 5].map((n) => ( <div className="adm-meta">
<button <b>{photo.name}</b>
key={n} <span>
type="button" {photo.star ? `★${photo.star} · ` : ''}
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`} {new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}
data-key={`lib-star-${photo.id}-${n}`} {edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
aria-pressed={(photo.star ?? 0) >= n} </span>
aria-label={t('lib.starHint', { n })} </div>
title={t('lib.starHint', { n })} </article>
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)} ))}
> </div>
★ </section>
</button> ))}
))} {wall.below > 0 ? <span className="adm-wall-gap" style={{ height: wall.below }} aria-hidden="true" /> : null}
</div> </div>
<div className="adm-meta"> {/* The shelf's own time, down its right edge: a year is the
<b>{photo.name}</b> tick that is named, the months of it are the ticks under
<span> it, and the band the eye is in is the needle. */}
{photo.star ? `★${photo.star} · ` : ''} <TimelineRuler
{new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })} marks={timeMarks}
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''} value={eyeAt}
</span> readout={eyeMonth ? `${monthLabel(eyeAt, lang)} ${yearKey(eyeAt)}` : ''}
</div> onPick={(mark) => jumpToMonth(mark.at)}
</article> />
))}
</div>
</section>
))}
</div>
{/* The rail beside the wall: the shelf's own time by the
year and the month, one press away from any of them,
and the month the eye is in said in the accent. It
scrolls on its own where the shelf is years long, and
it is read off the days the wall was cut into (`rail`),
so an entry and the header over the frames it names are
one cut of the shelf and not two. */}
<nav className="lib-rail" data-key="lib-rail" ref={railRef} aria-label={t('lib.rail')}>
{rail.map((mark, i) => (
<button
key={`${mark.kind}-${mark.key}`}
type="button"
className={`lib-rail-item lib-rail-${mark.kind}${i === railAt ? ' on' : ''}`}
data-key={`lib-rail-${mark.key}`}
data-kind={mark.kind}
data-at={mark.at}
aria-current={i === railAt ? 'true' : undefined}
title={mark.title}
onClick={() => jumpToTime(mark.at)}
>
{mark.label}
</button>
))}
</nav>
</div> </div>
</> </>
) : frame ? ( ) : frame ? (
@@ -4135,10 +4131,9 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?
} }
} }
// The record is the list in hand and nothing else: a day scrolled away is a day if (Object.keys(syncUrls).length > 0) {
// whose tiles are let go here, so what a long shelf holds is the screen around setUrls((prev) => ({ ...prev, ...syncUrls }));
// the eye rather than every frame it has ever drawn. }
setUrls(syncUrls);
if (idsToFetch.length === 0) return; if (idsToFetch.length === 0) return;
@@ -4225,3 +4220,338 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject<HTMLDivEle
}, [count, on]); }, [count, on]);
return { ref, from: win.from, to: win.to }; return { ref, from: win.from, to: win.to };
} }
// Which rows of the wall are drawn: the ones under its viewport and `WALL_ROWS`
// either side, with a spacer standing in for the rest so the scrollbar still spans
// the shelf. The wall holds every frame the open node does — a filter is a way of
// looking, not a second shelf — and a card is a card, an `<img>` and an object URL
// of its own; twenty thousand of them is a dead screen for as long as they take to
// build. `on` is the wall being up, since the element measured is its own.
function useWallWindow(
count: number,
dayStarts: number[],
heads: number[],
on: boolean
): {
ref: RefObject<HTMLDivElement>;
from: number;
to: number;
above: number;
below: number;
// The steps the shelf is stacked in, which the wall reads off the cards it has
// drawn and keeps. Handed back as the ref itself, not its value: the settle
// effect aims a scroll with them on the pass after the one that measured them.
steps: { current: WallSteps };
} {
const ref = useRef<HTMLDivElement>(null);
// The first window is a screenful: the wall has not been measured yet.
const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, 24), above: 0, below: 0 }));
// The window last written, because the reader of a `setWin` in a layout effect
// is not the state: a set made from a layout effect renders again whether or
// not the value moved, and this one is remeasured after every render — so a set
// it did not need is the screen's own render loop (React #185, "Maximum update
// depth exceeded"). The ref is the guard: nothing is set that the wall already
// stands at.
const winRef = useRef(win);
const measureRef = useRef<() => void>(() => {});
// The two steps the shelf is stacked in, taken once per column count and kept.
// They are refs rather than values read on every pass because the pass reads
// them off the rows the window drew — and a step that moves with the window is
// a window that moves with the step: the two trade places forever (see
// `measure`).
const stepsRef = useRef<WallSteps>({ head: MONTH_BAND, band: DAY_BAND, row: 0, gap: WALL_GAP });
const colsRef = useRef(0);
// The window over a scroll container is taken rather than kept, so it is taken
// after every render: the first pass draws a screenful, the next reads the row's
// step off it and lays out the spacers, and the pass after that leaves both
// alone. The listeners are for the two things no render can see coming — a
// scroll of the wall, and the window itself changing size.
useEffect(() => {
if (!on) return;
const el = ref.current;
if (!el) return;
// The window is only ever set through here: a `setWin` the wall already
// stands at is a render bought for nothing, and this is measured after every
// render — that is the loop (React #185).
// And the second guard, for a reader the first one cannot see: a window
// written off the window before it is a window that can write itself, and
// whatever the shelf does between the two there are only so many turns of
// that before React gives up with #185 — a blank screen, this app having no
// error boundary between it and the floor. `ponytail:` past the count the
// wall stands still, one window behind, until something outside moves — the
// next scroll, resize, or count hands the pen straight back.
let carries = 0;
const show = (next: { from: number; to: number; above: number; below: number }) => {
const prev = winRef.current;
if (prev.from === next.from && prev.to === next.to && prev.above === next.above && prev.below === next.below) return;
if (carries >= WALL_CARRIES) return;
carries++;
winRef.current = next;
setWin(next);
};
const measure = () => {
// The window is measured off the wall that is up, not off the one this
// effect was set up on: the stage is drawn while a node is open, so a visit
// that loses its nodes and gets them back draws a new wall, and a window
// measured off the old node — a box of nothing, a scroll always nought — is
// a window that never moves again for the rest of the visit.
const now = ref.current;
if (!now) return;
// A wall that is not standing has no box to measure: the library is
// `display:none` while the studio is up, and a window read off it would be
// a window read off nothing — every card reports `offsetTop` 0 and every
// scroll 0 — which threw the shelf back to its top every time the studio
// handed back to it. It is read on the frame it comes back up, which is a
// box change.
if (!now.clientHeight) return;
// And a window that is nowhere on the shelf stands nowhere: outside a shelf
// that shrank under it — another folder, a filter, a shorter roll — or empty
// while the shelf is not, which is the first paint, before the catalogue has
// landed. This is the one reset, and it is written off the shelf's own
// length rather than off the cards the last window drew, which is what made
// the reset a window the window fed.
const held = winRef.current.to - winRef.current.from;
if (held <= 0 || winRef.current.from >= count || winRef.current.to > count) {
show({ from: 0, to: Math.min(count, 24), above: 0, below: 0 });
return;
}
// How many columns is the grid's own business, read off its layout and not
// counted off the cards the window drew: the drawn run is a whole number of
// rows, those rows were read back into the count, and a count read off the
// run is a count the window feeds itself with. This is the same read the
// settle effect above scrolls the wall by.
const cols = Math.max(1, getComputedStyle(now).gridTemplateColumns.split(' ').length);
// The rows the window drew, counted the way the wall lays them out: a day's
// band is a row of its own (`grid-column: 1 / -1`) and its frames fill the
// rows under it. A row count taken off the cards alone is a count that
// leaves out every band — and the bands are what the rows are counted in.
const sections = [...now.querySelectorAll<HTMLElement>('.adm-day')];
// The one step left to read off the drawn days: a card and the grid's own
// gap — the two bands are heights the style sheet fixes (`MONTH_BAND`,
// `DAY_BAND`), so a shelf is measured exactly from the first card on and
// the spacers do not move with the window. Read the first time the wall
// stands at this many columns, and kept until it stands at another: a step
// re-read on every pass made the window depend on itself — `to` traded
// places with a second sum a row away, sixty times a second, until React
// gave up with #185 and the screen went blank. One step, one window. It is
// re-read when the wall re-columns, the only thing that changes a card's
// own height (a caption wraps at the width it is given); a resize that does
// not re-column keeps the step, and a spacer a few pixels out is a
// scrollbar a few pixels out, not a blank screen.
if (colsRef.current !== cols) {
const card = sections[0]?.querySelector<HTMLElement>('.adm-card');
if (card) {
const row = card.offsetHeight + WALL_GAP;
if (row > 0) {
colsRef.current = cols;
stepsRef.current = { head: MONTH_BAND, band: DAY_BAND, row, gap: WALL_GAP };
}
}
}
// And a run with no step to read — a shelf of one day whose only card is
// not in the DOM yet, a wall of four frames — has none to measure with. It
// is left to the pass after this one, which has the cards: returning here
// instead is a window that cannot grow, and there is a shelf it strands —
// the reset above puts a year of four frames at {0, 4}, and every pass after
// it reads that one row and leaves, so the shelf stands at four cards for
// the rest of the visit.
const steps = stepsRef.current;
if (!(steps.row > 0) || !dayStarts.length) return;
// The shelf measured in those steps: the two spacers, the rows drawn, and
// the scrollbar between them are all read off the same layout, so the
// shelf's own height does not move with the window.
const layout = wallLayout(dayStarts, heads, count, cols, steps);
const row0 = Math.max(0, rowAt(layout, now.scrollTop) - WALL_ROWS);
const row1 = Math.min(layout.rows, rowAt(layout, now.scrollTop + now.clientHeight) + WALL_ROWS);
const from = rowPhoto(dayStarts, count, cols, layout.order, row0);
const to = Math.max(from, rowPhoto(dayStarts, count, cols, layout.order, row1));
// What the two spacers stand for: the days above the window, and the days
// under it. A spacer's own height keeps the gap the grid would have left
// under the last day it stands in for, which is why the gap comes off both.
const first = dayAt(dayStarts, from);
const last = dayAt(dayStarts, Math.max(from, to - 1));
const above = first ? Math.max(0, layout.tops[first] - WALL_GAP) : 0;
const below = Math.max(0, layout.height - layout.ends[last] - WALL_GAP);
show({ from, to, above, below });
};
measureRef.current = measure;
const onOutside = () => {
carries = 0;
measure();
};
el.addEventListener('scroll', onOutside, { passive: true });
const ro = new ResizeObserver(onOutside);
ro.observe(el);
return () => {
measureRef.current = () => {};
el.removeEventListener('scroll', onOutside);
ro.disconnect();
};
}, [count, dayStarts, heads, on]);
useLayoutEffect(() => {
measureRef.current();
});
return { ref, from: win.from, to: win.to, above: win.above, below: win.below, steps: stepsRef };
}
// One tick of the wall's own time: a year, or a month of one. Built off the
// shelf and not off a calendar — a month the roll holds nothing in is a month
// with no tick, which is what keeps the ruler the length of the roll.
type TimelineMark = { kind: 'year' | 'month'; key: string; at: number; label: string };
// The wall's time, read the way the studio reads a number: a graduated scale
// down the shelf's right edge, the year the tick that is named and the months
// of it the ticks under it, with the band the eye is in carrying the needle.
// Dragging anywhere on the scale takes the wall to the month under the pointer,
// the wheel goes one month a notch, and a press on a tick goes to that tick: the
// scale is read by index, so every month of a roll of any length is a press away,
// and a month nothing was shot in is not a month the reader can be taken to.
function TimelineRuler({
marks,
value,
readout,
onPick,
}: {
marks: TimelineMark[];
value: number;
readout: string;
onPick: (mark: TimelineMark) => void;
}) {
const { t } = useI18n();
const scaleRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const live = useRef({ marks, onPick, value });
live.current = { marks, onPick, value };
// Where the needle stands: the month of the band the eye is under, looked up
// by the date the wall handed over — the shelf's own month, so a wall standing
// between two months is read as the one whose frames are over the eye. A value
// that names no month of the shelf (an empty one) leaves the needle at the top.
const eyeOf = (marks: TimelineMark[], value: number) => {
let i = marks.findIndex((mark) => mark.kind === 'month' && mark.at === value);
if (i < 0) {
for (let k = marks.length - 1; k >= 0; k--) {
if (marks[k].kind === 'month' && marks[k].at >= value) {
i = k;
break;
}
}
}
return i;
};
const eye = eyeOf(marks, value);
const last = Math.max(1, marks.length - 1);
const pctOf = (i: number) => (i / last) * 100;
// The wheel belongs to the ruler while the pointer is on it: one notch is one
// month, whichever way the roll is read. Native, because React's own listener
// is passive and the notch must not reach the wall under it.
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
const s = live.current;
const from = eyeOf(s.marks, s.value);
if (from < 0) return;
const dir = e.deltaY < 0 ? -1 : 1;
for (let k = from + dir; k >= 0 && k < s.marks.length; k += dir) {
if (s.marks[k].kind === 'month') {
s.onPick(s.marks[k]);
return;
}
}
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// A pixel of travel is well under a month on any shelf worth a ruler, so the
// reading snaps to the month the pointer is nearest however far it is dragged.
const markAt = (clientY: number) => {
const r = scaleRef.current?.getBoundingClientRect();
if (!r || !(r.height > 0) || !marks.length) return -1;
const frac = (clientY - r.top) / r.height;
return Math.max(0, Math.min(marks.length - 1, Math.round(frac * last)));
};
return (
<div ref={rootRef} className="lib-ruler" data-key="lib-ruler" onPointerDown={(e) => e.stopPropagation()}>
<div className="lib-ruler-head">
<span className="lib-ruler-name">{t('lib.rail')}</span>
<b className="lib-ruler-value" data-key="lib-ruler-value">
{readout}
</b>
</div>
<div
ref={scaleRef}
className="lib-ruler-scale"
data-key="lib-ruler-scale"
onPointerDown={(e) => {
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
const i = markAt(e.clientY);
if (i >= 0) onPick(marks[i]);
}}
onPointerMove={(e) => {
if (!dragging.current) return;
const i = markAt(e.clientY);
if (i >= 0) onPick(marks[i]);
}}
onPointerUp={(e) => {
dragging.current = false;
e.currentTarget.releasePointerCapture(e.pointerId);
}}
onPointerCancel={() => {
dragging.current = false;
}}
>
{/* The year's own band: the months of one year stand together under it,
which is the whole of what "read by the year, cut by the month" is. */}
{marks.map((mark, i) =>
mark.kind !== 'year' ? null : (
<span
key={`band-${mark.key}`}
className="lib-ruler-band"
aria-hidden="true"
style={{ top: `${pctOf(i)}%`, height: `${pctOf(yearEnd(marks, i)) - pctOf(i)}%` }}
/>
)
)}
{marks.map((mark, i) => (
<span
key={mark.key}
className={`lib-ruler-tick lib-ruler-${mark.kind}${i === eye ? ' on' : ''}`}
style={{ top: `${pctOf(i)}%` }}
data-key={`lib-ruler-${mark.key}`}
data-kind={mark.kind}
data-at={mark.at}
title={mark.label}
onClick={() => onPick(mark)}
>
{mark.kind === 'year' ? <i>{mark.label}</i> : null}
</span>
))}
<span
className="lib-ruler-needle"
data-key="lib-ruler-needle"
style={{ top: `${eye < 0 ? 0 : pctOf(eye)}%` }}
aria-hidden="true"
/>
</div>
</div>
);
}
// The mark the year at `i` ends at — the next year's own tick, or the last mark
// of the ruler — so a year's band stands under every month of it and no more.
function yearEnd(marks: TimelineMark[], i: number): number {
for (let k = i + 1; k < marks.length; k++) {
if (marks[k].kind === 'year') return k;
}
return marks.length - 1;
}
+1 -1
View File
@@ -278,7 +278,7 @@ export const en: Dict = {
'lib.colWidth': 'Drag to set the width of the folder column (arrow keys when focused)', 'lib.colWidth': 'Drag to set the width of the folder column (arrow keys when focused)',
'lib.viewPreview': 'SINGLE', 'lib.viewPreview': 'SINGLE',
'lib.viewGrid': 'ALL THUMBS', 'lib.viewGrid': 'ALL THUMBS',
// The column of time down the wall's right edge (see `Library.tsx`). // The column of time down the wall's right edge (see `TimelineRuler` in `Library.tsx`).
'lib.rail': 'Timeline', 'lib.rail': 'Timeline',
'lib.prev': 'Previous frame', 'lib.prev': 'Previous frame',
'lib.next': 'Next frame', 'lib.next': 'Next frame',
+1 -1
View File
@@ -289,7 +289,7 @@ export const vi = {
'lib.colWidth': 'Kéo để chỉnh độ rộng cột thư mục (mũi tên trái/phải khi đang chọn)', 'lib.colWidth': 'Kéo để chỉnh độ rộng cột thư mục (mũi tên trái/phải khi đang chọn)',
'lib.viewPreview': 'MỘT ẢNH', 'lib.viewPreview': 'MỘT ẢNH',
'lib.viewGrid': 'TOÀN BỘ', 'lib.viewGrid': 'TOÀN BỘ',
// Cột thời gian bên phải bày ảnh (xem `Library.tsx`). // Cột thời gian bên phải bày ảnh (xem `TimelineRuler` trong `Library.tsx`).
'lib.rail': 'Dòng thời gian', 'lib.rail': 'Dòng thời gian',
'lib.prev': 'Ảnh trước', 'lib.prev': 'Ảnh trước',
'lib.next': 'Ảnh sau', 'lib.next': 'Ảnh sau',
+46 -56
View File
@@ -1249,6 +1249,9 @@ form[data-key='immich-modal'] {
It is given the width they would have taken, and it must not shrink, or the It is given the width they would have taken, and it must not shrink, or the
scrollbar would measure the window instead of the shelf. */ scrollbar would measure the window instead of the shelf. */
.adm-thumb-gap { flex: 0 0 auto; } .adm-thumb-gap { flex: 0 0 auto; }
/* The wall's stand-in for the rows it is not drawing: a run of cards' worth of
height, spanning the whole grid so nothing lands beside it. */
.adm-wall-gap { grid-column: 1 / -1; }
.adm-thumb { .adm-thumb {
flex: 0 0 132px; flex: 0 0 132px;
margin: 0; margin: 0;
@@ -1315,11 +1318,30 @@ form[data-key='immich-modal'] {
when the wall drew them flat. */ when the wall drew them flat. */
.adm-day { grid-column: 1 / -1; } .adm-day { grid-column: 1 / -1; }
.adm-day-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } .adm-day-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
/* The month's own band, over the first day it holds: a heavier line than a day's,
and the only line of the wall that is not a date — so a shelf is read by the
month it was shot and only then by the day. Its height is fixed (54px, the
`MONTH_BAND` the wall measures its shelf with): the wall stands in spacers for
the months it has not drawn, and a band that grew with its own text is a shelf
whose end moves. */
.adm-month-head {
height: 54px;
display: flex;
align-items: flex-end;
margin: 0;
padding-bottom: 8px;
font-size: 19px;
font-weight: 700;
letter-spacing: 0.01em;
color: var(--text);
}
/* A day's own line under it, at the height the wall counts it in (`DAY_BAND`). */
.adm-day-head { .adm-day-head {
height: 28px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin: 6px 0 2px; margin: 0;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
@@ -1481,61 +1503,29 @@ form[data-key='immich-modal'] {
fewer frames than there are columns is one row, and a stretched row makes every fewer frames than there are columns is one row, and a stretched row makes every
card's border that much taller than the picture inside it. Rows keep the height card's border that much taller than the picture inside it. Rows keep the height
their own frames give them however few of them there are. */ their own frames give them however few of them there are. */
.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto; padding-right: 6px; overflow-anchor: none; align-content: start; } .lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; overflow-anchor: none; align-content: start; }
/* The wall and the rail beside it: the wall is the shelf and takes the room, the /* The wall and the ruler beside it: the wall takes what is left, the ruler is
rail is the time down its right edge and takes what it needs. The wall's own the fixed strip down the right edge. The row is the stage's own middle track —
scrollbar is inside its box, so the rail stands outside the frames rather than it carries the height the grid used to carry alone, since the grid now shares
over them. */ that height with the ruler and no longer fills the stage by itself. */
.lib-wall { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; gap: 6px; } .lib-root .lib-wall-row { flex: 1 1 auto; min-height: 0; display: flex; gap: 4px; }
.lib-rail { .lib-root .lib-wall-row > .adm-grid { min-width: 0; }
position: relative; /* The shelf's time down its right edge, read the way a ruler is read: the year
flex: 0 0 auto; is the tick that is named, the months of it are the ticks under it, and the
width: 92px; band the eye is standing in carries the needle. */
overflow-y: auto; .lib-ruler { flex: 0 0 74px; display: flex; flex-direction: column; min-height: 0; padding-left: 6px; }
overscroll-behavior: contain; .lib-ruler-head { flex: 0 0 auto; display: flex; flex-direction: column; gap: 1px; padding-bottom: 6px; }
/* A shelf of years is longer than the wall is tall, so the rail scrolls under .lib-ruler-name { font-size: 9px; font-weight: 700; letter-spacing: 0.12em; color: var(--text-dim); }
the reader's own hand — a thin bar of its own rather than the platform's. */ .lib-ruler-value { font-size: 12px; font-weight: 700; color: var(--accent); white-space: nowrap; }
scrollbar-width: thin; .lib-ruler-scale { position: relative; flex: 1 1 auto; min-height: 0; cursor: ns-resize; touch-action: none; user-select: none; }
scrollbar-color: var(--border) transparent; /* A year's own field, the months of it standing inside it: what makes the ruler
padding: 2px 0; read by the year first and the month under it after. */
border-left: 1px solid var(--border); .lib-ruler-band { position: absolute; left: 0; right: 0; background: var(--bg-elev); border-radius: 3px; }
} .lib-ruler-tick { position: absolute; left: 0; width: 10px; height: 1px; background: var(--border); }
.lib-rail::-webkit-scrollbar { width: 8px; } .lib-ruler-year { width: 100%; height: 2px; background: var(--text-dim); }
.lib-rail::-webkit-scrollbar-track { background: transparent; } .lib-ruler-year i { position: absolute; top: 2px; left: 14px; font-size: 9px; font-style: normal; font-weight: 700; letter-spacing: 0.06em; color: var(--text-dim); }
.lib-rail::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } .lib-ruler-tick.on { width: 22px; background: var(--accent); }
.lib-rail::-webkit-scrollbar-thumb:hover { background: var(--text-dim); } .lib-ruler-needle { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); pointer-events: none; }
/* An entry of the rail: the year standing over its months, and a month said in the
dim voice until the eye is in it — in the accent, with the rule that carries the
eye, once it is. */
.lib-rail-item {
display: block;
width: 100%;
padding: 3px 8px;
border: 0;
border-left: 2px solid transparent;
background: none;
color: var(--text-dim);
font: inherit;
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.lib-rail-item:hover { color: var(--text); background: var(--bg-sunken); }
/* The year is a head and not a month of the shelf: it stands on its own line, above
the months it holds, and its own press takes the wall to the first of them. */
.lib-rail-year {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
color: var(--text);
font-weight: 700;
letter-spacing: 0.04em;
}
.lib-rail-year:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
.lib-rail-month { padding-left: 14px; }
.lib-rail-item.on { color: var(--accent); border-left-color: var(--accent); font-weight: 600; }
/* What the thumbnail wall is read through: the selects and the count of what is /* What the thumbnail wall is read through: the selects and the count of what is
left. One row above the wall, and it does not scroll with it. */ left. One row above the wall, and it does not scroll with it. */
.lib-root .adm-stage > .lib-filters, .lib-root .adm-stage > .lib-filters,
+196 -6
View File
@@ -21,10 +21,9 @@ export function dayLabel(timestamp: number, lang: Lang): string {
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
} }
// The month a day is filed under, and the way the rail down the wall's right edge // The month a frame belongs to, and the year above it: the same cut as `dayKey`,
// is cut. Built on `dayKey`'s own spelling — a month is the first seven characters // read one and two fields shorter, so the three levels of the timeline — year,
// of the day, a year its first four — so the rail's entries and the days it jumps // month, day — are never two answers to the same question.
// to are cut by one rule and not two.
export function monthKey(timestamp: number): string { export function monthKey(timestamp: number): string {
return dayKey(timestamp).slice(0, 7); return dayKey(timestamp).slice(0, 7);
} }
@@ -33,10 +32,18 @@ export function yearKey(timestamp: number): string {
return dayKey(timestamp).slice(0, 4); return dayKey(timestamp).slice(0, 4);
} }
// "thg 10" / "Oct" — the month alone, since the year stands over it. // "thg 10" / "Oct" — what the ruler writes beside a tick, one month wide.
export function monthLabel(timestamp: number, lang: Lang): string { export function monthLabel(timestamp: number, lang: Lang): string {
const d = new Date(timestamp); const d = new Date(timestamp);
return lang === 'vi' ? `thg ${d.getMonth() + 1}` : d.toLocaleDateString(undefined, { month: 'short' }); if (lang === 'vi') return `thg ${d.getMonth() + 1}`;
return d.toLocaleDateString(undefined, { month: 'short' });
}
// "Tháng 10, 2026" / "October 2026" — the band the wall is cut by.
export function monthTitle(timestamp: number, lang: Lang): string {
const d = new Date(timestamp);
if (lang === 'vi') return `Tháng ${d.getMonth() + 1}, ${d.getFullYear()}`;
return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
} }
export type DayRun<T> = { key: string; at: number; photos: T[] }; export type DayRun<T> = { key: string; at: number; photos: T[] };
@@ -56,3 +63,186 @@ export function dayRuns<T>(photos: T[], at: (photo: T) => number): DayRun<T>[] {
} }
return runs; return runs;
} }
// The rows a shelf of days stands in, which is not `count / cols`: a day is a
// band of its own above its frames — the band spans the grid (`grid-column:
// 1 / -1`) and the frames start on the row after it — so every day spends one
// whole row on its band, and a day whose frames do not come out even over the
// columns leaves the rest of its last row empty, since the next day's band
// starts a row of its own. Counted flat, the model stands for fewer rows than
// the DOM draws, and what stands in for the rows not drawn is a spacer over the
// frames below the fold that were never drawn: the reader scrolls to a screen of
// nothing and stops there.
//
// `bandRows` is that count, day by day: the row each day's band stands in, and
// the row after the last day — the shelf's own height in rows. Every row below a
// band is `cols` frames of that day, so a row is turned back into a frame
// through the band above it and the frames of the day before it.
export function bandRows(dayStarts: number[], count: number, cols: number): number[] {
const rows = new Array<number>(dayStarts.length + 1);
let row = 0;
for (let k = 0; k < dayStarts.length; k++) {
rows[k] = row;
const frames = (k + 1 < dayStarts.length ? dayStarts[k + 1] : count) - dayStarts[k];
row += 1 + Math.ceil(frames / cols);
}
rows[dayStarts.length] = row;
return rows;
}
// The steps a shelf is stacked in, and what the grid leaves between them: a day
// opens with a band of `head` when it is the first day of its month, then a band
// of `band`, then rows of frames of `row`, and `gap` stands under each of them.
// They are not one step. A step taken as their average — read off the rows the
// window happens to be drawing — is a step that is wrong for all of them, and a
// shelf whose days do not come out even over the columns lays them in a different
// proportion from one screen to the next. What that costs is the end of the
// shelf: the spacers standing in for the rows not drawn add up to a height that
// moves with the window, the scrollbar ends above the frames that were never
// drawn, and the screen under the last card drawn is a screen of nothing.
//
// The bands are heights the style sheet fixes (`MONTH_BAND` / `DAY_BAND` in
// Library.tsx are the same two numbers), so the shelf's own height is known
// before a card is drawn: only `row` — a card and the grid's gap — is measured,
// and only once per column count.
export type WallSteps = { head: number; band: number; row: number; gap: number };
// The shelf measured in those steps: the row every day's band stands in
// (`order`, the same rows `rowPhoto` reads), the top and the bottom of every
// day's own box, and the height of the whole shelf, which is the bottom of the
// last day's box. `heads[k]` is 1 on a day that opens a month, which is what the
// wall draws above it.
export type WallLayout = {
steps: WallSteps;
cols: number;
rows: number;
order: number[];
heads: number[];
tops: number[];
ends: number[];
height: number;
};
export function wallLayout(
dayStarts: number[],
heads: 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];
// The last row of a day leaves no gap under it: the grid inside the day ends
// with the card, and the gap between two days is the one the wall's own grid
// draws between their boxes (counted once, past the block).
const height = (heads[k] ? steps.head : 0) + steps.band + Math.ceil(frames / cols) * steps.row - steps.gap;
tops.push(y);
ends.push(y + height);
y += height + steps.gap;
}
return {
steps,
cols,
rows: order[dayStarts.length],
order,
heads,
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 — the
// month's own band above it on the first day of a month — and the frame rows
// under it start where the bands' own height ends.
export function rowTop(layout: WallLayout, row: number): number {
if (!layout.tops.length) return 0;
const k = dayOfRow(layout.order, row);
const bands = (layout.heads[k] ? layout.steps.head : 0) + layout.steps.band;
return row <= layout.order[k]
? layout.tops[k]
: layout.tops[k] + bands + (row - layout.order[k] - 1) * layout.steps.row;
}
// And the other way: the row a scroll position falls in, so the window the wall
// draws is the window the reader is standing in.
export function rowAt(layout: WallLayout, y: number): number {
if (!layout.tops.length || y <= 0) return 0;
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 bands = (layout.heads[lo] ? layout.steps.head : 0) + layout.steps.band;
const rel = y - layout.tops[lo];
if (rel < bands) return layout.order[lo];
const under = Math.floor((rel - bands) / layout.steps.row) + 1;
return Math.min(layout.order[lo + 1] - 1, layout.order[lo] + under);
}
// The day a frame is filed under, on the same cut the days were begun at.
export function dayAt(dayStarts: number[], index: number): number {
if (!dayStarts.length) return 0;
let lo = 0;
let hi = dayStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (dayStarts[mid] <= index) lo = mid;
else hi = mid - 1;
}
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);
}