← 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;