From 822833c1efb323a6103ce22f79c6060898068960 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 13 Oct 2026 10:37:57 +0700 Subject: [PATCH] feat(library): cut the wall by month and day, and stand a ruler timeline on its right edge --- docker/frontend/scripts/wall-check.mjs | 470 ++++++------- docker/frontend/src/Library.tsx | 892 +++++++++++++++++-------- docker/frontend/src/i18n/en.ts | 2 +- docker/frontend/src/i18n/vi.ts | 2 +- docker/frontend/src/styles/app.css | 102 ++- docker/frontend/src/ui/dateGroups.ts | 202 +++++- 6 files changed, 1044 insertions(+), 626 deletions(-) diff --git a/docker/frontend/scripts/wall-check.mjs b/docker/frontend/scripts/wall-check.mjs index 6a2f49a..4169ead 100644 --- a/docker/frontend/scripts/wall-check.mjs +++ b/docker/frontend/scripts/wall-check.mjs @@ -1,23 +1,17 @@ // 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 -// node holds is a card (the wall is drawn whole — no window over it, so no card -// arriving late and no spacer standing in for one), every card sits under the band -// of its own day, the last frame is reachable by scrolling with nothing left under -// it, and the rail down the wall's right edge — the shelf's own time, by the year -// and the month and nothing finer — scrolls under the reader's hand, reads the month -// the eye is in and takes the wall to the month or the year it is pressed for. +// One failure is what this guards, and it is the one that has already cost a +// white screen: a window written from the window before it. The wall measures +// itself after every render, writes what it measured, and renders again — so an +// input the measure takes off the window it just wrote feeds itself, pass after +// pass, until React gives up with #185, unmounts the tree, and leaves the reader +// with nothing (there is no error boundary under it). // -// The wall is also the one screen that reads the whole shelf, so what it mints is -// watched here too: a roll of years is a rail of months, and a rail whose every entry -// is in the box it is read through. -// -// 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. +// The trigger is not spelled out — no browser reproduces the reader's own, +// which is the point — so the check drives the *class*: the wall's own box +// 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. // // npm run build && node scripts/wall-check.mjs // PLAYWRIGHT_CORE= 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 FRAMES = Number(process.env.FRAMES ?? 300); 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 // 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, @@ -49,11 +37,9 @@ const SMALL_DAYS = 2; // half of what this check is for. const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`); const DAY_MS = 86400000; -// The roll is spread over years, a shooting day every forty days or so: a shelf of -// months is what the rail is for, and a rail that holds three of them is a rail that -// never has to be scrolled — the years are what make it longer than the wall is tall. -const DAY_SPREAD = 40; -const DAY0 = Date.UTC(2018, 0, 1); +// Midday, so the day a frame is filed under on the reader's own clock is the day +// the check counts, whatever the box is set to. +const DAY0 = Date.UTC(2023, 0, 12, 12); // The days the roll was shot on, and they are not equal: a roll is an afternoon // of three frames and a wedding of four hundred, and the wall's own model of its // rows is the thing that has to survive that. A shelf of equal days is a shelf a @@ -82,45 +68,24 @@ const dayIndex = (n) => { } return 0; }; -const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_SPREAD * DAY_MS; - -// The second roll's own names and days: `one-0001` onwards, twenty to a day, over -// the fourth and fifth of March 2026 — one month, two days, which is the shelf the -// rail reads by the day. -const smallNames = Array.from( - { length: SMALL_DAYS * SMALL_EACH }, - (_, i) => `one-${String(i + 1).padStart(4, '0')}.jpg` -); -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. +// 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 +// a roll shot on consecutive days is a roll of one month and one tick. Eleven +// days between one shooting day and the next leaves it two years long with whole +// months nothing was shot in standing between them — and a month with no frame is +// a month with no tick, which is the ruler's own claim. +const DAY_STEP = 11; +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 monthOf = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; }; -const keyOf = (ts) => { - const d = new Date(ts); - return `${monthOf(ts)}-${String(d.getDate()).padStart(2, '0')}`; -}; -const yearOf = (ts) => String(new Date(ts).getFullYear()); -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; -})(); +const rollDays = names.map(dayOfName); +const rollYears = [...new Set(rollDays.map(yearOf))]; +// 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 rollMonths = [...new Set(rollDays.map(monthOf))]; let failures = 0; function step(name, ok, detail = '') { @@ -139,7 +104,7 @@ if (!playwright) { const jpg = await readFile(SAMPLE_FILE); const sampleServer = createServer((req, res) => { const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); - if (!servedNames.has(name)) { + if (!names.includes(name)) { res.writeHead(404).end(); 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 // plain object with no `getFile`. 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 // script is cloned into the page and cannot close over the check's own scope. - const dayOfNameIn = (name) => { - const at = names.indexOf(name); - return at >= 0 ? days[at] : smallDays[smallNames.indexOf(name)] ?? Date.UTC(2026, 0, 1); - }; + const dayOfNameIn = (name) => days[names.indexOf(name)] ?? Date.UTC(2026, 0, 1); const fileHandle = (name) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { @@ -247,21 +209,15 @@ await context.addInitScript( }); return handle; }; - // The folder the reader picks: the first press is the shelf this check walks, - // the second is the shelf of a single month (the rail's other reading). A - // 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); + const root = () => dirHandle(folder, { files: names }); + window.showDirectoryPicker = async () => root(); // 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. window.__dayOf = (name) => dayOfNameIn(name); const revive = (row) => { 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; }; @@ -281,15 +237,7 @@ await context.addInitScript( }; } }, - { - origin: `http://127.0.0.1:${SAMPLE_PORT}`, - names, - folder: FOLDER, - days: names.map(dayOfName), - smallNames, - smallFolder: SMALL_FOLDER, - smallDays: smallNames.map(smallDayOfName), - } + { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER, days: names.map(dayOfName) } ); const page = await context.newPage(); @@ -346,69 +294,31 @@ const dayAudit = () => return out; }); -// --- the wall, drawn whole --------------------------------------------------- await page.click('[data-key="lib-view-grid"]'); 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 -// 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.waitForFunction(() => document.querySelectorAll('.adm-card').length > 0, null, { timeout: 30_000 }); await page.waitForTimeout(2000); const wall = await read(); -step('the wall draws every frame of the roll', wall.cards === FRAMES, `${wall.cards} of ${FRAMES} cards drawn`); -step('and it is a shelf that scrolls', wall.wall, 'the wall scrolls'); +step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < FRAMES, `${wall.cards} of ${FRAMES} cards drawn`); +step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls'); 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))); -// What the rail is read for: the entries it holds, the entry the eye is in, and where -// a press on one takes the wall. The day at the fold is read off the sections -// themselves — the first one crossing the wall's top band, which is the rule the rail -// itself reads by — and the month the wall says it is in is read off the accent. -const railOf = () => - page.evaluate(() => { - 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'); +// The same wall, scrolled to where no day starts at the fold: the headers have to +// move with it, which is the whole risk of cutting a virtualised wall into days. +// Read down the shelf rather than at one stop: a stop of any one fraction lands +// inside a day big enough to fill the screen as often as it lands on a fold, so +// what is read is that no band ever files a card under another day's header, and +// that the shelf's own cuts move through the window as it travels. const stops = [0.15, 0.35, 0.55, 0.75, 0.95]; -const seenMonths = new Set(); +const seenDays = new Set(); let scrolledBad = []; -let railBad = []; for (const at of stops) { await page.evaluate((f) => { const el = document.querySelector('.adm-grid'); @@ -417,98 +327,136 @@ for (const at of stops) { }, at); await page.waitForTimeout(900); const audit = await dayAudit(); + for (const section of await page.$$eval('.adm-day', (els) => els.map((e) => e.getAttribute('data-key')))) seenDays.add(section); scrolledBad = scrolledBad.concat(audit.bad); - const 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( 'a scrolled wall still files every card under its own day', - scrolledBad.length === 0, - 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` + scrolledBad.length === 0 && seenDays.size > 1, + `${seenDays.size} days through the window, ${JSON.stringify(scrolledBad.slice(0, 2))}` ); -// And the press: the shelf's own first day of the month is where the wall has to -// stand, its header just under the wall's top edge, the entry pressed is the month -// read afterwards, and the rail brings that entry into its own box. -const press = async (key) => { - await page.click(`[data-key="lib-rail-${key}"]`); - await page.waitForTimeout(700); - return page.evaluate((k) => { - const grid = document.querySelector('.adm-grid'); - const rail = document.querySelector('.lib-rail'); - const button = document.querySelector(`[data-key="lib-rail-${k}"]`); - const at = button?.getAttribute('data-at') ?? null; - const section = at ? grid.querySelector(`[data-key="lib-day-${at}"]`) : null; - const on = document.querySelector('.lib-rail-item.on'); - const y = button ? button.offsetTop - rail.scrollTop : null; +// Back to the head of the roll, where the month's own band is drawn. +await page.evaluate(() => { + const el = document.querySelector('.adm-grid'); + el.scrollTop = 0; + el.dispatchEvent(new Event('scroll')); +}); +await page.waitForTimeout(1200); + +// The two bands, at the heights the wall counts its shelf in. The month's and the +// day's band are both fixed numbers in the style sheet and the shelf's whole +// length — and with it every spacer standing in for the rows not drawn — is +// counted from them, so a band that grew with its own text is a shelf whose end +// moves. +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 { - at, - top: section ? Math.round(section.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null, - cards: section ? section.querySelectorAll('.adm-card').length : 0, - on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null, - onKind: on?.getAttribute('data-kind') ?? null, - seen: y !== null && y >= -2 && y + (button?.offsetHeight ?? 0) <= rail.clientHeight + 2, + on: months.findIndex((t) => t.classList.contains('on')), + count: months.length, + needle: needle ? parseFloat(needle.style.top) || 0 : 0, + tick: months.find((t) => t.classList.contains('on')) ? parseFloat(months.find((t) => t.classList.contains('on')).style.top) || 0 : 0, }; - }, key); -}; -const jumped = months1[Math.floor(months1.length / 2)]; -const landed = await press(jumped); -step( - 'a press on a month stands the wall on that month, and the rail on its entry', - landed.top !== null && - Math.abs(landed.top - 6) <= 6 && - landed.on === jumped && - landed.onKind === 'month' && - landed.seen, - `header at ${landed.top} of ${landed.at}, entry ${landed.on}, in rail ${landed.seen}` -); + }); +const beforeNotch = await notch(); +await page.evaluate(() => { + const el = document.querySelector('[data-key="lib-ruler"]'); + el.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true })); +}); +await page.waitForTimeout(1500); +const afterNotch = await notch(); +step('a notch of the wheel is one month', afterNotch.on === beforeNotch.on + 1, `${beforeNotch.on} -> ${afterNotch.on} of ${beforeNotch.count}`); +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}%`); -// The year is a head and not a month of the shelf: a press on it stands the wall on -// the first month the shelf holds of that year, and the eye then reads that month. -const yearRow = yearsOf(rail1)[1]; -const overYear = await press(yearRow); -step( - 'a press on a year stands the wall on the first month it holds', - overYear.top !== null && Math.abs(overYear.top - 6) <= 6 && overYear.at?.slice(0, 4) === yearRow && overYear.on === overYear.at?.slice(0, 7), - `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. +// The shelf walked to its own end, a screen at a time — a jump straight to the +// bottom is a different question, and the one this check exists for: the wall +// stands in for the frames it is not drawing, and a spacer standing in for more +// rows than those frames take leaves the reader at the end of the scroll with a +// screen of nothing under the last card drawn and the rest of the roll never +// drawn at all. What the walk is read for is that it *gets* there, standing on +// the last frame, and that every frame of the roll was drawn on the way. const walked = await page.evaluate(async () => { const el = document.querySelector('.adm-grid'); const seen = new Set(); @@ -533,75 +481,35 @@ const walked = await page.evaluate(async () => { top: Math.round(el.scrollTop), reached: el.scrollTop + el.clientHeight >= el.scrollHeight - 2, room: Math.round(el.scrollHeight - el.clientHeight), - // What is left under the last card drawn. 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, + seen: [...seen], }; }); step('the shelf is walked to its own end', walked.reached, `stopped at ${walked.top} of ${walked.room}`); step('and stands on nothing there', walked.gap !== null && walked.gap <= 16, `${walked.gap}px of nothing under the last card`); +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 days around the eye, and a shelf walked end to end is not a shelf of tiles held -// end to end. A frame the wall holds no tile for draws nothing at all — a folder off -// the disk is a file only this page can open — so the count of blob-backed images is -// the count of tiles the wall is holding. -const held = await page.evaluate(() => ({ - cards: document.querySelectorAll('.adm-grid .adm-card').length, - tiles: document.querySelectorAll('.adm-grid img[src^="blob:"]').length, -})); -step( - '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); +// The class of trigger, made to happen: one input the wall reads back off itself, +// moving on every pass. +await page.evaluate(() => { + const el = document.querySelector('.adm-grid'); + const h = el.clientHeight; + let n = 0; + Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => (n++ % 2 ? h : h + 300) }); + el.dispatchEvent(new Event('scroll')); +}); +await page.waitForTimeout(8000); const after = await read(); const looped = errors.filter((e) => e.includes('185') || e.includes('update depth')); step( - 'a wall resized under its own eye does not take the screen down', - !after.blank && after.cards === FRAMES && looped.length === 0, + 'a wall fed its own window does not take the screen down', + !after.blank && after.cards > 0 && looped.length === 0, `blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}` ); -const rail2 = await railOf(); -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}` -); +step('the wall is still the wall', after.cards > 0, `${after.cards} cards after the feed`); await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {}); if (errors.length) console.log('page errors:', errors.slice(0, 6).join(' // ')); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 54859a9..7b6bec5 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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 { go } from './nav'; 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 // matter: what is drawn is this many, whatever the shelf holds. 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. // 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 // the reader — a screenful of frames read end to end is a dead screen. const WALL_LANES = 2; // 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 -// frame of the roll is a decode of every frame of the roll — half an hour of disk -// for a folder nobody has scrolled past the first screen of. +// asked for when a folder opens are the ones about to be drawn, and the wall mints +// the rest as it scrolls. A job over every frame of the roll is a decode of every +// 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 // 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 -// 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; -// What the wall mints before it has been looked at: the head of the shelf, which is -// the order it drew the days in. A first paint with no tiles at all is a rack of -// empty cards, and the first look lands a frame later. -const THUMB_HEAD = 120; +// What `.adm-grid` leaves between its rows. A spacer has to stand the same +// distance past the last row it replaces as the row itself would have. +const WALL_GAP = 12; +// The two bands the wall opens a run of frames with: the month's own line, then +// the day's. Both are heights the style sheet fixes, and these are those two +// numbers (`.adm-month-head` / `.adm-day-head` in styles/app.css): the shelf's +// whole height is built out of them before a single card is drawn, which is what +// lets the spacer stand in for a month nobody has scrolled to. +const MONTH_BAND = 54; +const DAY_BAND = 28; +// How many windows the wall may write in a row before it is talking to itself. +// A window read off the window before it is a loop, and React answers the fifty +// turns of one with #185 and a blank screen; four is more than an honest change +// needs — a re-column takes two passes — and the count is handed back by the +// first scroll, resize or change of shelf the reader makes. +const WALL_CARRIES = 4; // The hours a frame's own clock can read, and what the time filter offers: the // whole day in the 24-hour form a camera writes into EXIF. const HOURS = Array.from({ length: 24 }, (_, i) => i); @@ -349,14 +367,7 @@ export function Library() { } import { useIsMobile } from './ui/useIsMobile'; -import { dayLabel, dayRuns, monthKey, monthLabel, yearKey } 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 }; +import { bandRows, dayAt, dayKey, dayLabel, dayRuns, monthKey, monthLabel, monthTitle, photoRow, rowAt, rowPhoto, rowTop, wallLayout, yearKey, type WallSteps } from './ui/dateGroups'; function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { 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 strip is — the rows around the eye, with a spacer standing in for the rest - // — and what the reader sees in that is a shelf tearing itself open: the frames - // below the fold are not in the DOM to be scrolled to, so a day is drawn a couple - // of rows deep and grows as the eye walks down it, a frame scrolled back up to is - // a frame the wall has to build again, and the shelf's own end is a spacer that - // has to be right to the pixel or the scroll stops in a screen of nothing. - // - // What makes it safe to draw whole is that the wall now holds no state: no - // measure, no spacer, no model of where a row stands — the browser lays the grid - // out once and scrolls it, and there is no second answer to be at odds with the - // shelf drawn. `loading="lazy"` on the tiles (see `TileImg`) is what keeps the - // frames below the fold from being read before the eye is near them, and the - // tiles behind them are minted off the whole list, a couple of lanes at a time - // (`useThumbUrls`), so a day scrolled to has its pictures rather than a rack of - // empty cards. - // - // ponytail: the DOM is the whole shelf — twenty thousand frames is twenty - // thousand cards built up front, which is seconds of dead screen. When a roll - // 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 - // (`content-visibility` and the frames under it), which is what a window of rows - // could not be. - // - // The wall is read by the day a frame was shot, and the line above each run is - // that day. Cut off the list as it stands rather than sorted into days of its - // own, so the sort chip keeps the order it names. - const days = useMemo(() => dayRuns(listed, (photo) => photo.taken || photo.addedAt), [listed]); - // The wall's own box, and the rail is read off it: the scroll that reaches every - // frame is the grid's, since the grid is the whole shelf. - const gridRef = useRef(null); - - // The rail down the wall's right edge: the shelf's own time, a year and the - // months under it — and nothing finer, since the day a frame belongs to is what - // the wall's bands already say. It is cut off the same runs the wall is drawn in, - // so an entry and the header it jumps to are one cut of the shelf, not two. - const rail = useMemo(() => { - const all: RailMark[] = []; + // The wall is the other list, and it draws its rows the way the strip draws its + // tiles: the ones around the eye, with a spacer standing in for the rest. A card + // is a card, an `` and an object URL of its own, and twenty thousand of them + // is seven seconds of dead screen — the URL is only a tenth of it; the rest is + // the DOM and the number the browser formats for each date. The frame on the + // stage is given a picture wherever it sits — the stage is not the wall. + // Where each day of the shelf begins, cut by the same rule the bands are drawn + // by (`dayRuns`): the wall stands in rows for the days it is not drawing, so it + // is the shelf's own days it counts and not the window's. `dayHeads` is the + // month above it: a day the shelf's own month changes at opens with one, and + // the rest do not. + const { dayStarts, dayHeads } = useMemo(() => { + const starts: number[] = []; + const heads: number[] = []; + const at = (photo: (typeof listed)[number]) => photo.taken || photo.addedAt; + for (let i = 0; i < listed.length; i++) { + if (i && dayKey(at(listed[i])) === dayKey(at(listed[i - 1]))) continue; + starts.push(i); + heads.push(i && monthKey(at(listed[i])) === monthKey(at(listed[i - 1])) ? 0 : 1); + } + return { dayStarts: starts, dayHeads: heads }; + }, [listed]); + const wall = useWallWindow(listed.length, dayStarts, dayHeads, view === 'grid'); + // Memoised like `drawn`, and for a reason of its own: `useThumbUrls` keys its + // effect on the array it is handed, so a fresh slice every render made that + // effect run every render — a `setUrls` per pass, a render per `setUrls`, and + // thousands of passes a second in which the tiles of the rows scrolled to never + // land at all. + const painted = useMemo( + () => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)), + [listed, wall.from, wall.to] + ); + // The same slice, cut into the days it holds: a wall is read by the day a frame + // was shot, and the line above each run is that day. Cut off the slice as it + // stands rather than sorted into days of its own, so the sort chip keeps the + // order it names; the first run may be a day that started above the window, + // which is its own header drawn where the eye lands (see `dayRuns`). A run the + // month changes at also carries the month's band, read off the shelf's own days + // (`dayHeads`) rather than off the slice, which begins wherever the window does. + const paintedBlocks = useMemo(() => { + const at = (photo: (typeof listed)[number]) => photo.taken || photo.addedAt; + let i = Math.min(wall.from, listed.length); + return dayRuns(painted, at).map((day) => { + const head = dayHeads[dayAt(dayStarts, i)] === 1; + i += day.photos.length; + return { day, head }; + }); + }, [painted, listed, wall.from, dayStarts, dayHeads]); + // 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 month = ''; - for (const day of days) { - const key = monthKey(day.at); - const yr = yearKey(day.at); - // The year stands over its months, and the first day the shelf holds of it - // is where a press on the year lands. - if (yr !== year) { - year = yr; + for (const photo of listed) { + const time = photo.taken || photo.addedAt; + const y = yearKey(time); + if (y !== year) { + year = y; 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) { - month = key; - const label = monthLabel(day.at, lang); - all.push({ kind: 'month', key, at: day.key, label, title: `${label} ${year}` }); + const m = monthKey(time); + if (m !== month) { + month = m; + marks.push({ kind: 'month', key: m, at: time, label: monthLabel(time, lang) }); } } - return all; - }, [days, lang]); - const [railAt, setRailAt] = useState(0); - const railRef = useRef(null); - - // Which entry the eye is on. The days are watched from the wall's own box and - // 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. + return marks; + }, [listed, lang]); + // The month the needle stands in: the month of the day band the eye is under, + // written by the observer below. `''` before the wall has said anything, which + // leaves the needle at the top of the ruler. + const [eyeMonth, setEyeMonth] = useState(''); useEffect(() => { - if (view !== 'grid') return; - const el = gridRef.current; - if (!el) return; - const nodes = [...el.querySelectorAll('.adm-day')]; - if (!nodes.length) return; - const place = new Map(nodes.map((node, i) => [node.dataset.key ?? '', i])); - const seen = new Set(); + const el = wall.ref.current; + if (view !== 'grid' || !el || !timeMarks.length) return; + // The band the eye is under, and not the first one in the DOM: the window + // draws a couple of rows above the fold as well, and a needle that read the + // first band drawn would stand two rows early. `-72%` off the bottom leaves + // the top three tenths of the wall as the band of the eye. const io = new IntersectionObserver( (entries) => { for (const entry of entries) { - const key = (entry.target as HTMLElement).dataset.key ?? ''; - if (entry.isIntersecting) seen.add(key); - else seen.delete(key); + if (!entry.isIntersecting) continue; + const month = (entry.target as HTMLElement).dataset.month; + 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('.adm-day')) io.observe(section); return () => io.disconnect(); - }, [rail, days, view]); - - // The rail's own scroll: the entry being read is kept by the middle of the - // column, so a shelf of years is not a rail standing on its own top. - useEffect(() => { - const el = railRef.current; - const item = el?.children[railAt] as HTMLElement | undefined; - if (!el || !item) return; - const want = Math.max(0, item.offsetTop - el.clientHeight / 2 + item.offsetHeight / 2); - if (Math.abs(el.scrollTop - want) > 8) el.scrollTop = want; - }, [railAt]); - - // A press on an entry takes the wall to the day it stands for: the header is at - // a place the grid already knows, and the scroll is put where it stands — a jump - // and not a step, and the highlight follows the eye from there like any other - // scroll. - const jumpToTime = (key: string) => { - const el = gridRef.current; - const day = el?.querySelector(`[data-key="lib-day-${CSS.escape(key)}"]`); - if (!el || !day) return; - const box = el.getBoundingClientRect(); - el.scrollTop = Math.max(0, el.scrollTop + day.getBoundingClientRect().top - box.top - 6); - }; - + }, [view, wall.ref, wall.from, wall.to, timeMarks.length]); + const eyeAt = useMemo(() => { + const mark = timeMarks.find((m) => m.kind === 'month' && m.key === eyeMonth); + if (mark) return mark.at; + const first = listed[0]; + return first ? first.taken || first.addedAt : 0; + }, [timeMarks, eyeMonth, listed]); + // Going where the ruler is pressed: the first day of that month is brought to + // the top of the wall. The day's own top comes off the shelf's layout rather + // than off its box — the day is not in the DOM, which is the whole reason the + // layout exists (see `wallLayout`) — so the jump lands on a month the wall has + // never drawn. + const jumpToMonth = useCallback( + (at: number) => { + const el = wall.ref.current; + if (!el || !listed.length) return; + const key = monthKey(at); + const k = dayStarts.findIndex((start) => monthKey(listed[start].taken || listed[start].addedAt) === key); + if (k < 0) return; + const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length); + const layout = wallLayout(dayStarts, dayHeads, listed.length, cols, wall.steps.current); + if (!(layout.tops[k] >= 0)) return; + el.scrollTop = Math.max(0, layout.tops[k] - 6); + }, + [listed, dayStarts, dayHeads, wall.ref, wall.steps] + ); // 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 // 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 - // 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>(() => 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); + const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame, tilesAt); // Auto-scroll & focus tree sidebar to the active node selected in last session const treeRef = useRef(null); @@ -2256,16 +2221,20 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { }, [view, frame?.id, listed, node, strip.ref]); // And the wall's half of it: the frame the strip was on, brought to the middle - // of the wall. The card is in the DOM — the wall draws the whole shelf — so this - // is one scroll of the grid and no model of where a row stands. The loop is for - // the pass the list has not landed on yet, when there is no card to measure and - // what has to run this again is the list itself, not a frame of waiting. + // of the wall. The card is not in the DOM to begin with — the wall only paints + // the rows around its viewport — so the first passes go to where the row should + // be and the ones after that read the card itself, which is the only honest + // measure: a row stands as tall as its longest caption. The loop is bounded + // rather than driven by state, because the wall reports its window by + // re-rendering and a scroll set inside a render is a scroll the next render + // undoes. useEffect(() => { if (view !== 'grid') return; const id = centredRef.current; - const el = gridRef.current; + const el = wall.ref.current; 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; return; } @@ -2278,8 +2247,29 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { const box = el.getBoundingClientRect(); const own = card.getBoundingClientRect(); el.scrollTop = Math.max(0, el.scrollTop + own.top - box.top + own.height / 2 - el.clientHeight / 2); - centredRef.current = null; - return; + } else { + // No card drawn: the wall opens on the first screenful, so aim at the row + // the frame stands in and let the next pass find it. The shelf's own steps + // and the rows its days are counted in are the wall's, read off the same + // layout its window is measured against — a step of this effect's own is a + // second answer to a question the wall has already answered. + const first = el.querySelector('.adm-card'); + const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length); + const steps = wall.steps.current; + if (steps.row > 0) { + const layout = wallLayout(dayStarts, dayHeads, listed.length, cols, steps); + const row = photoRow(dayStarts, cols, layout.order, idx); + el.scrollTop = Math.max(0, Math.round(rowTop(layout, row) - (el.clientHeight - steps.row) / 2)); + } else { + // A step guessed at, for the one pass before the wall has drawn a card + // to measure: the row the frame stands in is counted over the shelf's own + // bands, not over its own index — every day spends a row above its first + // frame. + const pitch = first ? first.offsetHeight + WALL_GAP : 240; + const order = bandRows(dayStarts, listed.length, cols); + const row = photoRow(dayStarts, cols, order, idx); + el.scrollTop = Math.max(0, Math.round(row * pitch - (el.clientHeight - pitch) / 2)); + } } if (--left > 0) requestAnimationFrame(settle); 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 // moves when the shelf comes up on another frame, and a wall standing where it // already was is a wall that would not follow it. - }, [view, listed, up]); + }, [view, listed, dayStarts, wall.ref, up]); // This screen's own comings and goings: it never unmounts, so `here` is the // only sign of the shelf being the screen up again (see `here` above). @@ -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 // frame this shelf can raise. const openFromWall = () => { - const el = gridRef.current; + const el = wall.ref.current; const picked = lastSelectedId && selectedIds.has(lastSelectedId) && listed.some((p) => p.id === lastSelectedId) ? lastSelectedId @@ -3251,104 +3241,110 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { {view === 'grid' ? ( <> - {/* The wall is the shelf itself — every day of it, drawn — so - its scrollbar on the right edge reaches every frame and a - day scrolled to is a day that was already there. The rail - beside it is the shelf's time: the month the eye is in, - and a press on one takes the wall to it. */} -
-
- {days.map((day) => ( -
- {/* The day above its frames, the way the phone's own grid - reads a shelf: a card still says its date, and the band - is what makes a roll scannable by the month it was shot. */} -

- {dayLabel(day.at, lang)} -

-
- {day.photos.map((photo) => ( -
handleSelectPhoto(photo, e)} - onDoubleClick={() => { - setUp(photo.id); - scrolledFrameRef.current = null; - setView('preview'); - }} - onContextMenu={(e) => handlePhotoContextMenu(photo, e)} + {/* The wall is the shelf's own length, so the scrollbar on + its right edge still reaches every frame; what is drawn + is the rows around the eye, with a spacer standing in + for the rows above and below. A card is a card, an + and an object URL of its own, and twenty thousand + of them is a dead screen. + The wall is read the way the phone's own grid reads a + shelf and the way a cloud library does: the months down + it, and inside a month the days, one under the other — + the band of a day over the frames of that day, so a roll + is scanned by the month it was shot. The rows the eye + is not in stand as two spacers, which is what the ruler + beside the wall is pressed against. */} +
+
+ {wall.above > 0 ?
- {/* 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. */} - + {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+
+ {photo.name} + + {photo.star ? `★${photo.star} · ` : ''} + {new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })} + {edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''} + +
+
+ ))} +
+
+ ))} + {wall.below > 0 ?
+ {/* The shelf's own time, down its right edge: a year is the + tick that is named, the months of it are the ticks under + it, and the band the eye is in is the needle. */} + jumpToMonth(mark.at)} + />
) : 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 - // whose tiles are let go here, so what a long shelf holds is the screen around - // the eye rather than every frame it has ever drawn. - setUrls(syncUrls); + if (Object.keys(syncUrls).length > 0) { + setUrls((prev) => ({ ...prev, ...syncUrls })); + } if (idsToFetch.length === 0) return; @@ -4225,3 +4220,338 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject` and an object URL +// of its own; twenty thousand of them is a dead screen for as long as they take to +// build. `on` is the wall being up, since the element measured is its own. +function useWallWindow( + count: number, + dayStarts: number[], + heads: number[], + on: boolean +): { + ref: RefObject; + from: number; + to: number; + above: number; + below: number; + // The steps the shelf is stacked in, which the wall reads off the cards it has + // drawn and keeps. Handed back as the ref itself, not its value: the settle + // effect aims a scroll with them on the pass after the one that measured them. + steps: { current: WallSteps }; +} { + const ref = useRef(null); + // The first window is a screenful: the wall has not been measured yet. + const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, 24), above: 0, below: 0 })); + // 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({ 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('.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('.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(null); + const rootRef = useRef(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 ( +
e.stopPropagation()}> +
+ {t('lib.rail')} + + {readout} + +
+
{ + 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 : ( +
+
+ ); +} + +// 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; +} diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index cfa494a..3176faa 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -278,7 +278,7 @@ export const en: Dict = { 'lib.colWidth': 'Drag to set the width of the folder column (arrow keys when focused)', 'lib.viewPreview': 'SINGLE', '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.prev': 'Previous frame', 'lib.next': 'Next frame', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 28e60ec..ae9117c 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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.viewPreview': 'MỘT ẢNH', '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.prev': 'Ảnh trước', 'lib.next': 'Ảnh sau', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 813d3a9..6b90bc4 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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 scrollbar would measure the window instead of the shelf. */ .adm-thumb-gap { flex: 0 0 auto; } +/* The wall's stand-in for the rows it is not drawing: a run of cards' worth of + height, spanning the whole grid so nothing lands beside it. */ +.adm-wall-gap { grid-column: 1 / -1; } .adm-thumb { flex: 0 0 132px; margin: 0; @@ -1315,11 +1318,30 @@ form[data-key='immich-modal'] { when the wall drew them flat. */ .adm-day { grid-column: 1 / -1; } .adm-day-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +/* The month's own band, over the first day it holds: a heavier line than a day's, + and the only line of the wall that is not a date — so a shelf is read by the + month it was shot and only then by the day. Its height is fixed (54px, the + `MONTH_BAND` the wall measures its shelf with): the wall stands in spacers for + the months it has not drawn, and a band that grew with its own text is a shelf + whose end moves. */ +.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 { + height: 28px; display: flex; align-items: center; gap: 10px; - margin: 6px 0 2px; + margin: 0; font-size: 12px; font-weight: 600; 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 card's border that much taller than the picture inside it. Rows keep the height their own frames give them however few of them there are. */ -.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; min-width: 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 - rail is the time down its right edge and takes what it needs. The wall's own - scrollbar is inside its box, so the rail stands outside the frames rather than - over them. */ -.lib-wall { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; gap: 6px; } -.lib-rail { - position: relative; - flex: 0 0 auto; - width: 92px; - overflow-y: auto; - overscroll-behavior: contain; - /* A shelf of years is longer than the wall is tall, so the rail scrolls under - the reader's own hand — a thin bar of its own rather than the platform's. */ - scrollbar-width: thin; - scrollbar-color: var(--border) transparent; - padding: 2px 0; - border-left: 1px solid var(--border); -} -.lib-rail::-webkit-scrollbar { width: 8px; } -.lib-rail::-webkit-scrollbar-track { background: transparent; } -.lib-rail::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } -.lib-rail::-webkit-scrollbar-thumb:hover { background: var(--text-dim); } -/* 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; } +.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 ruler beside it: the wall takes what is left, the ruler is + the fixed strip down the right edge. The row is the stage's own middle track — + it carries the height the grid used to carry alone, since the grid now shares + that height with the ruler and no longer fills the stage by itself. */ +.lib-root .lib-wall-row { flex: 1 1 auto; min-height: 0; display: flex; gap: 4px; } +.lib-root .lib-wall-row > .adm-grid { min-width: 0; } +/* The shelf's time down its right edge, read the way a ruler is read: the year + is the tick that is named, the months of it are the ticks under it, and the + band the eye is standing in carries the needle. */ +.lib-ruler { flex: 0 0 74px; display: flex; flex-direction: column; min-height: 0; padding-left: 6px; } +.lib-ruler-head { flex: 0 0 auto; display: flex; flex-direction: column; gap: 1px; padding-bottom: 6px; } +.lib-ruler-name { font-size: 9px; font-weight: 700; letter-spacing: 0.12em; color: var(--text-dim); } +.lib-ruler-value { font-size: 12px; font-weight: 700; color: var(--accent); white-space: nowrap; } +.lib-ruler-scale { position: relative; flex: 1 1 auto; min-height: 0; cursor: ns-resize; touch-action: none; user-select: none; } +/* A year's own field, the months of it standing inside it: what makes the ruler + read by the year first and the month under it after. */ +.lib-ruler-band { position: absolute; left: 0; right: 0; background: var(--bg-elev); border-radius: 3px; } +.lib-ruler-tick { position: absolute; left: 0; width: 10px; height: 1px; background: var(--border); } +.lib-ruler-year { width: 100%; height: 2px; background: var(--text-dim); } +.lib-ruler-year i { position: absolute; top: 2px; left: 14px; font-size: 9px; font-style: normal; font-weight: 700; letter-spacing: 0.06em; color: var(--text-dim); } +.lib-ruler-tick.on { width: 22px; background: var(--accent); } +.lib-ruler-needle { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); pointer-events: none; } /* 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. */ .lib-root .adm-stage > .lib-filters, diff --git a/docker/frontend/src/ui/dateGroups.ts b/docker/frontend/src/ui/dateGroups.ts index 6b6d611..d988486 100644 --- a/docker/frontend/src/ui/dateGroups.ts +++ b/docker/frontend/src/ui/dateGroups.ts @@ -21,10 +21,9 @@ export function dayLabel(timestamp: number, lang: Lang): string { 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 -// is cut. Built on `dayKey`'s own spelling — a month is the first seven characters -// of the day, a year its first four — so the rail's entries and the days it jumps -// to are cut by one rule and not two. +// The month a frame belongs to, and the year above it: the same cut as `dayKey`, +// read one and two fields shorter, so the three levels of the timeline — year, +// month, day — are never two answers to the same question. export function monthKey(timestamp: number): string { return dayKey(timestamp).slice(0, 7); } @@ -33,10 +32,18 @@ export function yearKey(timestamp: number): string { 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 { 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 = { key: string; at: number; photos: T[] }; @@ -56,3 +63,186 @@ export function dayRuns(photos: T[], at: (photo: T) => number): DayRun[] { } return runs; } + +// The rows a shelf of days stands in, which is not `count / cols`: a day is a +// band of its own above its frames — the band spans the grid (`grid-column: +// 1 / -1`) and the frames start on the row after it — so every day spends one +// whole row on its band, and a day whose frames do not come out even over the +// columns leaves the rest of its last row empty, since the next day's band +// starts a row of its own. Counted flat, the model stands for fewer rows than +// the DOM draws, and what stands in for the rows not drawn is a spacer over the +// frames below the fold that were never drawn: the reader scrolls to a screen of +// nothing and stops there. +// +// `bandRows` is that count, day by day: the row each day's band stands in, and +// the row after the last day — the shelf's own height in rows. Every row below a +// band is `cols` frames of that day, so a row is turned back into a frame +// through the band above it and the frames of the day before it. +export function bandRows(dayStarts: number[], count: number, cols: number): number[] { + const rows = new Array(dayStarts.length + 1); + let row = 0; + for (let k = 0; k < dayStarts.length; k++) { + rows[k] = row; + const frames = (k + 1 < dayStarts.length ? dayStarts[k + 1] : count) - dayStarts[k]; + row += 1 + Math.ceil(frames / cols); + } + rows[dayStarts.length] = row; + return rows; +} + +// The 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); +}