From 140881194886041e6d4d4ec755d78bd74488f29a Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Tue, 7 Jul 2026 12:31:56 +0300 Subject: [PATCH] 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 --- .../src/components/GalleryLoadingMarker.css | 61 ++++++++ .../src/components/GalleryLoadingMarker.tsx | 35 +++++ client/src/components/MovementBands.tsx | 56 ++++++- client/src/components/VirtualGallery.tsx | 143 +++++++++++++++--- client/src/pages/HomePage.tsx | 36 +++-- 5 files changed, 300 insertions(+), 31 deletions(-) create mode 100644 client/src/components/GalleryLoadingMarker.css create mode 100644 client/src/components/GalleryLoadingMarker.tsx 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 ( +
+
+

{message}

+
+ ); +} 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 && (

{syncStatus}

@@ -1982,11 +2080,18 @@ export default function VirtualGallery(props: Props) { {!showExitNav && (
{exitHint}
)} + {glLost && ( + + )} + @@ -1997,13 +2102,16 @@ export default function VirtualGallery(props: Props) { castShadow shadow-mapSize={[1024, 1024]} /> - - - - + + + + + + +
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} />