import { useRef, useState, useEffect, useMemo, Suspense, useCallback } from 'react'; import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { Text, Environment } from '@react-three/drei'; import * as THREE from 'three'; import type { ArtistDetail, Painting, ArtistPeriod, ArtistNavigation, MovementArtistGroup, } from '../types'; import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client'; import { comparePaintingsChronological, paintingHasInfluenceLinks } from '../utils/paintingUtils'; import { createParquetFloorTexture, PARQUET_METERS_PER_TILE } from '../utils/parquetFloorTexture'; import './VirtualGallery.css'; interface Props { data: ArtistDetail; imageRevisions?: Record; active?: boolean; onPaintingClick: (paintingId: number) => void; onNavigateArtist: (artistId: number) => void; onBack: () => void; onBioClick: () => void; } const WALL_HEIGHT = 4.2; const WALL_THICKNESS = 0.18; const MOUNT_OFFSET = 0.16; const WALL_STANDOFF = 0.07; const BACK_WALL_EXTRA = 0.05; const FRAME_FACE_Z = 0.018; const FRAME_DEPTH = 0.07; const FRAME_MAT_BORDER = 0.1; const FRAME_RAIL = 0.08; /** Checked paintings — double-width moulding. */ const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2; const REVIEWED_RAIL = FRAME_RAIL * 2; const EYE_HEIGHT = 1.65; const FRAME_GAP = 0.32; const MIN_FRAME_W = 0.45; const MAX_FRAME_W = 1.05; const MAX_FRAME_H = 1.35; const MIN_HALL_SIZE = 9; const ROW_GAP = 0.2; const WALL_PADDING = 1.4; /** Above this count, use a long corridor (short back wall, extended side walls). */ const CORRIDOR_CATALOG_THRESHOLD = 15; const BACK_WALL_MAX_PAINTINGS = 8; /** Every wall shows at most one row; side-wall depth grows to fit the catalog. */ const MAX_WALL_ROWS = 1; const DOOR_WIDTH = 2.4; const DOOR_HEIGHT = 2.5; /** Museum exit — dimensions derived from door opening. */ const EXIT_JAMB = 0.13; const EXIT_HEADER = 0.15; const EXIT_TRANSOM = 0.52; const EXIT_SURROUND = 0.1; const TURN_SPEED = 0.032; const MOUSE_TURN_SENSITIVITY = 0.004; const DRAG_START_THRESHOLD_PX = 5; const DEFAULT_MOVEMENT_COLOR = '#8B7355'; const WALL_BASE_MAIN = '#f0ebe3'; const WALL_BASE_SIDE = '#e8e2d8'; function parseHex(hex: string): { r: number; g: number; b: number } { const n = parseInt(hex.replace('#', ''), 16); if (Number.isNaN(n)) return { r: 240, g: 235, b: 227 }; return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; } function blendHex(base: string, accent: string, accentWeight: number): string { const a = parseHex(accent); const b = parseHex(base); const w = Math.min(1, Math.max(0, accentWeight)); const mix = (ca: number, cb: number) => Math.round(ca * w + cb * (1 - w)); const r = mix(a.r, b.r); const g = mix(a.g, b.g); const bl = mix(a.b, b.b); return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`; } function galleryWallColors(movementColor?: string) { const accent = movementColor || DEFAULT_MOVEMENT_COLOR; return { main: blendHex(WALL_BASE_MAIN, accent, 0.34), side: blendHex(WALL_BASE_SIDE, accent, 0.26), trim: blendHex('#b8956a', accent, 0.45), }; } /** Height above frame top edge — keeps fixture out of the viewer's line of sight. */ const INFLUENCE_LAMP_ABOVE_FRAME = 0.46; type WallSide = 'back' | 'left' | 'right'; interface FrameSlot { position: [number, number, number]; rotationY: number; maxW: number; maxH: number; side: WallSide; } interface WallSegment { side: WallSide; label: string; paintings: Painting[]; slots: FrameSlot[]; } interface HallLayout { width: number; depth: number; segments: WallSegment[]; } function paintingIsReviewed(painting: Painting): boolean { return !!painting.checkup_checked; } function frameDimsForReviewed(reviewed: boolean) { return reviewed ? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL, depth: FRAME_DEPTH * 1.08 } : { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL, depth: FRAME_DEPTH }; } function frameOuterW(canvasW: number, reviewed: boolean): number { const { matBorder, rail } = frameDimsForReviewed(reviewed); return canvasW + matBorder * 2 + rail; } function frameOuterH(canvasH: number, reviewed: boolean): number { const { matBorder, rail } = frameDimsForReviewed(reviewed); return canvasH + matBorder * 2 + rail; } function layoutRow(paintings: Painting[], span: number) { const count = paintings.length; if (count === 0) return { slots: [], spanNeeded: span }; const gap = FRAME_GAP; const available = span - WALL_PADDING; let frameW = MAX_FRAME_W; for (let attempt = 0; attempt < 40; attempt++) { let total = 0; for (let i = 0; i < count; i++) { total += frameOuterW(frameW, paintingIsReviewed(paintings[i])); if (i < count - 1) total += gap; } if (total <= available) break; frameW -= 0.015; } frameW = Math.max(MIN_FRAME_W, frameW); const frameH = Math.min(MAX_FRAME_H, frameW * 1.22); const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p))); const rowWidth = outers.reduce((sum, w) => sum + w, 0) + (count - 1) * gap; const slots: { offset: number; maxW: number; maxH: number }[] = []; let cursor = -rowWidth / 2; for (let i = 0; i < count; i++) { const outerW = outers[i]; slots.push({ offset: cursor + outerW / 2, maxW: frameW, maxH: frameH, }); cursor += outerW + gap; } return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) }; } function wallRowHeights(paintings: Painting[], span: number, rows: number) { const perRow = Math.ceil(paintings.length / rows); const heights: number[] = []; for (let r = 0; r < rows; r++) { const rowStart = r * perRow; const row = paintings.slice(rowStart, rowStart + perRow); const { slots } = layoutRow(row, span); const reviewed = row.some(paintingIsReviewed); heights.push(frameOuterH(slots[0]?.maxH ?? MAX_FRAME_H, reviewed)); } return heights; } function wallStackHeight(heights: number[]) { return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0); } function fitsOnWall(paintings: Painting[], span: number, rows: number) { const count = paintings.length; const perRow = Math.ceil(count / rows); const row = paintings.slice(0, perRow); const { slots } = layoutRow(row, span); if ((slots[0]?.maxW ?? 0) < MIN_FRAME_W) return false; const totalHeight = wallStackHeight(wallRowHeights(paintings, span, rows)); const bottom = EYE_HEIGHT - totalHeight / 2; const top = EYE_HEIGHT + totalHeight / 2; if (bottom < 0.35) return false; if (top > WALL_HEIGHT - 0.45) return false; return true; } function rowCountForWall(paintings: Painting[], span: number, maxRows: number = paintings.length) { const count = paintings.length; const limit = Math.max(1, Math.min(count, maxRows)); for (let rows = 1; rows <= limit; rows++) { if (fitsOnWall(paintings, span, rows)) return rows; } return limit; } function minSpanForWall(paintings: Painting[], maxRows: number = paintings.length) { const count = paintings.length; if (count === 0) return MIN_HALL_SIZE; let best = Infinity; const rowLimit = Math.max(1, Math.min(count, maxRows)); for (let rows = 1; rows <= rowLimit; rows++) { const perRow = Math.ceil(count / rows); const { spanNeeded } = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE); if (fitsOnWall(paintings, spanNeeded, rows)) { best = Math.min(best, spanNeeded); } } if (best === Infinity) { const perRow = Math.ceil(count / rowLimit); best = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE).spanNeeded; } return Math.max(MIN_HALL_SIZE, best); } /** Left → right on each wall: later works on the left, earlier works on the right. */ function orderPaintingsForWallDisplay(paintings: Painting[]) { return [...paintings].sort(comparePaintingsChronological).reverse(); } function distributePaintingsAcrossWalls(paintings: Painting[]) { const sorted = [...paintings].sort(comparePaintingsChronological); const back: Painting[] = []; const left: Painting[] = []; const right: Painting[] = []; if (sorted.length <= CORRIDOR_CATALOG_THRESHOLD) { sorted.forEach((p, i) => { if (i % 3 === 0) back.push(p); else if (i % 3 === 1) left.push(p); else right.push(p); }); } else { const backCount = Math.min(BACK_WALL_MAX_PAINTINGS, Math.max(4, Math.ceil(sorted.length * 0.12))); back.push(...sorted.slice(0, backCount)); sorted.slice(backCount).forEach((p, i) => { if (i % 2 === 0) left.push(p); else right.push(p); }); } return [ orderPaintingsForWallDisplay(back), orderPaintingsForWallDisplay(left), orderPaintingsForWallDisplay(right), ]; } function wallLabelForPaintings(wallPaintings: Painting[], periods: ArtistPeriod[]) { const periodIds = new Set( wallPaintings.map((p) => p.period_id).filter((id): id is number => id != null && id !== 0) ); const names = periods.filter((p) => periodIds.has(p.id)).map((p) => p.name); const hasUnassigned = wallPaintings.some((p) => !p.period_id); if (names.length > 0 && hasUnassigned) return `${names.join(' · ')} · Other`; if (names.length > 0) return names.join(' · '); if (hasUnassigned) return 'Other Works'; return wallPaintings.length > 0 ? 'Works' : ''; } function layoutWallSlots( paintings: Painting[], span: number, side: WallSide, halfW: number, halfD: number, inset: number, maxRows: number = paintings.length ): FrameSlot[] { const count = paintings.length; if (count === 0) return []; const rows = rowCountForWall(paintings, span, maxRows); const perRow = Math.ceil(count / rows); const slots: FrameSlot[] = []; const rowLayouts = []; for (let r = 0; r < rows; r++) { const rowStart = r * perRow; const inRow = Math.min(perRow, count - rowStart); rowLayouts.push(layoutRow(paintings.slice(rowStart, rowStart + inRow), span)); } const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H); const totalHeight = wallStackHeight(rowHeights); let y = EYE_HEIGHT - totalHeight / 2 + rowHeights[0] / 2; for (let r = 0; r < rows; r++) { const { slots: rowSlots } = rowLayouts[r]; const rowFrameH = rowHeights[r]; for (let i = 0; i < rowSlots.length; i++) { const s = rowSlots[i]; if (side === 'back') { slots.push({ maxW: s.maxW, maxH: s.maxH, rotationY: 0, side, position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA], }); } else if (side === 'left') { slots.push({ maxW: s.maxW, maxH: s.maxH, rotationY: Math.PI / 2, side, position: [-halfW + inset + WALL_STANDOFF, y, s.offset], }); } else { slots.push({ maxW: s.maxW, maxH: s.maxH, rotationY: -Math.PI / 2, side, position: [halfW - inset - WALL_STANDOFF, y, s.offset], }); } } y += rowFrameH + ROW_GAP; } return slots; } function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout { const walls: WallSide[] = ['back', 'left', 'right']; const wallPaintings = distributePaintingsAcrossWalls(paintings); let width = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS); let depth = Math.max( minSpanForWall(wallPaintings[1], MAX_WALL_ROWS), minSpanForWall(wallPaintings[2], MAX_WALL_ROWS) ); for (let i = 0; i < 24; i++) { const nextWidth = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS); const nextDepth = Math.max( minSpanForWall(wallPaintings[1], MAX_WALL_ROWS), minSpanForWall(wallPaintings[2], MAX_WALL_ROWS) ); if (nextWidth === width && nextDepth === depth) break; width = nextWidth; depth = nextDepth; } width = Math.max(width, MIN_HALL_SIZE); depth = Math.max(depth, MIN_HALL_SIZE); const halfW = width / 2; const halfD = depth / 2; const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET; const segments: WallSegment[] = walls.map((side, i) => ({ side, label: wallLabelForPaintings(wallPaintings[i], periods), paintings: wallPaintings[i], slots: layoutWallSlots( wallPaintings[i], side === 'back' ? width : depth, side, halfW, halfD, inset, MAX_WALL_ROWS ), })); return { width, depth, segments }; } function computeFrameSize(aspect: number, maxW: number, maxH: number) { let w = maxW; let h = w / aspect; if (h > maxH) { h = maxH; w = h * aspect; } return { width: w, height: h }; } function paintingHasGalleryImage(painting: Painting) { return !!(painting.thumbnail_path || painting.image_path); } let canvasWeaveTexture: THREE.CanvasTexture | null = null; function getCanvasWeaveTexture() { if (canvasWeaveTexture) return canvasWeaveTexture; const size = 256; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (ctx) { ctx.fillStyle = '#ddd0b8'; ctx.fillRect(0, 0, size, size); for (let y = 0; y < size; y += 4) { for (let x = 0; x < size; x += 4) { ctx.fillStyle = (x + y) % 8 === 0 ? '#c4b494' : '#e4dac8'; ctx.fillRect(x, y, 4, 4); } } ctx.strokeStyle = 'rgba(72, 58, 42, 0.18)'; ctx.lineWidth = 1; for (let i = 0; i <= size; i += 8) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, size); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(size, i); ctx.stroke(); } } canvasWeaveTexture = new THREE.CanvasTexture(canvas); canvasWeaveTexture.wrapS = THREE.RepeatWrapping; canvasWeaveTexture.wrapT = THREE.RepeatWrapping; canvasWeaveTexture.colorSpace = THREE.SRGBColorSpace; return canvasWeaveTexture; } function frameFinish(reviewed: boolean, hovered: boolean) { if (reviewed) { return { color: hovered ? '#ffe566' : '#ffd700', roughness: 0.22, metalness: 0.78, emissive: hovered ? '#ffcc00' : '#daa520', emissiveIntensity: hovered ? 0.35 : 0.18, }; } return { color: hovered ? '#2a2a2a' : '#0a0a0a', roughness: 0.28, metalness: 0.72, emissive: '#000000', emissiveIntensity: 0, }; } function CanvasCover({ width, height, frameDepth, faceZ, matBorder, hovered, }: { width: number; height: number; frameDepth: number; faceZ: number; matBorder: number; hovered: boolean; }) { const weave = useMemo(() => { const tex = getCanvasWeaveTexture().clone(); tex.repeat.set(Math.max(3, width * 5), Math.max(3, height * 5)); return tex; }, [width, height]); useEffect(() => () => weave.dispose(), [weave]); const cloth = hovered ? '#ddd3bc' : '#c4b494'; const z = frameDepth + faceZ; return ( ); } function usePaintingTexture(url: string | null) { const [texture, setTexture] = useState(null); const [failed, setFailed] = useState(!url); useEffect(() => { if (!url) { setTexture(null); setFailed(true); return; } setFailed(false); let disposed = false; let loaded: THREE.Texture | null = null; const loader = new THREE.TextureLoader(); loader.setCrossOrigin('anonymous'); loader.load( url, (tex) => { if (disposed) { tex.dispose(); return; } const img = tex.image as HTMLImageElement | undefined; if (!img || img.width < 4 || img.height < 4) { tex.dispose(); setFailed(true); return; } loaded = tex; tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; setTexture(tex); }, undefined, () => { if (!disposed) setFailed(true); } ); return () => { disposed = true; loaded?.dispose(); setTexture(null); }; }, [url]); return { texture, failed }; } function InfluencePictureLamp({ frameHeight, matBorder, frameDepth, highlighted, }: { frameHeight: number; matBorder: number; frameDepth: number; highlighted: boolean; }) { const mountY = frameHeight / 2 + matBorder + INFLUENCE_LAMP_ABOVE_FRAME; const glow = highlighted ? 1.6 : 1.15; const scale = 1.35; return ( ); } function PaintingFrame({ painting, position, rotationY, maxWidth, maxHeight, wallSide, imageRevision, onClick, }: { painting: Painting; position: [number, number, number]; rotationY: number; maxWidth: number; maxHeight: number; wallSide: WallSide; imageRevision?: number; onClick: () => void; }) { const [hovered, setHovered] = useState(false); const [aspect, setAspect] = useState(1.33); const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight); const reviewed = paintingIsReviewed(painting); const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed); const hasImage = paintingHasGalleryImage(painting); const url = hasImage ? galleryImageUrlWithRevision(painting, imageRevision) : null; const { texture, failed } = usePaintingTexture(url); const showImage = hasImage && !failed && !!texture; const showCanvas = !showImage; const hasInfluenceLinks = paintingHasInfluenceLinks(painting); const finish = frameFinish(reviewed, hovered); const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0); useEffect(() => { if (!showImage) return; const img = texture?.image as HTMLImageElement | undefined; if (img?.width && img.height) { setAspect(img.width / img.height); } }, [texture, showImage]); return ( { e.stopPropagation(); onClick(); }} onPointerOver={() => setHovered(true)} onPointerOut={() => setHovered(false)} > {!showCanvas && ( )} {showCanvas ? ( ) : ( )} {hasInfluenceLinks && ( )} ); } function GalleryWall({ position, size, rotation = [0, 0, 0], color = '#ebe4d8', }: { position: [number, number, number]; size: [number, number]; rotation?: [number, number, number]; color?: string; }) { const [w, h] = size; return ( ); } function ExitPortal({ position, active, onActivate, wallColor = '#f0ebe3', trimColor = '#ddd5c8', }: { position: [number, number, number]; active: boolean; onActivate: () => void; wallColor?: string; trimColor?: string; }) { const [hovered, setHovered] = useState(false); const highlight = active || hovered; const openingW = DOOR_WIDTH; const openingH = DOOR_HEIGHT; const jamb = EXIT_JAMB; const header = EXIT_HEADER; const transomH = EXIT_TRANSOM; const surround = EXIT_SURROUND; const frameW = openingW + jamb * 2; const frameFullH = openingH + header + transomH; const leafW = (openingW - 0.025) / 2; const leafH = openingH - 0.08; const leafY = 0.04 + leafH / 2; const faceZ = -0.04; const woodDark = '#261a10'; const woodMid = '#3d2818'; const woodGrain = '#4e3624'; const stone = wallColor; const stoneDark = trimColor; const brass = highlight ? '#d4af37' : '#a08050'; const brassEmissive = highlight ? '#5a4010' : '#000000'; const corridorGlow = highlight ? '#fff0d0' : '#ffe8c0'; const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => { e.stopPropagation(); onActivate(); }; const setHover = (on: boolean) => () => setHovered(on); const doorLeaf = (side: 'left' | 'right') => { const x = side === 'left' ? -leafW / 2 - 0.006 : leafW / 2 + 0.006; const panelInset = 0.06; return ( {/* Raised panel */} {/* Stile edges */} {/* Brass handle */} ); }; return ( {/* Wall reveal — depth into opening */} {/* Warm vestibule glow beyond doors */} {/* Marble threshold */} {/* Door leaves */} {doorLeaf('left')} {doorLeaf('right')} {/* Center meeting stile / push bar */} {/* Side jambs */} {([-1, 1] as const).map((sign) => ( ))} {/* Header lintel */} {/* Transom — frosted museum glass */} {/* Transom mullions */} {[-0.35, 0, 0.35].map((ox) => ( ))} {/* Limestone surround */} {([-1, 1] as const).map((sign) => ( ))} {/* Crown on surround */} {/* Brass EXIT plaque */} EXIT {/* Wall sconces */} {([-1, 1] as const).map((sign) => ( ))} {/* Hinges */} {([-1, 1] as const).flatMap((side) => [0.35, 0.85, 1.35].map((hy) => ( )) )} {/* Large invisible click target */} ); } function ParquetFloor({ width, depth }: { width: number; depth: number }) { const floorW = width + 0.4; const floorD = depth + 0.4; const texture = useMemo(() => { const map = createParquetFloorTexture(); map.repeat.set(floorW / PARQUET_METERS_PER_TILE, floorD / PARQUET_METERS_PER_TILE); map.needsUpdate = true; return map; }, [floorW, floorD]); return ( ); } function ArtistHall({ layout, artistName, movementColor, imageRevisions, onPaintingClick, onExitActivate, nearExit, }: { layout: HallLayout; artistName: string; movementColor?: string; imageRevisions?: Record; onPaintingClick: (id: number) => void; onExitActivate: () => void; nearExit: boolean; }) { const { width, depth, segments } = layout; const halfW = width / 2; const halfD = depth / 2; const walls = useMemo(() => galleryWallColors(movementColor), [movementColor]); return ( {/* Floor & ceiling — open centre, no furniture */} {/* Back wall */} {/* Left wall */} {/* Right wall */} {/* Front wall — two segments with door gap */} {/* Wall section above door opening */} {/* Crown molding on back wall */} {artistName.toUpperCase()} {segments.map((seg) => ( {seg.paintings.map((painting, i) => ( onPaintingClick(painting.id)} /> ))} {seg.label && ( {seg.label} )} ))} {depth > 14 && ( )} ); } function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) { pos.y = EYE_HEIGHT; target.y = EYE_HEIGHT; } function CameraController({ position, target, }: { position: THREE.Vector3; target: THREE.Vector3; }) { const { camera } = useThree(); useFrame(() => { levelHorizontalView(position, target); camera.position.lerp(position, 0.12); camera.lookAt(target.x, EYE_HEIGHT, target.z); }); return null; } function NavigationPanel({ navigation, loading, onSelect, onClose, }: { navigation: ArtistNavigation | null; loading: boolean; onSelect: (artistId: number) => void; onClose: () => void; }) { const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (

{title}

{loading &&

Loading…

} {!loading && groups.length === 0 &&

{emptyHint}

} {!loading && groups.map((group) => (

{group.movement_name}

    {group.artists.map((artist) => (
  • ))}
))}
); return (

Choose your path

{renderColumn( 'Predecessors', navigation?.predecessors ?? [], 'No documented predecessors via painting influences.' )} {renderColumn( 'Successors', navigation?.successors ?? [], 'No documented successors via painting influences.' )}
); } export default function VirtualGallery({ data, imageRevisions, active = true, onPaintingClick, onNavigateArtist, onBack, onBioClick, }: Props) { const [artist, setArtist] = useState(data.artist); const [periods, setPeriods] = useState(data.periods); const [paintings, setPaintings] = useState(data.paintings); const [syncStatus, setSyncStatus] = useState(''); const [showExitNav, setShowExitNav] = useState(false); const [navigation, setNavigation] = useState(null); const [navLoading, setNavLoading] = useState(false); const [nearExit, setNearExit] = useState(false); const [isLooking, setIsLooking] = useState(false); useEffect(() => { setArtist(data.artist); setPeriods(data.periods); setPaintings(data.paintings); }, [data]); useEffect(() => { let cancelled = false; (async () => { try { setSyncStatus('Syncing images…'); const timeout = new Promise((resolve) => setTimeout(resolve, 2000)); await Promise.race([preloadArtistImages(data.artist.id), timeout]); const fresh = await api.getArtist(data.artist.id); if (!cancelled) { setArtist(fresh.artist); setPeriods(fresh.periods); setPaintings(fresh.paintings); 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; }; }, [data.artist.id]); const layout = useMemo(() => buildHallLayout(paintings, periods), [paintings, periods]); const halfW = layout.width / 2 - 0.55; const halfD = layout.depth / 2 - 0.35; const exitZ = layout.depth / 2 - 0.55; const fogFar = Math.max(55, layout.depth + 42); const initialPos = useMemo( () => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2), [layout.depth] ); const initialTarget = useMemo( () => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth * (layout.depth > 14 ? 0.38 : 0.25)), [layout.depth] ); const [camPos, setCamPos] = useState(() => initialPos.clone()); const [camTarget, setCamTarget] = useState(() => initialTarget.clone()); const keysPressed = useRef>(new Set()); const dragTurnActive = useRef(false); const dragStartRef = useRef<{ x: number; y: number } | null>(null); const camPosRef = useRef(camPos); const camTargetRef = useRef(camTarget); camPosRef.current = camPos; camTargetRef.current = camTarget; useEffect(() => { setCamPos(initialPos.clone()); setCamTarget(initialTarget.clone()); setShowExitNav(false); setNearExit(false); }, [data.artist.id, initialPos, initialTarget]); const openExitNav = useCallback(async () => { setShowExitNav(true); setNavLoading(true); try { const nav = await api.getArtistNavigation(artist.id); setNavigation(nav); } catch { setNavigation({ predecessors: [], successors: [] }); } finally { setNavLoading(false); } }, [artist.id]); const moveCamera = useCallback( (forward: number, strafe: number, rotY: number) => { const pos = camPosRef.current.clone(); const target = camTargetRef.current.clone(); const angle = Math.atan2(target.x - pos.x, target.z - pos.z); if (rotY !== 0) { const newAngle = angle + rotY; const dist = pos.distanceTo(target); target.x = pos.x + Math.sin(newAngle) * dist; target.z = pos.z + Math.cos(newAngle) * dist; } else { const newAngle = angle; pos.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe; pos.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe; target.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe; target.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe; } pos.x = Math.max(-halfW, Math.min(halfW, pos.x)); target.x = Math.max(-halfW, Math.min(halfW, target.x)); pos.z = Math.max(-halfD, Math.min(exitZ, pos.z)); target.z = Math.max(-halfD, Math.min(exitZ, target.z)); levelHorizontalView(pos, target); setCamPos(pos); setCamTarget(target); const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; setNearExit(atExit); }, [halfW, halfD, exitZ] ); useEffect(() => { if (active) return; keysPressed.current.clear(); dragTurnActive.current = false; dragStartRef.current = null; setIsLooking(false); }, [active]); useEffect(() => { if (!active) return; const onKeyDown = (e: KeyboardEvent) => { keysPressed.current.add(e.key); if ((e.key === 'e' || e.key === 'E') && !showExitNav) { openExitNav(); } }; const onKeyUp = (e: KeyboardEvent) => keysPressed.current.delete(e.key); window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); const interval = setInterval(() => { const keys = keysPressed.current; if (keys.has('ArrowUp') || keys.has('w') || keys.has('W')) moveCamera(0.1, 0, 0); if (keys.has('ArrowDown') || keys.has('s') || keys.has('S')) moveCamera(-0.1, 0, 0); if (keys.has('ArrowLeft') || keys.has('a') || keys.has('A') || keys.has('q') || keys.has('Q')) { moveCamera(0, 0, TURN_SPEED); } if (keys.has('ArrowRight') || keys.has('d') || keys.has('D')) { moveCamera(0, 0, -TURN_SPEED); } }, 16); return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); clearInterval(interval); }; }, [active, moveCamera, showExitNav, openExitNav]); const handleNavigate = (artistId: number) => { setShowExitNav(false); onNavigateArtist(artistId); }; const handleCanvasPointerDown = (e: React.PointerEvent) => { if (!active || showExitNav || e.button !== 0) return; dragStartRef.current = { x: e.clientX, y: e.clientY }; }; const handleCanvasPointerMove = (e: React.PointerEvent) => { if (!active || showExitNav || !dragStartRef.current) return; const dx = e.clientX - dragStartRef.current.x; const dy = e.clientY - dragStartRef.current.y; const distSq = dx * dx + dy * dy; if (!dragTurnActive.current && distSq >= DRAG_START_THRESHOLD_PX * DRAG_START_THRESHOLD_PX) { dragTurnActive.current = true; setIsLooking(true); e.currentTarget.setPointerCapture(e.pointerId); } if (dragTurnActive.current && e.movementX !== 0) { moveCamera(0, 0, e.movementX * MOUSE_TURN_SENSITIVITY); } }; const endCanvasDrag = (e: React.PointerEvent) => { dragStartRef.current = null; dragTurnActive.current = false; setIsLooking(false); if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); } }; return (

{artist.name}

Personal hall · {paintings.length} works on the walls

{syncStatus && (

{syncStatus}

)} {!showExitNav && (
Click the exit door, E, or Exit → above
)}
{showExitNav && ( setShowExitNav(false)} /> )}

{artist.name}'s Hall

  • W / Walk forward
  • S / Walk back
  • A / / Q Turn left
  • D / Turn right
  • Drag on the view to look around
  • Click a painting to view details and influences
  • Golden lamps mark works linked in the influence graph
  • Click the exit door or E to visit related artists
); }