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`
|
## `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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 |