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
+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 { 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>
+4
View File
@@ -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';
}}
+16 -38
View File
@@ -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