Add gallery loading indicators and recover from WebGL context loss.

- Show loading markers while the catalog, portrait thumbnails, and 3D
  halls load so users know loading is still in progress.
- Recover the 3D hall from a lost WebGL context by remounting the canvas
  with a fresh context instead of leaving a permanent dark window, and
  guard the HDR environment map behind an error boundary.
- Show movement streams whose span overlaps the view even when their
  artists lived outside the visible time range.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-07 12:31:56 +03:00
co-authored by Cursor
parent 99b8559607
commit 1408811948
5 changed files with 300 additions and 31 deletions
+126 -17
View File
@@ -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<number, number>;
@@ -551,6 +581,7 @@ function CanvasCover({
function usePaintingTexture(url: string | null) {
const [texture, setTexture] = useState<THREE.Texture | null>(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 && (
<GalleryLoadingMarker overlay message="Loading paintings…" />
)}
{syncStatus && texturesPending === 0 && (
<div className="gallery-loading-overlay gallery-sync-badge">
<p>{syncStatus}</p>
</div>
@@ -1982,11 +2080,18 @@ export default function VirtualGallery(props: Props) {
{!showExitNav && (
<div className="gallery-exit-hint">{exitHint}</div>
)}
{glLost && (
<GalleryLoadingMarker overlay message="Restoring gallery…" />
)}
<Canvas
key={`${hallKey}-${glEpoch}`}
shadows
frameloop={active ? 'always' : 'never'}
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
onCreated={handleCanvasCreated}
>
<FrameloopSync active={active} />
<color attach="background" args={[sceneBackground]} />
<fog attach="fog" args={[sceneFog, 18, fogFar]} />
<ambientLight intensity={ambientIntensity} />
@@ -1997,13 +2102,16 @@ export default function VirtualGallery(props: Props) {
castShadow
shadow-mapSize={[1024, 1024]}
/>
<Suspense fallback={null}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
</Suspense>
<ArtistHall
<SceneErrorBoundary fallback={null}>
<Suspense fallback={null}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
</Suspense>
</SceneErrorBoundary>
<GalleryTextureLoadContext.Provider value={textureLoad}>
<ArtistHall
layout={layout}
hallTitle={hallTitle}
hallSubtitle={hallSubtitle}
@@ -2020,6 +2128,7 @@ export default function VirtualGallery(props: Props) {
onNextHall={goToNextHall}
nearPassage={nearPassage}
/>
</GalleryTextureLoadContext.Provider>
<CameraController position={camPos} target={camTarget} />
</Canvas>
</div>