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