Speed up timeline load with portrait thumbs, bootstrap API, and caching.

Add catalog bootstrap endpoint, portrait thumbnail pipeline, lazy queued timeline images, gzip compression, and 3D texture throttling with code-split VirtualGallery.
This commit is contained in:
Danila Khodjaef
2026-07-06 14:27:29 +03:00
parent bdddadc4d6
commit 99b8559607
117 changed files with 646 additions and 171 deletions
+44 -2
View File
@@ -86,6 +86,48 @@ Curator mutations are recorded in `curator_audit_log` (see [DB_structure.md](DB_
--- ---
## `GET /api/catalog/bootstrap`
**Preferred for timeline first paint.** Returns bounds, eras, movements, and slim artist rows in a single response (replaces the separate `bounds` + `timeline` + `artists?timeline=1` waterfall).
**Query**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `start` | int | bounds `min_year` | Window start year |
| `end` | int | bounds `max_year` | Window end year |
**Response**
```json
{
"bounds": { "min_year": -800, "max_year": 2100 },
"eras": [ ],
"movements": [ ],
"artists": [
{
"id": 1,
"name": "Claude Monet",
"birth_year": 1840,
"death_year": 1926,
"movement_id": 12,
"portrait_path": "portraits/Claude_Monet.jpg",
"portrait_thumb_path": "portraits/thumbs/Claude_Monet_thumb.jpg",
"wikipedia_title": "Claude Monet",
"century": 19,
"movement_name": "Impressionism",
"movement_color": "#6B8E9F"
}
]
}
```
**Caching:** `Cache-Control: public, max-age=300` with `ETag` (304 when catalog row counts unchanged).
The React home page loads this endpoint **once** on mount. Pan and zoom filter movements and portraits **client-side** — no refetch per view change.
---
## `GET /api/bounds` ## `GET /api/bounds`
Returns the overall timeline year range used to initialise the zoomable timeline. Returns the overall timeline year range used to initialise the zoomable timeline.
@@ -138,11 +180,11 @@ Artists for timeline portraits and the movement flow diagram.
| `start` | int | Only artists alive after this year | | `start` | int | Only artists alive after this year |
| `end` | int | Only artists born before this year | | `end` | int | Only artists born before this year |
| `movement_id` | int | Filter by movement | | `movement_id` | int | Filter by movement |
| `timeline` | bool | When `1` or `true`, return a lightweight row set for the home-page timeline (omits `bio_full` and other heavy fields) | | `timeline` | bool | When `1` or `true`, return a lightweight row set for the home-page timeline (omits `bio_short`, `bio_full`; includes `portrait_thumb_path`) |
**Response** — array of artist objects with joined `movement_name` and `movement_color`. **Response** — array of artist objects with joined `movement_name` and `movement_color`.
The React home page loads the timeline catalog **once** on mount via `GET /api/bounds`, `GET /api/timeline?start=…&end=…` (full range), and `GET /api/artists?timeline=1`. Pan and zoom filter movements and portraits **client-side** — no refetch per view change. Rapid pan/zoom is batched with `createViewChangeScheduler()` (one React update per animation frame). The React home page loads the timeline catalog **once** on mount via `GET /api/catalog/bootstrap` (or legacy: `GET /api/bounds` + `GET /api/timeline` + `GET /api/artists?timeline=1`). Pan and zoom filter movements and portraits **client-side** — no refetch per view change. Rapid pan/zoom is batched with `createViewChangeScheduler()` (one React update per animation frame).
--- ---
+8 -3
View File
@@ -12,8 +12,10 @@ How catalog content, biographies, and artwork files enter the system.
```text ```text
data/images/ data/images/
├── portraits/ # Artist headshots ├── portraits/ # Artist headshots (display ~900px wide)
── Claude_Monet.jpg ── Claude_Monet.jpg
│ └── thumbs/ # Timeline thumbnails (~256px)
│ └── Claude_Monet_thumb.jpg
└── paintings/ └── paintings/
├── Claude_Monet_Water_Lilies.jpg ├── Claude_Monet_Water_Lilies.jpg
└── thumbs/ └── thumbs/
@@ -47,7 +49,8 @@ Promote dev → prod files: `npm run images:sync-to-prod` (after `net use \\192.
| `fetch-missing-images.js` | `npm run fetch-images` | Downloads files for paintings missing on disk | | `fetch-missing-images.js` | `npm run fetch-images` | Downloads files for paintings missing on disk |
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API | | `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
| `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run images:sync-*` | Robocopy via SMB `\\192.168.10.122\Gallery` | | `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run images:sync-*` | Robocopy via SMB `\\192.168.10.122\Gallery` |
| `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild thumbs from full images via `sharp` | | `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild painting thumbs from full images via `sharp` |
| `regenerate-portrait-thumbs.js` | `npm run regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) and set `portrait_thumb_path` |
| `audit-painting-images.js` | `npm run audit-painting-images` | Detect thumb/full aspect-ratio mismatches | | `audit-painting-images.js` | `npm run audit-painting-images` | Detect thumb/full aspect-ratio mismatches |
| `find-duplicate-paintings.js` | `npm run find-duplicates` | Report exact and near-duplicate catalog rows | | `find-duplicate-paintings.js` | `npm run find-duplicates` | Report exact and near-duplicate catalog rows |
| `migrate-checkup-flags.js` | `npm run migrate:checkup-flags` | Add `checkup_checked` / `checkup_fixed` columns | | `migrate-checkup-flags.js` | `npm run migrate:checkup-flags` | Add `checkup_checked` / `checkup_fixed` columns |
@@ -154,6 +157,8 @@ Typical result on a full clone: ~1,000+ paintings linked from ~1,000 on-disk fil
## Artist portraits ## Artist portraits
Timeline movement flow loads **`portrait_thumb_path`** (~256px JPEG under `portraits/thumbs/{Artist}_thumb.jpg`) when available; biography and 3D exit navigation use full `portrait_path`. After adding portraits, run `npm run regenerate-portrait-thumbs` to backfill thumbs on dev.
`npm run fetch-artist-images` runs `scripts/fetch-artist-images.js`: `npm run fetch-artist-images` runs `scripts/fetch-artist-images.js`:
1. For each artist, checks `data/images/portraits/{Artist}.jpg` (or other extensions) and sets `portrait_path` when a local file exists. 1. For each artist, checks `data/images/portraits/{Artist}.jpg` (or other extensions) and sets `portrait_path` when a local file exists.
+25 -2
View File
@@ -1,5 +1,6 @@
import type { import type {
TimelineData, TimelineData,
CatalogBootstrap,
YearBounds, YearBounds,
Artist, Artist,
ArtistDetail, ArtistDetail,
@@ -62,13 +63,25 @@ export function portraitUrl(path: string | null | undefined, revision?: number):
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`; return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
} }
/** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */ /** Small portrait for timeline / movement flow (~256px). Falls back to full portrait. */
export function portraitThumbUrl(
artist: {
portrait_thumb_path?: string | null;
portrait_path?: string | null;
},
revision?: number
): string {
const path = artist.portrait_thumb_path || artist.portrait_path;
return portraitUrl(path, revision);
}
/** Image for 3D gallery — prefer thumbnail for faster texture loads */
export function galleryImageUrl(painting: { export function galleryImageUrl(painting: {
thumbnail_path?: string | null; thumbnail_path?: string | null;
image_path?: string | null; image_path?: string | null;
}): string | null { }): string | null {
if (painting.image_path) return `/images/${painting.image_path}`;
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`; if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
if (painting.image_path) return `/images/${painting.image_path}`;
return null; return null;
} }
@@ -202,6 +215,14 @@ export interface PaintingCheckupData {
export const api = { export const api = {
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`), getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
getCatalogBootstrap: (start?: number, end?: number) => {
const params = new URLSearchParams();
if (start != null) params.set('start', String(start));
if (end != null) params.set('end', String(end));
const qs = params.toString();
return fetchJson<CatalogBootstrap>(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`);
},
getTimeline: (start: number, end: number) => getTimeline: (start: number, end: number) =>
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`), fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
@@ -345,6 +366,8 @@ export const api = {
} }
return res.json() as Promise<{ checked: boolean; fixed: boolean }>; return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}), }),
preloadArtistImages,
}; };
export function debugImageProxyUrl( export function debugImageProxyUrl(
+127 -54
View File
@@ -1,6 +1,7 @@
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
import type { ArtMovement, Artist } from '../types'; import type { ArtMovement, Artist } from '../types';
import { portraitUrl } from '../api/client'; import { portraitThumbUrl } from '../api/client';
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage'; import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView'; import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import './MovementBands.css'; import './MovementBands.css';
@@ -639,6 +640,108 @@ function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
return { x, y: yOnStream(layout, x) }; return { x, y: yOnStream(layout, x) };
} }
const MovementArtistPortrait = memo(function MovementArtistPortrait({
artist,
portraitRevision,
lineLeft,
lineWidth,
portraitX,
y,
layoutHeight,
color,
colorIndex,
isHovered,
birthLabel,
deathLabel,
movementName,
viewStart,
viewEnd,
onArtistClick,
onArtistHover,
onHoverStart,
onHoverEnd,
}: {
artist: Artist;
portraitRevision?: number;
lineLeft: number;
lineWidth: number;
portraitX: number;
y: number;
layoutHeight: number;
color: string;
colorIndex: number;
isHovered: boolean;
birthLabel: string | number;
deathLabel: string | number;
movementName: string;
viewStart: number;
viewEnd: number;
onArtistClick: (id: number) => void;
onArtistHover?: (lifespan: { birthYear: number; deathYear: number; color: string } | null) => void;
onHoverStart: () => void;
onHoverEnd: () => void;
}) {
const thumbSrc = portraitThumbUrl(artist, portraitRevision);
const queuedSrc = useQueuedImageSrc(thumbSrc);
return (
<div className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}>
<div
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
style={{
left: `${lineLeft}%`,
width: `${lineWidth}%`,
top: `${(y / layoutHeight) * 100}%`,
zIndex: isHovered ? 12 : 4 + colorIndex,
['--lifespan-color' as string]: color,
}}
>
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
</div>
<button
type="button"
className="artist-portrait"
style={{
left: `${portraitX}%`,
top: `${(y / layoutHeight) * 100}%`,
borderColor: color,
zIndex: isHovered ? 13 : 5 + colorIndex,
}}
onClick={() => onArtistClick(artist.id)}
onMouseEnter={() => {
onHoverStart();
onArtistHover?.({
birthYear: artist.birth_year ?? viewStart,
deathYear: artist.death_year ?? viewEnd,
color,
});
}}
onMouseLeave={() => {
onHoverEnd();
onArtistHover?.(null);
}}
onMouseDown={(e) => e.stopPropagation()}
title={`${artist.name} (${birthLabel}${deathLabel}) · ${movementName}`}
>
{queuedSrc ? (
<img
src={queuedSrc}
alt={artist.name}
loading="lazy"
decoding="async"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
/>
) : (
<img src="/placeholder-portrait.svg" alt="" aria-hidden />
)}
<span className="artist-name">{artist.name}</span>
</button>
</div>
);
});
export default function MovementBands({ export default function MovementBands({
movements, movements,
artists, artists,
@@ -674,7 +777,7 @@ export default function MovementBands({
interactionTimer.current = window.setTimeout(() => { interactionTimer.current = window.setTimeout(() => {
interactionTimer.current = null; interactionTimer.current = null;
setInteracting(false); setInteracting(false);
}, 120); }, 200);
}, []); }, []);
useEffect(() => () => { useEffect(() => () => {
@@ -1216,60 +1319,30 @@ export default function MovementBands({
const birthLabel = artist.birth_year != null ? artist.birth_year : '?'; const birthLabel = artist.birth_year != null ? artist.birth_year : '?';
const deathLabel = artist.death_year != null ? artist.death_year : '?'; const deathLabel = artist.death_year != null ? artist.death_year : '?';
const artistKey = `${layout.movement.id}-${artist.id}`; const artistKey = `${layout.movement.id}-${artist.id}`;
const isHovered = hoveredArtistKey === artistKey;
return ( return (
<div <MovementArtistPortrait
key={artistKey} key={artistKey}
className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`} artist={artist}
> portraitRevision={portraitRevisions?.[artist.id]}
<div lineLeft={lineLeft}
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`} lineWidth={lineWidth}
style={{ portraitX={portraitX}
left: `${lineLeft}%`, y={y}
width: `${lineWidth}%`, layoutHeight={layoutHeight}
top: `${(y / layoutHeight) * 100}%`, color={color}
zIndex: isHovered ? 12 : 4 + colorIndex, colorIndex={colorIndex}
['--lifespan-color' as string]: color, isHovered={hoveredArtistKey === artistKey}
}} birthLabel={birthLabel}
> deathLabel={deathLabel}
{isHovered && <span className="artist-lifespan-line" aria-hidden />} movementName={layout.movement.name}
</div> viewStart={viewStart}
<button viewEnd={viewEnd}
type="button" onArtistClick={onArtistClick}
className="artist-portrait" onArtistHover={onArtistHover}
style={{ onHoverStart={() => setHoveredArtistKey(artistKey)}
left: `${portraitX}%`, onHoverEnd={() => setHoveredArtistKey(null)}
top: `${(y / layoutHeight) * 100}%`, />
borderColor: color,
zIndex: isHovered ? 13 : 5 + colorIndex,
}}
onClick={() => onArtistClick(artist.id)}
onMouseEnter={() => {
setHoveredArtistKey(artistKey);
onArtistHover?.({
birthYear: artist.birth_year ?? viewStart,
deathYear: artist.death_year ?? viewEnd,
color,
});
}}
onMouseLeave={() => {
setHoveredArtistKey(null);
onArtistHover?.(null);
}}
onMouseDown={(e) => e.stopPropagation()}
title={`${artist.name} (${birthLabel}${deathLabel}) · ${layout.movement.name}`}
>
<img
src={portraitUrl(artist.portrait_path, portraitRevisions?.[artist.id])}
alt={artist.name}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
/>
<span className="artist-name">{artist.name}</span>
</button>
</div>
); );
})} })}
</div> </div>
+4
View File
@@ -130,6 +130,8 @@ function InfluenceCard({
<img <img
src={imageUrl(inf.artist_portrait)} src={imageUrl(inf.artist_portrait)}
alt={artistName} alt={artistName}
loading="lazy"
decoding="async"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg'; (e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}} }}
@@ -164,6 +166,8 @@ function InfluenceCard({
<img <img
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path }) ?? '/placeholder-art.svg'} src={paintingImageUrl({ id: inf.id, image_path: inf.image_path }) ?? '/placeholder-art.svg'}
alt={inf.title || 'Painting'} alt={inf.title || 'Painting'}
loading="lazy"
decoding="async"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg'; (e.target as HTMLImageElement).src = '/placeholder-art.svg';
}} }}
+16 -38
View File
@@ -10,7 +10,8 @@ import type {
ArtistNavigation, ArtistNavigation,
MovementArtistGroup, MovementArtistGroup,
} from '../types'; } from '../types';
import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client'; import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
import { loadTextureQueued } from '../utils/textureLoadQueue';
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils'; import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures'; import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles'; import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
@@ -561,11 +562,9 @@ function usePaintingTexture(url: string | null) {
setFailed(false); setFailed(false);
let disposed = false; let disposed = false;
let loaded: THREE.Texture | null = null; let loaded: THREE.Texture | null = null;
const loader = new THREE.TextureLoader();
loader.setCrossOrigin('anonymous'); loadTextureQueued(url)
loader.load( .then((tex) => {
url,
(tex) => {
if (disposed) { if (disposed) {
tex.dispose(); tex.dispose();
return; return;
@@ -580,12 +579,10 @@ function usePaintingTexture(url: string | null) {
tex.colorSpace = THREE.SRGBColorSpace; tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4; tex.anisotropy = 4;
setTexture(tex); setTexture(tex);
}, })
undefined, .catch(() => {
() => {
if (!disposed) setFailed(true); if (!disposed) setFailed(true);
} });
);
return () => { return () => {
disposed = true; disposed = true;
@@ -1675,32 +1672,13 @@ export default function VirtualGallery(props: Props) {
return; return;
} }
let cancelled = false; const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
const artistId = props.data.artist.id; setSyncStatus(
(async () => { withImg < initialPaintings.length
try { ? `${withImg} of ${initialPaintings.length} works have images`
setSyncStatus('Syncing images…'); : ''
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000)); );
await Promise.race([preloadArtistImages(artistId), timeout]); }, [hallKey, props.mode, initialPaintings]);
const fresh = await api.getArtist(artistId);
if (!cancelled) {
setPaintings(fresh.paintings);
setPeriods(fresh.periods);
const withImg = fresh.paintings.filter((p) => p.image_path || p.thumbnail_path).length;
setSyncStatus(
withImg < fresh.paintings.length
? `${withImg} of ${fresh.paintings.length} works have images`
: ''
);
}
} catch {
if (!cancelled) setSyncStatus('');
}
})();
return () => {
cancelled = true;
};
}, [hallKey, props.mode, initialPaintings.length, props.mode === 'artist' ? props.data.artist.id : null]);
const interiorStyle = useMemo( const interiorStyle = useMemo(
() => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined), () => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined),
@@ -2017,7 +1995,7 @@ export default function VirtualGallery(props: Props) {
intensity={interiorStyle ? 0.85 : 0.65} intensity={interiorStyle ? 0.85 : 0.65}
color={interiorStyle?.sunLight ?? '#fff8f0'} color={interiorStyle?.sunLight ?? '#fff8f0'}
castShadow castShadow
shadow-mapSize={[2048, 2048]} shadow-mapSize={[1024, 1024]}
/> />
<Suspense fallback={null}> <Suspense fallback={null}>
<Environment <Environment
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
const MAX_CONCURRENT = 6;
const queue: Array<() => void> = [];
let inFlight = 0;
function pumpQueue() {
while (inFlight < MAX_CONCURRENT && queue.length > 0) {
const next = queue.shift();
if (next) next();
}
}
function enqueueLoad(run: () => void) {
return new Promise<void>((resolve) => {
const task = () => {
inFlight++;
run();
resolve();
inFlight--;
pumpQueue();
};
queue.push(task);
pumpQueue();
});
}
/**
* Limits parallel image URL activation so hundreds of timeline portraits
* do not saturate the browser connection pool at once.
*/
export function useQueuedImageSrc(src: string | null | undefined): string | undefined {
const [activeSrc, setActiveSrc] = useState<string | undefined>(undefined);
useEffect(() => {
if (!src) {
setActiveSrc(undefined);
return;
}
let cancelled = false;
setActiveSrc(undefined);
enqueueLoad(() => {
if (!cancelled) setActiveSrc(src);
});
return () => {
cancelled = true;
};
}, [src]);
return activeSrc;
}
+12 -14
View File
@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
import Timeline from '../components/Timeline'; import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides'; import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands'; import MovementBands from '../components/MovementBands';
import VirtualGallery from '../components/VirtualGallery'; const VirtualGallery = lazy(() => import('../components/VirtualGallery'));
import PaintingDetailView from '../components/PaintingDetail'; import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio'; import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage'; import CheckupPage from '../pages/CheckupPage';
@@ -144,23 +144,16 @@ export default function HomePage() {
(async () => { (async () => {
try { try {
setLoading(true); setLoading(true);
const b = await api.getBounds(); const catalog = await api.getCatalogBootstrap();
const min = b.min_year ?? -800;
const max = b.max_year ?? 2025;
if (cancelled) return; if (cancelled) return;
const min = catalog.bounds.min_year ?? -800;
const max = catalog.bounds.max_year ?? 2025;
setBounds({ min, max }); setBounds({ min, max });
setViewStart(min); setViewStart(min);
setViewEnd(max); setViewEnd(max);
setTimelineData({ eras: catalog.eras, movements: catalog.movements });
const [timeline, artistList] = await Promise.all([ setArtists(catalog.artists);
api.getTimeline(min, max),
api.getTimelineArtists(),
]);
if (cancelled) return;
setTimelineData(timeline);
setArtists(artistList);
setError(null); setError(null);
} catch { } catch {
if (!cancelled) setError('Could not load gallery data. Is the server running?'); if (!cancelled) setError('Could not load gallery data. Is the server running?');
@@ -407,6 +400,7 @@ export default function HomePage() {
const handleArtistClick = async (artistId: number) => { const handleArtistClick = async (artistId: number) => {
try { try {
await api.preloadArtistImages(artistId).catch(() => undefined);
const data = await api.getArtist(artistId); const data = await api.getArtist(artistId);
openArtistGallery(artistId, data); openArtistGallery(artistId, data);
} catch { } catch {
@@ -605,6 +599,7 @@ export default function HomePage() {
aria-hidden={!galleryActive} aria-hidden={!galleryActive}
> >
{displayGallery.kind === 'artist' ? ( {displayGallery.kind === 'artist' ? (
<Suspense fallback={null}>
<VirtualGallery <VirtualGallery
key={`artist-${displayGallery.artistId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`} key={`artist-${displayGallery.artistId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
mode="artist" mode="artist"
@@ -622,7 +617,9 @@ export default function HomePage() {
}) })
} }
/> />
</Suspense>
) : ( ) : (
<Suspense fallback={null}>
<VirtualGallery <VirtualGallery
key={`movement-${displayGallery.movementId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`} key={`movement-${displayGallery.movementId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
mode="movement" mode="movement"
@@ -632,6 +629,7 @@ export default function HomePage() {
onPaintingClick={handlePaintingClick} onPaintingClick={handlePaintingClick}
onBack={() => setView({ type: 'timeline' })} onBack={() => setView({ type: 'timeline' })}
/> />
</Suspense>
)} )}
</div> </div>
)} )}
+8 -2
View File
@@ -31,8 +31,9 @@ export interface Artist {
movement_name?: string; movement_name?: string;
movement_color?: string; movement_color?: string;
portrait_path: string | null; portrait_path: string | null;
bio_short: string; portrait_thumb_path?: string | null;
bio_full: string; bio_short?: string;
bio_full?: string;
wikipedia_title: string; wikipedia_title: string;
century: number; century: number;
checkup_checked?: boolean; checkup_checked?: boolean;
@@ -133,6 +134,11 @@ export interface TimelineData {
movements: ArtMovement[]; movements: ArtMovement[];
} }
export interface CatalogBootstrap extends TimelineData {
bounds: YearBounds;
artists: Artist[];
}
export interface YearBounds { export interface YearBounds {
min_year: number; min_year: number;
max_year: number; max_year: number;
+39
View File
@@ -0,0 +1,39 @@
import * as THREE from 'three';
const MAX_CONCURRENT = 8;
const queue: Array<() => void> = [];
let inFlight = 0;
function pumpQueue() {
while (inFlight < MAX_CONCURRENT && queue.length > 0) {
const next = queue.shift();
if (next) next();
}
}
const loader = new THREE.TextureLoader();
loader.setCrossOrigin('anonymous');
export function loadTextureQueued(url: string): Promise<THREE.Texture> {
return new Promise((resolve, reject) => {
const task = () => {
inFlight++;
loader.load(
url,
(tex) => {
inFlight--;
pumpQueue();
resolve(tex);
},
undefined,
(err) => {
inFlight--;
pumpQueue();
reject(err);
}
);
};
queue.push(task);
pumpQueue();
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Some files were not shown because too many files have changed in this diff Show More