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:
co-authored by
Cursor
parent
99b8559607
commit
1408811948
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`gallery-loading-marker${modeClass}${className ? ` ${className}` : ''}`}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<div className="gallery-loading-marker-spinner" aria-hidden />
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ interface Props {
|
|||||||
onArtistClick: (artistId: number) => void;
|
onArtistClick: (artistId: number) => void;
|
||||||
onMovementClick?: (movementId: number) => void;
|
onMovementClick?: (movementId: number) => void;
|
||||||
onArtistHover?: (info: { birthYear: number; deathYear: number; color: string } | null) => void;
|
onArtistHover?: (info: { birthYear: number; deathYear: number; color: string } | null) => void;
|
||||||
|
onPortraitsLoadingChange?: (loading: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MovementLayout {
|
interface MovementLayout {
|
||||||
@@ -660,6 +661,7 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
|
|||||||
onArtistHover,
|
onArtistHover,
|
||||||
onHoverStart,
|
onHoverStart,
|
||||||
onHoverEnd,
|
onHoverEnd,
|
||||||
|
onPortraitLoadChange,
|
||||||
}: {
|
}: {
|
||||||
artist: Artist;
|
artist: Artist;
|
||||||
portraitRevision?: number;
|
portraitRevision?: number;
|
||||||
@@ -680,9 +682,29 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
|
|||||||
onArtistHover?: (lifespan: { birthYear: number; deathYear: number; color: string } | null) => void;
|
onArtistHover?: (lifespan: { birthYear: number; deathYear: number; color: string } | null) => void;
|
||||||
onHoverStart: () => void;
|
onHoverStart: () => void;
|
||||||
onHoverEnd: () => void;
|
onHoverEnd: () => void;
|
||||||
|
onPortraitLoadChange?: (delta: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const thumbSrc = portraitThumbUrl(artist, portraitRevision);
|
const thumbSrc = portraitThumbUrl(artist, portraitRevision);
|
||||||
const queuedSrc = useQueuedImageSrc(thumbSrc);
|
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 (
|
return (
|
||||||
<div className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}>
|
<div className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}>
|
||||||
@@ -729,12 +751,19 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
|
|||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onLoad={settlePortraitLoad}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
settlePortraitLoad();
|
||||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img src="/placeholder-portrait.svg" alt="" aria-hidden />
|
<img
|
||||||
|
src="/placeholder-portrait.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
onLoad={settlePortraitLoad}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="artist-name">{artist.name}</span>
|
<span className="artist-name">{artist.name}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -754,8 +783,11 @@ export default function MovementBands({
|
|||||||
onArtistClick,
|
onArtistClick,
|
||||||
onMovementClick,
|
onMovementClick,
|
||||||
onArtistHover,
|
onArtistHover,
|
||||||
|
onPortraitsLoadingChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pendingPortraitsRef = useRef(0);
|
||||||
|
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||||
const [panning, setPanning] = useState(false);
|
const [panning, setPanning] = useState(false);
|
||||||
const [interacting, setInteracting] = useState(false);
|
const [interacting, setInteracting] = useState(false);
|
||||||
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
||||||
@@ -769,6 +801,15 @@ export default function MovementBands({
|
|||||||
viewRef.current = { viewStart, viewEnd };
|
viewRef.current = { viewStart, viewEnd };
|
||||||
onViewChangeRef.current = onViewChange;
|
onViewChangeRef.current = onViewChange;
|
||||||
|
|
||||||
|
const handlePortraitLoadChange = useCallback((delta: number) => {
|
||||||
|
pendingPortraitsRef.current += delta;
|
||||||
|
setPortraitsLoading(pendingPortraitsRef.current > 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPortraitsLoadingChange?.(portraitsLoading);
|
||||||
|
}, [portraitsLoading, onPortraitsLoadingChange]);
|
||||||
|
|
||||||
const markInteracting = useCallback(() => {
|
const markInteracting = useCallback(() => {
|
||||||
setInteracting(true);
|
setInteracting(true);
|
||||||
if (interactionTimer.current != null) {
|
if (interactionTimer.current != null) {
|
||||||
@@ -860,15 +901,23 @@ export default function MovementBands({
|
|||||||
return map;
|
return map;
|
||||||
}, [artists, viewStart, viewEnd]);
|
}, [artists, viewStart, viewEnd]);
|
||||||
|
|
||||||
|
const movementsWithArtists = useMemo(() => {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const artist of artists) {
|
||||||
|
if (artist.movement_id) set.add(artist.movement_id);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [artists]);
|
||||||
|
|
||||||
const visibleMovements = useMemo(
|
const visibleMovements = useMemo(
|
||||||
() =>
|
() =>
|
||||||
movements.filter(
|
movements.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.end_year >= viewStart &&
|
m.end_year >= viewStart &&
|
||||||
m.start_year <= viewEnd &&
|
m.start_year <= viewEnd &&
|
||||||
(artistsByMovement.get(m.id)?.length ?? 0) > 0
|
movementsWithArtists.has(m.id)
|
||||||
),
|
),
|
||||||
[movements, viewStart, viewEnd, artistsByMovement]
|
[movements, viewStart, viewEnd, movementsWithArtists]
|
||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -1342,6 +1391,7 @@ export default function MovementBands({
|
|||||||
onArtistHover={onArtistHover}
|
onArtistHover={onArtistHover}
|
||||||
onHoverStart={() => setHoveredArtistKey(artistKey)}
|
onHoverStart={() => setHoveredArtistKey(artistKey)}
|
||||||
onHoverEnd={() => setHoveredArtistKey(null)}
|
onHoverEnd={() => setHoveredArtistKey(null)}
|
||||||
|
onPortraitLoadChange={handlePortraitLoadChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
import { Text, Environment } from '@react-three/drei';
|
import { Text, Environment } from '@react-three/drei';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
@@ -11,7 +12,6 @@ import type {
|
|||||||
MovementArtistGroup,
|
MovementArtistGroup,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
|
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
|
||||||
import { loadTextureQueued } from '../utils/textureLoadQueue';
|
|
||||||
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||||
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
||||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||||
@@ -25,6 +25,36 @@ import {
|
|||||||
type MovementHallLayout,
|
type MovementHallLayout,
|
||||||
} from '../utils/movementHallLayout';
|
} from '../utils/movementHallLayout';
|
||||||
import './VirtualGallery.css';
|
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 {
|
interface BaseGalleryProps {
|
||||||
imageRevisions?: Record<number, number>;
|
imageRevisions?: Record<number, number>;
|
||||||
@@ -551,6 +581,7 @@ function CanvasCover({
|
|||||||
function usePaintingTexture(url: string | null) {
|
function usePaintingTexture(url: string | null) {
|
||||||
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
||||||
const [failed, setFailed] = useState(!url);
|
const [failed, setFailed] = useState(!url);
|
||||||
|
const textureLoad = useContext(GalleryTextureLoadContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -562,9 +593,21 @@ function usePaintingTexture(url: string | null) {
|
|||||||
setFailed(false);
|
setFailed(false);
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let loaded: THREE.Texture | null = null;
|
let loaded: THREE.Texture | null = null;
|
||||||
|
let settled = false;
|
||||||
|
const loader = new THREE.TextureLoader();
|
||||||
|
loader.setCrossOrigin('anonymous');
|
||||||
|
|
||||||
loadTextureQueued(url)
|
const finish = () => {
|
||||||
.then((tex) => {
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
textureLoad?.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
textureLoad?.begin();
|
||||||
|
loader.load(
|
||||||
|
url,
|
||||||
|
(tex) => {
|
||||||
|
finish();
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
tex.dispose();
|
tex.dispose();
|
||||||
return;
|
return;
|
||||||
@@ -579,17 +622,21 @@ function usePaintingTexture(url: string | null) {
|
|||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
tex.anisotropy = 4;
|
tex.anisotropy = 4;
|
||||||
setTexture(tex);
|
setTexture(tex);
|
||||||
})
|
},
|
||||||
.catch(() => {
|
undefined,
|
||||||
|
() => {
|
||||||
|
finish();
|
||||||
if (!disposed) setFailed(true);
|
if (!disposed) setFailed(true);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
finish();
|
||||||
loaded?.dispose();
|
loaded?.dispose();
|
||||||
setTexture(null);
|
setTexture(null);
|
||||||
};
|
};
|
||||||
}, [url]);
|
}, [url, textureLoad]);
|
||||||
|
|
||||||
return { texture, failed };
|
return { texture, failed };
|
||||||
}
|
}
|
||||||
@@ -1485,6 +1532,14 @@ function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
|
|||||||
target.y = EYE_HEIGHT;
|
target.y = EYE_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FrameloopSync({ active }: { active: boolean }) {
|
||||||
|
const { invalidate } = useThree();
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) invalidate();
|
||||||
|
}, [active, invalidate]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function CameraController({
|
function CameraController({
|
||||||
position,
|
position,
|
||||||
target,
|
target,
|
||||||
@@ -1643,7 +1698,7 @@ export default function VirtualGallery(props: Props) {
|
|||||||
return [...props.data.paintings].sort(comparePaintingsChronological);
|
return [...props.data.paintings].sort(comparePaintingsChronological);
|
||||||
}
|
}
|
||||||
return props.data.paintings;
|
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 initialPeriods = isMovement ? [] : props.data.periods;
|
||||||
|
|
||||||
const [paintings, setPaintings] = useState(initialPaintings);
|
const [paintings, setPaintings] = useState(initialPaintings);
|
||||||
@@ -1655,6 +1710,46 @@ export default function VirtualGallery(props: Props) {
|
|||||||
const [nearExit, setNearExit] = useState(false);
|
const [nearExit, setNearExit] = useState(false);
|
||||||
const [nearPassage, setNearPassage] = useState(false);
|
const [nearPassage, setNearPassage] = useState(false);
|
||||||
const [isLooking, setIsLooking] = 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(() => {
|
useEffect(() => {
|
||||||
setPaintings(initialPaintings);
|
setPaintings(initialPaintings);
|
||||||
@@ -1974,7 +2069,10 @@ export default function VirtualGallery(props: Props) {
|
|||||||
onPointerLeave={endCanvasDrag}
|
onPointerLeave={endCanvasDrag}
|
||||||
onPointerCancel={endCanvasDrag}
|
onPointerCancel={endCanvasDrag}
|
||||||
>
|
>
|
||||||
{syncStatus && (
|
{active && texturesPending > 0 && (
|
||||||
|
<GalleryLoadingMarker overlay message="Loading paintings…" />
|
||||||
|
)}
|
||||||
|
{syncStatus && texturesPending === 0 && (
|
||||||
<div className="gallery-loading-overlay gallery-sync-badge">
|
<div className="gallery-loading-overlay gallery-sync-badge">
|
||||||
<p>{syncStatus}</p>
|
<p>{syncStatus}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1982,11 +2080,18 @@ export default function VirtualGallery(props: Props) {
|
|||||||
{!showExitNav && (
|
{!showExitNav && (
|
||||||
<div className="gallery-exit-hint">{exitHint}</div>
|
<div className="gallery-exit-hint">{exitHint}</div>
|
||||||
)}
|
)}
|
||||||
|
{glLost && (
|
||||||
|
<GalleryLoadingMarker overlay message="Restoring gallery…" />
|
||||||
|
)}
|
||||||
<Canvas
|
<Canvas
|
||||||
|
key={`${hallKey}-${glEpoch}`}
|
||||||
shadows
|
shadows
|
||||||
frameloop={active ? 'always' : 'never'}
|
frameloop={active ? 'always' : 'never'}
|
||||||
|
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
|
||||||
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
||||||
|
onCreated={handleCanvasCreated}
|
||||||
>
|
>
|
||||||
|
<FrameloopSync active={active} />
|
||||||
<color attach="background" args={[sceneBackground]} />
|
<color attach="background" args={[sceneBackground]} />
|
||||||
<fog attach="fog" args={[sceneFog, 18, fogFar]} />
|
<fog attach="fog" args={[sceneFog, 18, fogFar]} />
|
||||||
<ambientLight intensity={ambientIntensity} />
|
<ambientLight intensity={ambientIntensity} />
|
||||||
@@ -1997,12 +2102,15 @@ export default function VirtualGallery(props: Props) {
|
|||||||
castShadow
|
castShadow
|
||||||
shadow-mapSize={[1024, 1024]}
|
shadow-mapSize={[1024, 1024]}
|
||||||
/>
|
/>
|
||||||
|
<SceneErrorBoundary fallback={null}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Environment
|
<Environment
|
||||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</SceneErrorBoundary>
|
||||||
|
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
||||||
<ArtistHall
|
<ArtistHall
|
||||||
layout={layout}
|
layout={layout}
|
||||||
hallTitle={hallTitle}
|
hallTitle={hallTitle}
|
||||||
@@ -2020,6 +2128,7 @@ export default function VirtualGallery(props: Props) {
|
|||||||
onNextHall={goToNextHall}
|
onNextHall={goToNextHall}
|
||||||
nearPassage={nearPassage}
|
nearPassage={nearPassage}
|
||||||
/>
|
/>
|
||||||
|
</GalleryTextureLoadContext.Provider>
|
||||||
<CameraController position={camPos} target={camTarget} />
|
<CameraController position={camPos} target={camTarget} />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 Timeline from '../components/Timeline';
|
||||||
import TimelineEventGuides from '../components/TimelineEventGuides';
|
import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||||
import MovementBands from '../components/MovementBands';
|
import MovementBands from '../components/MovementBands';
|
||||||
const VirtualGallery = lazy(() => import('../components/VirtualGallery'));
|
import VirtualGallery from '../components/VirtualGallery';
|
||||||
import PaintingDetailView from '../components/PaintingDetail';
|
import PaintingDetailView from '../components/PaintingDetail';
|
||||||
import ArtistBio from '../components/ArtistBio';
|
import ArtistBio from '../components/ArtistBio';
|
||||||
import CheckupPage from '../pages/CheckupPage';
|
import CheckupPage from '../pages/CheckupPage';
|
||||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||||
|
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||||
import '../components/CuratorLoginModal.css';
|
import '../components/CuratorLoginModal.css';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||||
@@ -108,6 +109,8 @@ export default function HomePage() {
|
|||||||
const [timelineData, setTimelineData] = useState<TimelineData>({ eras: [], movements: [] });
|
const [timelineData, setTimelineData] = useState<TimelineData>({ eras: [], movements: [] });
|
||||||
const [artists, setArtists] = useState<Artist[]>([]);
|
const [artists, setArtists] = useState<Artist[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||||
|
const [galleryEntryLoading, setGalleryEntryLoading] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
||||||
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||||
@@ -399,21 +402,27 @@ export default function HomePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleArtistClick = async (artistId: number) => {
|
const handleArtistClick = async (artistId: number) => {
|
||||||
|
setGalleryEntryLoading('Opening artist gallery…');
|
||||||
try {
|
try {
|
||||||
await api.preloadArtistImages(artistId).catch(() => undefined);
|
await api.preloadArtistImages(artistId).catch(() => undefined);
|
||||||
const data = await api.getArtist(artistId);
|
const data = await api.getArtist(artistId);
|
||||||
openArtistGallery(artistId, data);
|
openArtistGallery(artistId, data);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load artist gallery.');
|
setError('Failed to load artist gallery.');
|
||||||
|
} finally {
|
||||||
|
setGalleryEntryLoading(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMovementClick = async (movementId: number) => {
|
const handleMovementClick = async (movementId: number) => {
|
||||||
|
setGalleryEntryLoading('Opening movement gallery…');
|
||||||
try {
|
try {
|
||||||
const data = await api.getMovementGallery(movementId);
|
const data = await api.getMovementGallery(movementId);
|
||||||
openMovementGallery(movementId, data);
|
openMovementGallery(movementId, data);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load movement gallery.');
|
setError('Failed to load movement gallery.');
|
||||||
|
} finally {
|
||||||
|
setGalleryEntryLoading(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -599,9 +608,8 @@ export default function HomePage() {
|
|||||||
aria-hidden={!galleryActive}
|
aria-hidden={!galleryActive}
|
||||||
>
|
>
|
||||||
{displayGallery.kind === 'artist' ? (
|
{displayGallery.kind === 'artist' ? (
|
||||||
<Suspense fallback={null}>
|
|
||||||
<VirtualGallery
|
<VirtualGallery
|
||||||
key={`artist-${displayGallery.artistId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
|
key={`artist-${displayGallery.artistId}-${galleryRevision}`}
|
||||||
mode="artist"
|
mode="artist"
|
||||||
data={displayGallery.data}
|
data={displayGallery.data}
|
||||||
imageRevisions={imageRevisions}
|
imageRevisions={imageRevisions}
|
||||||
@@ -617,11 +625,9 @@ export default function HomePage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
|
||||||
) : (
|
) : (
|
||||||
<Suspense fallback={null}>
|
|
||||||
<VirtualGallery
|
<VirtualGallery
|
||||||
key={`movement-${displayGallery.movementId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
|
key={`movement-${displayGallery.movementId}-${galleryRevision}`}
|
||||||
mode="movement"
|
mode="movement"
|
||||||
data={displayGallery.data}
|
data={displayGallery.data}
|
||||||
imageRevisions={imageRevisions}
|
imageRevisions={imageRevisions}
|
||||||
@@ -629,7 +635,6 @@ export default function HomePage() {
|
|||||||
onPaintingClick={handlePaintingClick}
|
onPaintingClick={handlePaintingClick}
|
||||||
onBack={() => setView({ type: 'timeline' })}
|
onBack={() => setView({ type: 'timeline' })}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -787,6 +792,16 @@ export default function HomePage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="home-timeline-stack">
|
<div className="home-timeline-stack">
|
||||||
|
{loading && (
|
||||||
|
<GalleryLoadingMarker overlay message="Loading art history…" />
|
||||||
|
)}
|
||||||
|
{galleryEntryLoading && (
|
||||||
|
<GalleryLoadingMarker overlay message={galleryEntryLoading} />
|
||||||
|
)}
|
||||||
|
{portraitsLoading && !loading && !galleryEntryLoading && (
|
||||||
|
<GalleryLoadingMarker banner message="Loading portraits…" />
|
||||||
|
)}
|
||||||
|
|
||||||
<Timeline
|
<Timeline
|
||||||
eras={timelineData.eras}
|
eras={timelineData.eras}
|
||||||
viewStart={viewStart}
|
viewStart={viewStart}
|
||||||
@@ -799,9 +814,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
{loading && timelineData.movements.length === 0 ? (
|
{!loading && (
|
||||||
<div className="loading home-movements-section">Loading art history...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
|
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
|
||||||
<div className="home-movements-section">
|
<div className="home-movements-section">
|
||||||
@@ -817,6 +830,7 @@ export default function HomePage() {
|
|||||||
onArtistClick={handleArtistClick}
|
onArtistClick={handleArtistClick}
|
||||||
onMovementClick={handleMovementClick}
|
onMovementClick={handleMovementClick}
|
||||||
onArtistHover={setHoveredLifespan}
|
onArtistHover={setHoveredLifespan}
|
||||||
|
onPortraitsLoadingChange={setPortraitsLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user