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:
@@ -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';
|
||||
}}
|
||||
/>
|
||||
<span className="artist-name">{artist.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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;
|
||||
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 withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
|
||||
setSyncStatus(
|
||||
withImg < initialPaintings.length
|
||||
? `${withImg} of ${initialPaintings.length} works have images`
|
||||
: ''
|
||||
);
|
||||
}, [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
|
||||
|
||||
Reference in New Issue
Block a user