diff --git a/client/src/components/GalleryLoadingMarker.css b/client/src/components/GalleryLoadingMarker.css
new file mode 100644
index 0000000..23c241e
--- /dev/null
+++ b/client/src/components/GalleryLoadingMarker.css
@@ -0,0 +1,61 @@
+.gallery-loading-marker {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+ color: rgba(232, 213, 181, 0.92);
+ font-family: Georgia, serif;
+ font-size: 15px;
+ text-align: center;
+ pointer-events: none;
+}
+
+.gallery-loading-marker p {
+ margin: 0;
+}
+
+.gallery-loading-marker-spinner {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: 3px solid rgba(201, 169, 110, 0.22);
+ border-top-color: #c9a96e;
+ animation: gallery-loading-spin 0.85s linear infinite;
+}
+
+.gallery-loading-marker-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ background: rgba(10, 10, 20, 0.72);
+ backdrop-filter: blur(2px);
+}
+
+.gallery-loading-marker-banner {
+ position: absolute;
+ left: 50%;
+ bottom: 12px;
+ transform: translateX(-50%);
+ z-index: 12;
+ flex-direction: row;
+ gap: 10px;
+ padding: 8px 16px;
+ border-radius: 999px;
+ background: rgba(15, 15, 26, 0.88);
+ border: 1px solid rgba(201, 169, 110, 0.35);
+ font-size: 13px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
+}
+
+.gallery-loading-marker-banner .gallery-loading-marker-spinner {
+ width: 18px;
+ height: 18px;
+ border-width: 2px;
+}
+
+@keyframes gallery-loading-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/client/src/components/GalleryLoadingMarker.tsx b/client/src/components/GalleryLoadingMarker.tsx
new file mode 100644
index 0000000..794c006
--- /dev/null
+++ b/client/src/components/GalleryLoadingMarker.tsx
@@ -0,0 +1,35 @@
+import './GalleryLoadingMarker.css';
+
+interface Props {
+ message?: string;
+ /** Full-area overlay (dims background). */
+ overlay?: boolean;
+ /** Compact strip along the bottom — does not block interaction. */
+ banner?: boolean;
+ className?: string;
+}
+
+export default function GalleryLoadingMarker({
+ message = 'Loading…',
+ overlay = false,
+ banner = false,
+ className = '',
+}: Props) {
+ const modeClass = overlay
+ ? ' gallery-loading-marker-overlay'
+ : banner
+ ? ' gallery-loading-marker-banner'
+ : '';
+
+ return (
+
+ );
+}
diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx
index b90ee21..4340439 100644
--- a/client/src/components/MovementBands.tsx
+++ b/client/src/components/MovementBands.tsx
@@ -18,6 +18,7 @@ interface Props {
onArtistClick: (artistId: number) => void;
onMovementClick?: (movementId: number) => void;
onArtistHover?: (info: { birthYear: number; deathYear: number; color: string } | null) => void;
+ onPortraitsLoadingChange?: (loading: boolean) => void;
}
interface MovementLayout {
@@ -660,6 +661,7 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
onArtistHover,
onHoverStart,
onHoverEnd,
+ onPortraitLoadChange,
}: {
artist: Artist;
portraitRevision?: number;
@@ -680,9 +682,29 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
onArtistHover?: (lifespan: { birthYear: number; deathYear: number; color: string } | null) => void;
onHoverStart: () => void;
onHoverEnd: () => void;
+ onPortraitLoadChange?: (delta: number) => void;
}) {
const thumbSrc = portraitThumbUrl(artist, portraitRevision);
const queuedSrc = useQueuedImageSrc(thumbSrc);
+ const portraitPendingRef = useRef(false);
+
+ const settlePortraitLoad = useCallback(() => {
+ if (!portraitPendingRef.current) return;
+ portraitPendingRef.current = false;
+ onPortraitLoadChange?.(-1);
+ }, [onPortraitLoadChange]);
+
+ useEffect(() => {
+ if (!thumbSrc) return;
+ portraitPendingRef.current = true;
+ onPortraitLoadChange?.(1);
+ return () => {
+ if (portraitPendingRef.current) {
+ portraitPendingRef.current = false;
+ onPortraitLoadChange?.(-1);
+ }
+ };
+ }, [thumbSrc, onPortraitLoadChange]);
return (
@@ -729,12 +751,19 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
alt={artist.name}
loading="lazy"
decoding="async"
+ onLoad={settlePortraitLoad}
onError={(e) => {
+ settlePortraitLoad();
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
/>
) : (
-

+

)}
{artist.name}
@@ -754,8 +783,11 @@ export default function MovementBands({
onArtistClick,
onMovementClick,
onArtistHover,
+ onPortraitsLoadingChange,
}: Props) {
const canvasRef = useRef
(null);
+ const pendingPortraitsRef = useRef(0);
+ const [portraitsLoading, setPortraitsLoading] = useState(false);
const [panning, setPanning] = useState(false);
const [interacting, setInteracting] = useState(false);
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
@@ -769,6 +801,15 @@ export default function MovementBands({
viewRef.current = { viewStart, viewEnd };
onViewChangeRef.current = onViewChange;
+ const handlePortraitLoadChange = useCallback((delta: number) => {
+ pendingPortraitsRef.current += delta;
+ setPortraitsLoading(pendingPortraitsRef.current > 0);
+ }, []);
+
+ useEffect(() => {
+ onPortraitsLoadingChange?.(portraitsLoading);
+ }, [portraitsLoading, onPortraitsLoadingChange]);
+
const markInteracting = useCallback(() => {
setInteracting(true);
if (interactionTimer.current != null) {
@@ -860,15 +901,23 @@ export default function MovementBands({
return map;
}, [artists, viewStart, viewEnd]);
+ const movementsWithArtists = useMemo(() => {
+ const set = new Set();
+ for (const artist of artists) {
+ if (artist.movement_id) set.add(artist.movement_id);
+ }
+ return set;
+ }, [artists]);
+
const visibleMovements = useMemo(
() =>
movements.filter(
(m) =>
m.end_year >= viewStart &&
m.start_year <= viewEnd &&
- (artistsByMovement.get(m.id)?.length ?? 0) > 0
+ movementsWithArtists.has(m.id)
),
- [movements, viewStart, viewEnd, artistsByMovement]
+ [movements, viewStart, viewEnd, movementsWithArtists]
);
useLayoutEffect(() => {
@@ -1342,6 +1391,7 @@ export default function MovementBands({
onArtistHover={onArtistHover}
onHoverStart={() => setHoveredArtistKey(artistKey)}
onHoverEnd={() => setHoveredArtistKey(null)}
+ onPortraitLoadChange={handlePortraitLoadChange}
/>
);
})}
diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx
index cd6f024..967b1e6 100644
--- a/client/src/components/VirtualGallery.tsx
+++ b/client/src/components/VirtualGallery.tsx
@@ -1,4 +1,5 @@
-import { useRef, useState, useEffect, useMemo, Suspense, useCallback } from 'react';
+import { useRef, useState, useEffect, useMemo, Suspense, useCallback, createContext, useContext, Component } from 'react';
+import type { ReactNode } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Text, Environment } from '@react-three/drei';
import * as THREE from 'three';
@@ -11,7 +12,6 @@ import type {
MovementArtistGroup,
} from '../types';
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';
@@ -25,6 +25,36 @@ import {
type MovementHallLayout,
} from '../utils/movementHallLayout';
import './VirtualGallery.css';
+import GalleryLoadingMarker from './GalleryLoadingMarker';
+
+const GalleryTextureLoadContext = createContext<{
+ begin: () => void;
+ end: () => void;
+} | null>(null);
+
+/**
+ * Keeps a single failing subtree (e.g. the network-loaded HDR environment map)
+ * from unmounting the whole 3D scene and leaving a dark window.
+ */
+class SceneErrorBoundary extends Component<
+ { children: ReactNode; fallback?: ReactNode },
+ { hasError: boolean }
+> {
+ state = { hasError: false };
+
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: unknown) {
+ console.warn('Gallery scene subtree failed, continuing without it.', error);
+ }
+
+ render() {
+ if (this.state.hasError) return this.props.fallback ?? null;
+ return this.props.children;
+ }
+}
interface BaseGalleryProps {
imageRevisions?: Record;
@@ -551,6 +581,7 @@ function CanvasCover({
function usePaintingTexture(url: string | null) {
const [texture, setTexture] = useState(null);
const [failed, setFailed] = useState(!url);
+ const textureLoad = useContext(GalleryTextureLoadContext);
useEffect(() => {
if (!url) {
@@ -562,9 +593,21 @@ function usePaintingTexture(url: string | null) {
setFailed(false);
let disposed = false;
let loaded: THREE.Texture | null = null;
+ let settled = false;
+ const loader = new THREE.TextureLoader();
+ loader.setCrossOrigin('anonymous');
- loadTextureQueued(url)
- .then((tex) => {
+ const finish = () => {
+ if (settled) return;
+ settled = true;
+ textureLoad?.end();
+ };
+
+ textureLoad?.begin();
+ loader.load(
+ url,
+ (tex) => {
+ finish();
if (disposed) {
tex.dispose();
return;
@@ -579,17 +622,21 @@ function usePaintingTexture(url: string | null) {
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
setTexture(tex);
- })
- .catch(() => {
+ },
+ undefined,
+ () => {
+ finish();
if (!disposed) setFailed(true);
- });
+ }
+ );
return () => {
disposed = true;
+ finish();
loaded?.dispose();
setTexture(null);
};
- }, [url]);
+ }, [url, textureLoad]);
return { texture, failed };
}
@@ -1485,6 +1532,14 @@ function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
target.y = EYE_HEIGHT;
}
+function FrameloopSync({ active }: { active: boolean }) {
+ const { invalidate } = useThree();
+ useEffect(() => {
+ if (active) invalidate();
+ }, [active, invalidate]);
+ return null;
+}
+
function CameraController({
position,
target,
@@ -1643,7 +1698,7 @@ export default function VirtualGallery(props: Props) {
return [...props.data.paintings].sort(comparePaintingsChronological);
}
return props.data.paintings;
- }, [props]);
+ }, [props.mode, props.mode === 'movement' ? props.data.movement.id : props.data.artist.id, props.data.paintings]);
const initialPeriods = isMovement ? [] : props.data.periods;
const [paintings, setPaintings] = useState(initialPaintings);
@@ -1655,6 +1710,46 @@ export default function VirtualGallery(props: Props) {
const [nearExit, setNearExit] = useState(false);
const [nearPassage, setNearPassage] = useState(false);
const [isLooking, setIsLooking] = useState(false);
+ const [texturesPending, setTexturesPending] = useState(0);
+ const [glEpoch, setGlEpoch] = useState(0);
+ const [glLost, setGlLost] = useState(false);
+
+ const textureLoad = useMemo(
+ () => ({
+ begin: () => setTexturesPending((n) => n + 1),
+ end: () => setTexturesPending((n) => Math.max(0, n - 1)),
+ }),
+ []
+ );
+
+ useEffect(() => {
+ setTexturesPending(0);
+ }, [hallKey, glEpoch]);
+
+ const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
+ const canvas = state.gl.domElement;
+ // A freshly created canvas has a healthy context, so clear any lingering
+ // "restoring" state from a previous loss/remount.
+ setGlLost(false);
+ const onLost = (event: Event) => {
+ // Prevent the default so the browser can restore the context, and
+ // force a clean remount to obtain a fresh WebGL context if it does not.
+ event.preventDefault();
+ setGlLost(true);
+ window.setTimeout(() => {
+ setGlLost((stillLost) => {
+ if (stillLost) setGlEpoch((n) => n + 1);
+ return stillLost;
+ });
+ }, 600);
+ };
+ const onRestored = () => {
+ setGlLost(false);
+ setGlEpoch((n) => n + 1);
+ };
+ canvas.addEventListener('webglcontextlost', onLost as EventListener, false);
+ canvas.addEventListener('webglcontextrestored', onRestored as EventListener, false);
+ }, []);
useEffect(() => {
setPaintings(initialPaintings);
@@ -1974,7 +2069,10 @@ export default function VirtualGallery(props: Props) {
onPointerLeave={endCanvasDrag}
onPointerCancel={endCanvasDrag}
>
- {syncStatus && (
+ {active && texturesPending > 0 && (
+
+ )}
+ {syncStatus && texturesPending === 0 && (
@@ -1982,11 +2080,18 @@ export default function VirtualGallery(props: Props) {
{!showExitNav && (
{exitHint}
)}
+ {glLost && (
+
+ )}
diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx
index fb2d21d..d970c02 100644
--- a/client/src/pages/HomePage.tsx
+++ b/client/src/pages/HomePage.tsx
@@ -1,12 +1,13 @@
-import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands';
-const VirtualGallery = lazy(() => import('../components/VirtualGallery'));
+import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
import CuratorLoginModal from '../components/CuratorLoginModal';
+import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import '../components/CuratorLoginModal.css';
import { useAuth } from '../context/AuthContext';
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
@@ -108,6 +109,8 @@ export default function HomePage() {
const [timelineData, setTimelineData] = useState({ eras: [], movements: [] });
const [artists, setArtists] = useState([]);
const [loading, setLoading] = useState(true);
+ const [portraitsLoading, setPortraitsLoading] = useState(false);
+ const [galleryEntryLoading, setGalleryEntryLoading] = useState(null);
const [error, setError] = useState(null);
const [detailArtistPaintings, setDetailArtistPaintings] = useState([]);
const [imageRevisions, setImageRevisions] = useState>({});
@@ -399,21 +402,27 @@ export default function HomePage() {
}, []);
const handleArtistClick = async (artistId: number) => {
+ setGalleryEntryLoading('Opening artist gallery…');
try {
await api.preloadArtistImages(artistId).catch(() => undefined);
const data = await api.getArtist(artistId);
openArtistGallery(artistId, data);
} catch {
setError('Failed to load artist gallery.');
+ } finally {
+ setGalleryEntryLoading(null);
}
};
const handleMovementClick = async (movementId: number) => {
+ setGalleryEntryLoading('Opening movement gallery…');
try {
const data = await api.getMovementGallery(movementId);
openMovementGallery(movementId, data);
} catch {
setError('Failed to load movement gallery.');
+ } finally {
+ setGalleryEntryLoading(null);
}
};
@@ -599,9 +608,8 @@ export default function HomePage() {
aria-hidden={!galleryActive}
>
{displayGallery.kind === 'artist' ? (
-
-
) : (
-
setView({ type: 'timeline' })}
/>
-
)}
)}
@@ -787,6 +792,16 @@ export default function HomePage() {
+ {loading && (
+
+ )}
+ {galleryEntryLoading && (
+
+ )}
+ {portraitsLoading && !loading && !galleryEntryLoading && (
+
+ )}
+
{error}
}
- {loading && timelineData.movements.length === 0 ? (
- Loading art history...
- ) : (
+ {!loading && (
<>
@@ -817,6 +830,7 @@ export default function HomePage() {
onArtistClick={handleArtistClick}
onMovementClick={handleMovementClick}
onArtistHover={setHoveredLifespan}
+ onPortraitsLoadingChange={setPortraitsLoading}
/>
>