← back to Thedesignerlibrary
public/bookshelf.js
490 lines
/*
* bookshelf-catalog — a horizontal-scroll 3D bookshelf catalog viewer.
*
* A shelf of 3D "books" you scroll through horizontally (Stripe Press style).
* Click a book: it pulls off the shelf, turns to face you, and opens so you can
* flip through its "pages" — each page is one catalog item (image + title +
* caption + click-through link). Click the backdrop (or press Esc) to reshelve.
*
* Zero build step. Import Three.js via an import map (see index.html). One class:
*
* import { BookshelfCatalog } from './bookshelf.js';
* const shelf = new BookshelfCatalog(document.querySelector('#shelf'), books, opts);
*
* `books` is an array of:
* { title, subtitle?, spineColor?, coverColor?, coverImage?,
* pages: [ { image?, title?, caption?, href? }, ... ] }
*
* Design notes for whoever hands this to an AI to extend (the Meng To thesis —
* a model nails a UI when it starts from a solid open source, so this IS that):
* - Everything is a plain THREE.Mesh with a CanvasTexture for text/labels, so
* it renders with NO external assets. Pass coverImage/page.image URLs to use
* real photos; they fall back to generated covers if omitted or 404.
* - Animations use a tiny self-contained tween loop (no GSAP). Each tween is
* {obj, key, from, to, t, dur, ease, onDone}. See _tween()/_stepTweens().
* - State machine: 'shelf' -> 'opening' -> 'reading' -> 'closing' -> 'shelf'.
*/
import * as THREE from 'three';
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const easeOutBack = (t) => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); };
// --- book geometry constants (world units) ---
const BOOK_H = 3.0; // spine height
const BOOK_W = 2.1; // cover width (when open, this is one page)
const BOOK_T = 0.42; // thickness (spine width on the shelf)
const GAP = 0.10; // gap between books on the shelf
const SHELF_Y = 0;
export class BookshelfCatalog {
constructor(container, books = [], opts = {}) {
if (!container) throw new Error('BookshelfCatalog: container element required');
this.container = container;
this.books = books;
this.opts = Object.assign({
background: '#0f1117',
shelfColor: '#20242e',
accent: '#c9a227',
autoRotateHint: true, // gentle idle drift on the shelf
onOpen: null, // (book, index) => void
onClose: null, // (book) => void ; fires when the book is reshelved
onPageChange: null, // (book, pageIndex) => void
onLinkClick: null, // (page, book) => void ; return false to prevent navigation
}, opts);
this.state = 'shelf';
this.activeIndex = -1;
this.pageIndex = 0;
this._tweens = [];
this._bookGroups = [];
this._raf = null;
this._scrollX = 0; // camera pan target along the shelf
this._scrollXCurrent = 0;
this._initThree();
this._buildShelf();
this._buildBooks();
this._bindEvents();
this._resize();
this._loop();
}
// ---------- three.js scaffold ----------
_initThree() {
const { clientWidth: w, clientHeight: h } = this.container;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(this.opts.background);
this.camera = new THREE.PerspectiveCamera(42, (w || 1) / (h || 1), 0.1, 100);
this.camera.position.set(0, 1.2, 11);
this.camera.lookAt(0, 0.4, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setSize(w || 1, h || 1);
this.container.appendChild(this.renderer.domElement);
this.renderer.domElement.style.display = 'block';
this.renderer.domElement.style.touchAction = 'none';
// lighting — a soft key + warm rim so spines read
this.scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const key = new THREE.DirectionalLight(0xffffff, 0.9); key.position.set(4, 8, 6); this.scene.add(key);
const rim = new THREE.DirectionalLight(0xffe6b0, 0.35); rim.position.set(-6, 2, 4); this.scene.add(rim);
this.raycaster = new THREE.Raycaster();
this.pointer = new THREE.Vector2();
}
_buildShelf() {
const total = this.books.length;
const width = Math.max(6, total * (BOOK_T + GAP) + 2);
const plank = new THREE.Mesh(
new THREE.BoxGeometry(width, 0.25, 2.4),
new THREE.MeshStandardMaterial({ color: this.opts.shelfColor, roughness: 0.85 })
);
plank.position.set(0, SHELF_Y - BOOK_H / 2 - 0.12, 0);
this.scene.add(plank);
const back = new THREE.Mesh(
new THREE.BoxGeometry(width, BOOK_H + 1.2, 0.15),
new THREE.MeshStandardMaterial({ color: this.opts.shelfColor, roughness: 0.9 })
);
back.position.set(0, SHELF_Y, -1.15);
this.scene.add(back);
this._shelfWidth = width;
}
_buildBooks() {
const total = this.books.length;
const stride = BOOK_T + GAP;
const x0 = -((total - 1) * stride) / 2;
this.books.forEach((book, i) => {
const group = new THREE.Group();
group.position.set(x0 + i * stride, SHELF_Y, 0);
group.userData = { index: i, homeX: x0 + i * stride, book };
// the closed book body (spine faces +Z toward the viewer)
const spineColor = new THREE.Color(book.spineColor || this._autoColor(i));
const body = new THREE.Mesh(
new THREE.BoxGeometry(BOOK_T, BOOK_H, BOOK_W),
[
new THREE.MeshStandardMaterial({ color: spineColor, roughness: 0.6 }), // +x page edge
new THREE.MeshStandardMaterial({ color: 0xf3efe4, roughness: 0.9 }), // -x pages
new THREE.MeshStandardMaterial({ color: spineColor.clone().offsetHSL(0, 0, 0.05) }), // top
new THREE.MeshStandardMaterial({ color: spineColor.clone().offsetHSL(0, 0, -0.05) }),// bottom
new THREE.MeshStandardMaterial({ map: this._spineTexture(book, spineColor), roughness: 0.5 }), // +z SPINE
new THREE.MeshStandardMaterial({ color: 0xf3efe4, roughness: 0.9 }), // -z back
]
);
body.userData.pickable = true;
body.userData.index = i;
group.add(body);
group.userData.body = body;
this.scene.add(group);
this._bookGroups.push(group);
});
}
// ---------- generated textures (no external assets required) ----------
_autoColor(i) {
// golden-angle hue rotation -> distinct, muted spine colors
const hue = (i * 137.508) % 360;
return new THREE.Color().setHSL(hue / 360, 0.42, 0.42).getStyle();
}
// spine/cover text ink chosen by luminance — white titles vanish on the
// White/Cream and Greige hue-books, so light spines get dark walnut ink
_inkFor(spineColor) {
const { r, g, b } = spineColor;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return lum > 0.6
? { main: '#2b2118', dim: 'rgba(43,33,24,0.75)', band: 'rgba(43,33,24,0.18)' }
: { main: '#fff', dim: 'rgba(255,255,255,0.82)', band: 'rgba(255,255,255,0.10)' };
}
_spineTexture(book, spineColor) {
const cw = 256, ch = 1024;
const c = document.createElement('canvas'); c.width = cw; c.height = ch;
const g = c.getContext('2d');
const ink = this._inkFor(spineColor);
g.fillStyle = spineColor.getStyle(); g.fillRect(0, 0, cw, ch);
// subtle top/bottom bands
g.fillStyle = ink.band; g.fillRect(0, 40, cw, 6); g.fillRect(0, ch - 46, cw, 6);
// vertical title
g.save(); g.translate(cw / 2, ch / 2); g.rotate(-Math.PI / 2);
g.fillStyle = ink.main; g.textAlign = 'center'; g.textBaseline = 'middle';
g.font = '600 62px Georgia, serif';
g.fillText(this._fit(g, book.title || 'Untitled', ch - 120), 0, -18);
if (book.subtitle) { g.font = '400 34px Georgia, serif'; g.fillStyle = ink.dim; g.fillText(this._fit(g, book.subtitle, ch - 160), 0, 46); }
g.restore();
const tex = new THREE.CanvasTexture(c); tex.anisotropy = 4; return tex;
}
_coverTexture(book, spineColor) {
const cw = 512, ch = 720;
const c = document.createElement('canvas'); c.width = cw; c.height = ch;
const g = c.getContext('2d');
const grad = g.createLinearGradient(0, 0, 0, ch);
grad.addColorStop(0, spineColor.clone().offsetHSL(0, 0, 0.08).getStyle());
grad.addColorStop(1, spineColor.clone().offsetHSL(0, 0, -0.10).getStyle());
g.fillStyle = grad; g.fillRect(0, 0, cw, ch);
const ink = this._inkFor(spineColor);
g.strokeStyle = ink.main === '#fff' ? 'rgba(255,255,255,0.35)' : 'rgba(43,33,24,0.35)';
g.lineWidth = 3; g.strokeRect(24, 24, cw - 48, ch - 48);
g.fillStyle = ink.main; g.textAlign = 'center';
g.font = '700 52px Georgia, serif';
this._wrap(g, book.title || 'Untitled', cw / 2, 220, cw - 100, 60);
if (book.subtitle) { g.font = '400 30px Georgia, serif'; g.fillStyle = ink.dim; g.fillText(this._fit(g, book.subtitle, cw - 100), cw / 2, ch - 90); }
const tex = new THREE.CanvasTexture(c); tex.anisotropy = 4; return tex;
}
_pageTexture(page, pageNo, count) {
const cw = 512, ch = 720;
const c = document.createElement('canvas'); c.width = cw; c.height = ch;
const g = c.getContext('2d');
g.fillStyle = '#faf8f2'; g.fillRect(0, 0, cw, ch);
// image area (filled by loaded texture if page.image; here a placeholder block)
g.fillStyle = '#e8e3d6'; g.fillRect(40, 40, cw - 80, 420);
g.fillStyle = '#b8b0a0'; g.textAlign = 'center'; g.font = '400 24px Georgia, serif';
g.fillText(page.image ? 'loading image…' : 'no image', cw / 2, 250);
g.fillStyle = '#2a2a2a'; g.textAlign = 'left'; g.font = '700 34px Georgia, serif';
this._wrap(g, page.title || '', 44, 520, cw - 88, 40);
g.fillStyle = '#555'; g.font = '400 24px Georgia, serif';
this._wrap(g, page.caption || '', 44, 600, cw - 88, 30);
g.fillStyle = '#999'; g.textAlign = 'right'; g.font = '400 20px Georgia, serif';
g.fillText(`${pageNo} / ${count}`, cw - 44, ch - 34);
if (page.href) { g.fillStyle = this.opts.accent; g.textAlign = 'left'; g.font = '600 22px Georgia, serif'; g.fillText('View ›', 44, ch - 40); }
const tex = new THREE.CanvasTexture(c); tex.anisotropy = 4;
// async swap in the real image on top of the placeholder, if provided.
// Guard on _destroyed: the load can outlive a shelf-tab switch, and writing
// to a disposed texture is a WebGL error waiting to happen.
if (page.image) {
const img = new Image(); img.crossOrigin = 'anonymous';
img.onload = () => {
if (this._destroyed) return;
const r = Math.min((cw - 80) / img.width, 420 / img.height);
const dw = img.width * r, dh = img.height * r;
g.fillStyle = '#e8e3d6'; g.fillRect(40, 40, cw - 80, 420);
g.drawImage(img, (cw - dw) / 2, 40 + (420 - dh) / 2, dw, dh);
tex.needsUpdate = true;
};
img.onerror = () => {};
img.src = page.image;
}
return tex;
}
_fit(g, text, maxW) { let t = text; while (g.measureText(t).width > maxW && t.length > 4) t = t.slice(0, -2); return t.length < text.length ? t.trim() + '…' : t; }
_wrap(g, text, x, y, maxW, lh) {
const words = String(text).split(/\s+/); let line = '', yy = y;
for (const w of words) { const test = line ? line + ' ' + w : w; if (g.measureText(test).width > maxW && line) { g.fillText(line, x, yy); line = w; yy += lh; } else line = test; }
if (line) g.fillText(line, x, yy);
}
// ---------- the "open book" object (built lazily on first open) ----------
_ensureOpenBook() {
if (this._openBook) return this._openBook;
const group = new THREE.Group();
// left page + right page as two thin boxes hinged at the center spine
const pageGeo = new THREE.BoxGeometry(BOOK_W, BOOK_H, 0.04);
const white = () => new THREE.MeshStandardMaterial({ color: 0xfaf8f2, roughness: 0.95 });
this._leftPage = new THREE.Mesh(pageGeo, [white(), white(), white(), white(), white(), white()]);
this._rightPage = new THREE.Mesh(pageGeo.clone(), [white(), white(), white(), white(), white(), white()]);
this._leftPage.position.set(-BOOK_W / 2 - 0.02, 0, 0);
this._rightPage.position.set(BOOK_W / 2 + 0.02, 0, 0);
group.add(this._leftPage, this._rightPage);
group.visible = false;
group.position.set(0, 0.4, 6.2); // reading position, close to camera
this.scene.add(group);
this._openBook = group;
return group;
}
_paintSpread(book) {
const count = book.pages ? book.pages.length : 0;
const spineColor = new THREE.Color(book.spineColor || this._autoColor(this.activeIndex));
// left page = cover-ish / prior page; right page = current item
const leftPage = book.pages && book.pages[this.pageIndex - 1];
const rightPage = book.pages && book.pages[this.pageIndex];
const leftTex = leftPage ? this._pageTexture(leftPage, this.pageIndex, count) : this._coverTexture(book, spineColor);
const rightTex = rightPage ? this._pageTexture(rightPage, this.pageIndex + 1, count) : this._coverTexture(book, spineColor);
this._setFace(this._leftPage, 4, leftTex); // +z face toward viewer
this._setFace(this._rightPage, 4, rightTex);
if (this.opts.onPageChange) this.opts.onPageChange(book, this.pageIndex);
}
_setFace(mesh, faceIdx, tex) {
const m = mesh.material[faceIdx];
if (m.map) m.map.dispose();
m.map = tex; m.color.set(0xffffff); m.needsUpdate = true;
}
// ---------- interaction ----------
_bindEvents() {
const el = this.renderer.domElement;
this._onResize = () => this._resize(); window.addEventListener('resize', this._onResize);
// horizontal scroll on the shelf
el.addEventListener('wheel', (e) => {
if (this.state !== 'shelf') return; e.preventDefault();
const d = (Math.abs(e.deltaX) > Math.abs(e.deltaY)) ? e.deltaX : e.deltaY;
this._scrollX = clamp(this._scrollX + d * 0.01, this._minScroll(), this._maxScroll());
}, { passive: false });
// drag to pan (shelf) — pointer events cover mouse + touch
let dragging = false, lastX = 0, moved = 0;
el.addEventListener('pointerdown', (e) => { dragging = true; moved = 0; lastX = e.clientX; el.setPointerCapture(e.pointerId); });
el.addEventListener('pointermove', (e) => {
if (!dragging) return; const dx = e.clientX - lastX; lastX = e.clientX; moved += Math.abs(dx);
if (this.state === 'shelf') this._scrollX = clamp(this._scrollX - dx * 0.02, this._minScroll(), this._maxScroll());
});
el.addEventListener('pointerup', (e) => {
dragging = false;
if (moved < 5) this._handleClick(e); // treat as a click, not a drag
});
// browser-cancelled pointer (two-finger scroll etc.) must clear the drag
// state or the next tap gets misread as a drag
el.addEventListener('pointercancel', () => { dragging = false; });
// keyboard: arrows page/scroll, Esc closes (stored so destroy() can unbind —
// this instance is rebuilt on every shelf-tab switch)
this._onKeydown = (e) => {
if (this.state === 'reading') {
if (e.key === 'ArrowRight') this.nextPage();
else if (e.key === 'ArrowLeft') this.prevPage();
else if (e.key === 'Escape') this.closeBook();
} else if (this.state === 'shelf') {
if (e.key === 'ArrowRight') this._scrollX = clamp(this._scrollX + 1, this._minScroll(), this._maxScroll());
else if (e.key === 'ArrowLeft') this._scrollX = clamp(this._scrollX - 1, this._minScroll(), this._maxScroll());
}
};
window.addEventListener('keydown', this._onKeydown);
}
_minScroll() { return -this._shelfWidth / 2 + 3; }
_maxScroll() { return this._shelfWidth / 2 - 3; }
_handleClick(e) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.pointer, this.camera);
if (this.state === 'shelf') {
const hits = this.raycaster.intersectObjects(this._bookGroups.map(g => g.userData.body), false);
if (hits.length) { this.openBook(hits[0].object.userData.index); return; }
} else if (this.state === 'reading') {
// right page: tap flips forward; ONLY the "View ›" hotspot — the bottom
// ~14% of the page, left third, where the label is drawn — opens the
// store link. Otherwise a touch user can never see page 2 because every
// page has an href. left page: back. backdrop: close.
const hits = this.raycaster.intersectObjects([this._leftPage, this._rightPage], false);
if (hits.length) {
const page = this.books[this.activeIndex].pages?.[this.pageIndex];
const uv = hits[0].uv;
const onViewStrip = uv && uv.y < 0.14 && uv.x < 0.38;
if (hits[0].object === this._rightPage && page && page.href && onViewStrip) {
const ok = this.opts.onLinkClick ? this.opts.onLinkClick(page, this.books[this.activeIndex]) : true;
if (ok !== false) window.open(page.href, '_blank', 'noopener,noreferrer');
return;
}
if (hits[0].object === this._rightPage) this.nextPage(); else this.prevPage();
} else {
this.closeBook();
}
}
}
// ---------- public API ----------
openBook(index) {
if (this.state !== 'shelf' || index < 0 || index >= this._bookGroups.length) return;
this.state = 'opening'; this.activeIndex = index; this.pageIndex = 0;
const g = this._bookGroups[index];
g.userData.body.visible = false; // hide the shelved spine
const open = this._ensureOpenBook(); open.visible = true;
open.scale.setScalar(this._openBookScale());
this._paintSpread(this.books[index]);
// animate: book flies from shelf to reading position + opens flat
open.position.set(g.position.x - this._scrollXCurrent, 0.4, 0.5);
open.rotation.set(0, -0.9, 0);
this._leftPage.rotation.y = 0.9; this._rightPage.rotation.y = -0.9; // start closed-ish
this._tween(open.position, 'z', open.position.z, 6.2, 620, easeOutBack);
this._tween(open.position, 'x', open.position.x, 0, 620, easeInOutCubic);
this._tween(open.rotation, 'y', open.rotation.y, 0, 620, easeInOutCubic);
this._tween(this._leftPage.rotation, 'y', 0.9, 0, 700, easeInOutCubic);
this._tween(this._rightPage.rotation, 'y', -0.9, 0, 700, easeInOutCubic, () => { this.state = 'reading'; });
if (this.opts.onOpen) this.opts.onOpen(this.books[index], index);
}
// scale the open spread down on narrow (portrait) viewports so both pages
// stay inside the frame — on a phone the full-size spread crops both edges
_openBookScale() {
const dist = 11 - 6.2; // camera z - reading z
const visH = 2 * dist * Math.tan((this.camera.fov / 2) * Math.PI / 180);
const visW = visH * this.camera.aspect;
const spreadW = BOOK_W * 2 + 0.1;
return clamp((visW * 0.94) / spreadW, 0.3, 1);
}
closeBook() {
if (this.state !== 'reading') return;
this.state = 'closing';
const open = this._openBook; const g = this._bookGroups[this.activeIndex];
const closedBook = this.books[this.activeIndex];
this._tween(this._leftPage.rotation, 'y', 0, 0.9, 400, easeInOutCubic);
this._tween(this._rightPage.rotation, 'y', 0, -0.9, 400, easeInOutCubic);
this._tween(open.position, 'z', open.position.z, 0.5, 520, easeInOutCubic);
this._tween(open.position, 'x', 0, g.position.x - this._scrollXCurrent, 520, easeInOutCubic);
this._tween(open.rotation, 'y', 0, -0.9, 520, easeInOutCubic, () => {
open.visible = false; g.userData.body.visible = true;
this.state = 'shelf'; this.activeIndex = -1;
if (this.opts.onClose) this.opts.onClose(closedBook);
});
}
nextPage() {
if (this.state !== 'reading') return;
const pages = this.books[this.activeIndex].pages || [];
if (this.pageIndex >= pages.length - 1) return;
this.pageIndex++; this._flip(1);
}
prevPage() {
if (this.state !== 'reading') return;
if (this.pageIndex <= 0) return;
this.pageIndex--; this._flip(-1);
}
_flip(dir) {
// quick page-turn: swing the leading page then repaint the spread
const page = dir > 0 ? this._rightPage : this._leftPage;
const from = page.rotation.y, mid = dir > 0 ? -Math.PI * 0.5 : Math.PI * 0.5;
this._tween(page.rotation, 'y', from, mid, 160, easeInOutCubic, () => {
this._paintSpread(this.books[this.activeIndex]);
this._tween(page.rotation, 'y', mid, 0, 160, easeInOutCubic);
});
}
goToBook(index) { if (this.state === 'shelf') this._scrollX = clamp(this._bookGroups[index].userData.homeX, this._minScroll(), this._maxScroll()); }
// ---------- render loop + tweens ----------
_tween(obj, key, from, to, dur, ease, onDone) { this._tweens.push({ obj, key, from, to, t: 0, dur, ease: ease || easeInOutCubic, onDone }); }
_stepTweens(dt) {
for (let i = this._tweens.length - 1; i >= 0; i--) {
const tw = this._tweens[i]; tw.t += dt;
const k = clamp(tw.t / tw.dur, 0, 1); tw.obj[tw.key] = tw.from + (tw.to - tw.from) * tw.ease(k);
if (k >= 1) { this._tweens.splice(i, 1); if (tw.onDone) tw.onDone(); }
}
}
_loop() {
let last = performance.now();
const frame = (now) => {
const dt = now - last; last = now;
this._stepTweens(dt);
// ease camera pan toward target
this._scrollXCurrent += (this._scrollX - this._scrollXCurrent) * 0.12;
if (this.state === 'shelf') {
this.camera.position.x = this._scrollXCurrent;
this.camera.lookAt(this._scrollXCurrent, 0.4, 0);
if (this.opts.autoRotateHint) this._bookGroups.forEach((g, i) => { g.rotation.y = Math.sin(now * 0.0004 + i) * 0.04; });
} else {
// when reading, glide camera to center on the open book
this.camera.position.x += (0 - this.camera.position.x) * 0.12;
this.camera.lookAt(0, 0.4, 4);
}
this.renderer.render(this.scene, this.camera);
this._raf = requestAnimationFrame(frame);
};
this._raf = requestAnimationFrame(frame);
}
_resize() {
const w = this.container.clientWidth || 1, h = this.container.clientHeight || 1;
this.camera.aspect = w / h; this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
if (this._openBook && this._openBook.visible) this._openBook.scale.setScalar(this._openBookScale());
}
destroy() {
this._destroyed = true;
cancelAnimationFrame(this._raf);
window.removeEventListener('resize', this._onResize);
window.removeEventListener('keydown', this._onKeydown);
// renderer.dispose() alone leaves every geometry/material/CanvasTexture on
// the GPU — this instance is rebuilt per shelf-tab switch, so without the
// traversal 4-5 switches visibly stutter a mobile GPU
this.scene.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of mats) { if (m.map) m.map.dispose(); m.dispose(); }
}
});
this.renderer.dispose();
if (this.renderer.domElement.parentNode) this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
}
export default BookshelfCatalog;