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.
@@ -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`
|
||||
|
||||
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 |
|
||||
| `end` | int | Only artists born before this year |
|
||||
| `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`.
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ How catalog content, biographies, and artwork files enter the system.
|
||||
|
||||
```text
|
||||
data/images/
|
||||
├── portraits/ # Artist headshots
|
||||
│ └── Claude_Monet.jpg
|
||||
├── portraits/ # Artist headshots (display ~900px wide)
|
||||
│ ├── Claude_Monet.jpg
|
||||
│ └── thumbs/ # Timeline thumbnails (~256px)
|
||||
│ └── Claude_Monet_thumb.jpg
|
||||
└── paintings/
|
||||
├── Claude_Monet_Water_Lilies.jpg
|
||||
└── 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 |
|
||||
| `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` |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -154,6 +157,8 @@ Typical result on a full clone: ~1,000+ paintings linked from ~1,000 on-disk fil
|
||||
|
||||
## 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`:
|
||||
|
||||
1. For each artist, checks `data/images/portraits/{Artist}.jpg` (or other extensions) and sets `portrait_path` when a local file exists.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
TimelineData,
|
||||
CatalogBootstrap,
|
||||
YearBounds,
|
||||
Artist,
|
||||
ArtistDetail,
|
||||
@@ -62,13 +63,25 @@ export function portraitUrl(path: string | null | undefined, revision?: number):
|
||||
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: {
|
||||
thumbnail_path?: string | null;
|
||||
image_path?: string | null;
|
||||
}): string | null {
|
||||
if (painting.image_path) return `/images/${painting.image_path}`;
|
||||
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
|
||||
if (painting.image_path) return `/images/${painting.image_path}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -202,6 +215,14 @@ export interface PaintingCheckupData {
|
||||
export const api = {
|
||||
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) =>
|
||||
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
|
||||
|
||||
@@ -345,6 +366,8 @@ export const api = {
|
||||
}
|
||||
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
||||
}),
|
||||
|
||||
preloadArtistImages,
|
||||
};
|
||||
|
||||
export function debugImageProxyUrl(
|
||||
|
||||
@@ -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 { portraitUrl } from '../api/client';
|
||||
import { portraitThumbUrl } from '../api/client';
|
||||
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import './MovementBands.css';
|
||||
@@ -639,6 +640,108 @@ function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
|
||||
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({
|
||||
movements,
|
||||
artists,
|
||||
@@ -674,7 +777,7 @@ export default function MovementBands({
|
||||
interactionTimer.current = window.setTimeout(() => {
|
||||
interactionTimer.current = null;
|
||||
setInteracting(false);
|
||||
}, 120);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
@@ -1216,60 +1319,30 @@ export default function MovementBands({
|
||||
const birthLabel = artist.birth_year != null ? artist.birth_year : '?';
|
||||
const deathLabel = artist.death_year != null ? artist.death_year : '?';
|
||||
const artistKey = `${layout.movement.id}-${artist.id}`;
|
||||
const isHovered = hoveredArtistKey === artistKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
<MovementArtistPortrait
|
||||
key={artistKey}
|
||||
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={() => {
|
||||
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';
|
||||
}}
|
||||
artist={artist}
|
||||
portraitRevision={portraitRevisions?.[artist.id]}
|
||||
lineLeft={lineLeft}
|
||||
lineWidth={lineWidth}
|
||||
portraitX={portraitX}
|
||||
y={y}
|
||||
layoutHeight={layoutHeight}
|
||||
color={color}
|
||||
colorIndex={colorIndex}
|
||||
isHovered={hoveredArtistKey === artistKey}
|
||||
birthLabel={birthLabel}
|
||||
deathLabel={deathLabel}
|
||||
movementName={layout.movement.name}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
onArtistClick={onArtistClick}
|
||||
onArtistHover={onArtistHover}
|
||||
onHoverStart={() => setHoveredArtistKey(artistKey)}
|
||||
onHoverEnd={() => setHoveredArtistKey(null)}
|
||||
/>
|
||||
<span className="artist-name">{artist.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -130,6 +130,8 @@ function InfluenceCard({
|
||||
<img
|
||||
src={imageUrl(inf.artist_portrait)}
|
||||
alt={artistName}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
@@ -164,6 +166,8 @@ function InfluenceCard({
|
||||
<img
|
||||
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path }) ?? '/placeholder-art.svg'}
|
||||
alt={inf.title || 'Painting'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
|
||||
}}
|
||||
|
||||
@@ -10,7 +10,8 @@ import type {
|
||||
ArtistNavigation,
|
||||
MovementArtistGroup,
|
||||
} 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 { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||
@@ -561,11 +562,9 @@ function usePaintingTexture(url: string | null) {
|
||||
setFailed(false);
|
||||
let disposed = false;
|
||||
let loaded: THREE.Texture | null = null;
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.setCrossOrigin('anonymous');
|
||||
loader.load(
|
||||
url,
|
||||
(tex) => {
|
||||
|
||||
loadTextureQueued(url)
|
||||
.then((tex) => {
|
||||
if (disposed) {
|
||||
tex.dispose();
|
||||
return;
|
||||
@@ -580,12 +579,10 @@ function usePaintingTexture(url: string | null) {
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.anisotropy = 4;
|
||||
setTexture(tex);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) setFailed(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
@@ -1675,32 +1672,13 @@ export default function VirtualGallery(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const artistId = props.data.artist.id;
|
||||
(async () => {
|
||||
try {
|
||||
setSyncStatus('Syncing images…');
|
||||
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000));
|
||||
await Promise.race([preloadArtistImages(artistId), timeout]);
|
||||
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;
|
||||
const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
|
||||
setSyncStatus(
|
||||
withImg < fresh.paintings.length
|
||||
? `${withImg} of ${fresh.paintings.length} works have images`
|
||||
withImg < initialPaintings.length
|
||||
? `${withImg} of ${initialPaintings.length} works have images`
|
||||
: ''
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setSyncStatus('');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hallKey, props.mode, initialPaintings.length, props.mode === 'artist' ? props.data.artist.id : null]);
|
||||
}, [hallKey, props.mode, initialPaintings]);
|
||||
|
||||
const interiorStyle = useMemo(
|
||||
() => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined),
|
||||
@@ -2017,7 +1995,7 @@ export default function VirtualGallery(props: Props) {
|
||||
intensity={interiorStyle ? 0.85 : 0.65}
|
||||
color={interiorStyle?.sunLight ?? '#fff8f0'}
|
||||
castShadow
|
||||
shadow-mapSize={[2048, 2048]}
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<Environment
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
import MovementBands from '../components/MovementBands';
|
||||
import VirtualGallery from '../components/VirtualGallery';
|
||||
const VirtualGallery = lazy(() => import('../components/VirtualGallery'));
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
@@ -144,23 +144,16 @@ export default function HomePage() {
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const b = await api.getBounds();
|
||||
const min = b.min_year ?? -800;
|
||||
const max = b.max_year ?? 2025;
|
||||
const catalog = await api.getCatalogBootstrap();
|
||||
if (cancelled) return;
|
||||
|
||||
const min = catalog.bounds.min_year ?? -800;
|
||||
const max = catalog.bounds.max_year ?? 2025;
|
||||
setBounds({ min, max });
|
||||
setViewStart(min);
|
||||
setViewEnd(max);
|
||||
|
||||
const [timeline, artistList] = await Promise.all([
|
||||
api.getTimeline(min, max),
|
||||
api.getTimelineArtists(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
setTimelineData(timeline);
|
||||
setArtists(artistList);
|
||||
setTimelineData({ eras: catalog.eras, movements: catalog.movements });
|
||||
setArtists(catalog.artists);
|
||||
setError(null);
|
||||
} catch {
|
||||
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) => {
|
||||
try {
|
||||
await api.preloadArtistImages(artistId).catch(() => undefined);
|
||||
const data = await api.getArtist(artistId);
|
||||
openArtistGallery(artistId, data);
|
||||
} catch {
|
||||
@@ -605,6 +599,7 @@ export default function HomePage() {
|
||||
aria-hidden={!galleryActive}
|
||||
>
|
||||
{displayGallery.kind === 'artist' ? (
|
||||
<Suspense fallback={null}>
|
||||
<VirtualGallery
|
||||
key={`artist-${displayGallery.artistId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
|
||||
mode="artist"
|
||||
@@ -622,7 +617,9 @@ export default function HomePage() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={null}>
|
||||
<VirtualGallery
|
||||
key={`movement-${displayGallery.movementId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
|
||||
mode="movement"
|
||||
@@ -632,6 +629,7 @@ export default function HomePage() {
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,8 +31,9 @@ export interface Artist {
|
||||
movement_name?: string;
|
||||
movement_color?: string;
|
||||
portrait_path: string | null;
|
||||
bio_short: string;
|
||||
bio_full: string;
|
||||
portrait_thumb_path?: string | null;
|
||||
bio_short?: string;
|
||||
bio_full?: string;
|
||||
wikipedia_title: string;
|
||||
century: number;
|
||||
checkup_checked?: boolean;
|
||||
@@ -133,6 +134,11 @@ export interface TimelineData {
|
||||
movements: ArtMovement[];
|
||||
}
|
||||
|
||||
export interface CatalogBootstrap extends TimelineData {
|
||||
bounds: YearBounds;
|
||||
artists: Artist[];
|
||||
}
|
||||
|
||||
export interface YearBounds {
|
||||
min_year: number;
|
||||
max_year: number;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 45 KiB |