From ddbde7d426d7ec77131259a378c366d9f9d1e285 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 13 Oct 2026 09:54:47 +0700 Subject: [PATCH] feat(library): read the wall's time by the year and the month, and mint only the frames near the eye --- docker/frontend/scripts/wall-check.mjs | 171 ++++++++++++++++--------- docker/frontend/src/Library.tsx | 127 +++++++++++++----- docker/frontend/src/styles/app.css | 29 ++++- docker/frontend/src/ui/dateGroups.ts | 16 ++- 4 files changed, 244 insertions(+), 99 deletions(-) diff --git a/docker/frontend/scripts/wall-check.mjs b/docker/frontend/scripts/wall-check.mjs index 0254033..6a2f49a 100644 --- a/docker/frontend/scripts/wall-check.mjs +++ b/docker/frontend/scripts/wall-check.mjs @@ -5,8 +5,13 @@ // 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 — reads the -// day the eye is in and takes the wall to the day it is pressed for. +// 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. +// +// 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 @@ -31,8 +36,8 @@ 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 one month, which the rail reads by the day rather -// than by the month — the other half of what the rail is (`rail` in Library.tsx), +// 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; @@ -44,7 +49,11 @@ 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; -const DAY0 = Date.UTC(2026, 0, 1); +// 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); // 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 @@ -73,7 +82,7 @@ const dayIndex = (n) => { } return 0; }; -const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_MS; +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 @@ -100,7 +109,9 @@ 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 = (() => { @@ -359,7 +370,7 @@ step('every header names the day of the cards under it', days1.bad.length === 0, // 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 entry the wall says it is in is read off the accent. +// 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'); @@ -377,16 +388,23 @@ const railOf = () => } 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 seenMonths = new Set(); let scrolledBad = []; @@ -401,13 +419,13 @@ for (const at of stops) { const audit = await dayAudit(); scrolledBad = scrolledBad.concat(audit.bad); const rail = await railOf(); - // The entry the rail says the eye is in has to own the day at the fold — the month - // its key begins with — 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.fold.slice(0, 7) !== rail.on.slice(0, 7)) { - railBad.push({ at, fold: rail.fold, on: rail.on }); + // 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.slice(0, 7)); + 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 }); @@ -419,33 +437,72 @@ step( JSON.stringify(scrolledBad.slice(0, 2)) ); const rail1 = await railOf(); -const railMonths = [...new Set(rail1.keys.map((k) => k.slice(0, 7)))].sort(); -step('the rail holds the months of the shelf', railMonths.join() === [...MONTHS].sort().join(), `${rail1.keys.join(' ')}`); +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 === MONTHS.length, + railBad.length === 0 && seenMonths.size >= 3, `${seenMonths.size} of ${MONTHS.length} months read, ${JSON.stringify(railBad.slice(0, 2))}` ); - -// And the press: the month's own first day is where the wall has to stand, its header -// just under the wall's top edge, and the entry pressed is the one read afterwards. -const jumped = rail1.keys[1]; -await page.click(`[data-key="lib-rail-${jumped}"]`); -await page.waitForTimeout(700); -const landed = await page.evaluate((key) => { - const grid = document.querySelector('.adm-grid'); - const section = grid.querySelector(`[data-key="lib-day-${key}"]`); - const box = grid.getBoundingClientRect(); - const on = document.querySelector('.lib-rail-item.on'); - return { - top: section ? Math.round(section.getBoundingClientRect().top - box.top) : null, - on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null, - }; -}, jumped); step( - 'a press on a month takes the wall to that day', - landed.top !== null && Math.abs(landed.top - 6) <= 6 && landed.on === jumped, - `header at ${landed.top}, entry ${landed.on}` + '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 +// 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; + 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, + }; + }, 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}` +); + +// 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 @@ -483,6 +540,21 @@ const walked = await page.evaluate(async () => { 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`); +// 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. @@ -518,32 +590,17 @@ await page.waitForFunction( 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 read by the day', - [...rail3.keys].sort().join() === [...SMALL_DAY_KEYS].sort().join() && rail3.keys.length === SMALL_DAY_KEYS.length, + '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 plainDay = SMALL_DAY_KEYS[0]; -await page.click(`[data-key="lib-rail-${plainDay}"]`); -await page.waitForTimeout(700); -const small = await page.evaluate((key) => { - const grid = document.querySelector('.adm-grid'); - const section = grid.querySelector(`[data-key="lib-day-${key}"]`); - const box = grid.getBoundingClientRect(); - const on = document.querySelector('.lib-rail-item.on'); - return { - top: section ? Math.round(section.getBoundingClientRect().top - box.top) : null, - cards: section ? section.querySelectorAll('.adm-card').length : 0, - on: on?.textContent.trim() ?? null, - }; -}, plainDay); +const overMonth = await press(smallMonth); step( - 'a press on a day takes the wall to it, whole', - small.top !== null && - Math.abs(small.top - 6) <= 6 && - small.cards === SMALL_EACH && - small.on === String(Number(plainDay.slice(8, 10))), - `header at ${small.top}, ${small.cards} cards, entry ${small.on}` + '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(() => {}); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 66a615f..54859a9 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -221,6 +221,10 @@ const WALL_LANES = 2; // folder opening is the reader waiting on tiles for frames three screens below // the one they are looking at. 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; // 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); @@ -345,13 +349,14 @@ export function Library() { } import { useIsMobile } from './ui/useIsMobile'; -import { dayLabel, dayRuns, monthKey, monthLabel } from './ui/dateGroups'; +import { dayLabel, dayRuns, monthKey, monthLabel, yearKey } from './ui/dateGroups'; -// One entry of the rail down the wall's right edge. `key` is what the entry -// stands for — a month (`2026-10`) or a day (`2026-10-04`) — and `at` is the day -// key the wall is sent to when it is pressed, which is the first day of the -// month for a month's entry. -type RailMark = { key: string; at: string; label: string; title: string }; +// 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 }) { const { t, lang } = useI18n(); @@ -2045,22 +2050,31 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { // 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 time, one entry a month, or a - // day each when the shelf has not a month's worth of months. It is cut off the - // same runs the wall is drawn in, and an entry is looked up by the day key it - // stands for, so an entry and the header it jumps to are one cut of the shelf and - // not two. + // 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 months: RailMark[] = []; - const each: RailMark[] = []; + const all: RailMark[] = []; + let year = ''; + let month = ''; for (const day of days) { - const month = monthKey(day.at); - if (months[months.length - 1]?.key !== month) { - months.push({ key: month, at: day.key, label: monthLabel(day.at, lang), title: dayLabel(day.at, lang) }); + 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; + month = ''; + all.push({ kind: 'year', key: yr, at: day.key, label: yr, title: yr }); + } + if (key !== month) { + month = key; + const label = monthLabel(day.at, lang); + all.push({ kind: 'month', key, at: day.key, label, title: `${label} ${year}` }); } - each.push({ key: day.key, at: day.key, label: String(new Date(day.at).getDate()), title: dayLabel(day.at, lang) }); } - return months.length > 1 ? months : each; + return all; }, [days, lang]); const [railAt, setRailAt] = useState(0); const railRef = useRef(null); @@ -2093,7 +2107,11 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { } if (best < 0) return; const key = (nodes[best].dataset.key ?? '').replace('lib-day-', ''); - const found = rail.findIndex((mark) => key.startsWith(mark.key)); + // 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' } @@ -2142,7 +2160,49 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { }), [] ); - const urls = useThumbUrls(view === 'preview' ? drawn : listed, frame, tilesAt); + // 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); // Auto-scroll & focus tree sidebar to the active node selected in last session const treeRef = useRef(null); @@ -3265,18 +3325,22 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { ))} - {/* The rail beside the wall: the shelf's own time, one - press away from any month of it, and the entry the eye - is in said in the accent. 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 day and not two. */} + {/* 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. */}