Files
Art-gallery/client/src/components/VirtualGallery.tsx
T
Danila KhodjaefandCursor 019ce4e136 Improve 3D gallery and debug checkup workflow for image curation.
Add parquet floor, museum-style exit, movement-tinted walls, and gold/black
frames with gallery sync after Fix it. Debug panel gets Checked and Fix it
buttons; documentation and image-fetch reliability updates included.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 22:05:24 +03:00

1606 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, number>;
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 (
<group renderOrder={20}>
<mesh position={[0, 0, z]}>
<planeGeometry args={[width + matBorder * 1.6, height + matBorder * 1.6]} />
<meshBasicMaterial
map={weave}
color={cloth}
toneMapped={false}
depthWrite
polygonOffset
polygonOffsetFactor={-6}
polygonOffsetUnits={-6}
/>
</mesh>
<mesh position={[-width * 0.22, 0, z + 0.004]} rotation={[0, 0.22, 0.02]}>
<planeGeometry args={[width * 0.48, height * 0.98]} />
<meshBasicMaterial
map={weave}
color={hovered ? '#a89478' : '#958470'}
toneMapped={false}
transparent
opacity={0.55}
depthWrite={false}
polygonOffset
polygonOffsetFactor={-5}
polygonOffsetUnits={-5}
/>
</mesh>
</group>
);
}
function usePaintingTexture(url: string | null) {
const [texture, setTexture] = useState<THREE.Texture | null>(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 (
<group position={[0, mountY, frameDepth * 0.55]} scale={scale}>
<mesh position={[0, 0.04, -0.05]} castShadow renderOrder={30}>
<boxGeometry args={[0.11, 0.05, 0.05]} />
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
</mesh>
<mesh position={[0, 0.01, -0.01]} rotation={[0.25, 0, 0]} castShadow renderOrder={30}>
<boxGeometry args={[0.04, 0.08, 0.035]} />
<meshStandardMaterial color="#555555" metalness={0.8} roughness={0.3} />
</mesh>
<group position={[0, -0.02, 0.03]} rotation={[-0.55, 0, 0]}>
<mesh position={[0, 0, 0.04]} rotation={[Math.PI, 0, 0]} renderOrder={31}>
<coneGeometry args={[0.09, 0.1, 16, 1, true]} />
<meshStandardMaterial
color={highlighted ? '#f0d050' : '#d4af37'}
emissive={highlighted ? '#c89400' : '#8a6500'}
emissiveIntensity={0.75 * glow}
metalness={0.45}
roughness={0.35}
side={THREE.DoubleSide}
/>
</mesh>
<mesh position={[0, -0.04, 0.04]} renderOrder={32}>
<sphereGeometry args={[0.022, 10, 10]} />
<meshStandardMaterial
color="#fff8e0"
emissive="#ffcc55"
emissiveIntensity={1.4 * glow}
toneMapped={false}
/>
</mesh>
<pointLight
position={[0, -0.03, 0.05]}
intensity={1.8 * glow}
distance={4}
color="#ffe4a8"
decay={2}
/>
<spotLight
position={[0, -0.03, 0.05]}
angle={0.75}
penumbra={0.95}
intensity={2.2 * glow}
distance={5}
color="#fff0c8"
castShadow={false}
/>
</group>
</group>
);
}
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 (
<group position={position} rotation={[0, rotationY, 0]}>
<spotLight
position={[0, height / 2 + 0.25, 0.3]}
angle={0.5}
penumbra={0.75}
intensity={showCanvas ? (hovered ? 0.9 : 0.55) : hovered ? 2.6 : 1.9}
distance={4.5}
color={showCanvas ? '#e8dcc8' : '#fff8ee'}
/>
<mesh
position={[0, 0, frameDepth / 2]}
castShadow
renderOrder={1}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<boxGeometry args={[width + matBorder * 2 + rail, height + matBorder * 2 + rail, frameDepth]} />
<meshStandardMaterial
color={finish.color}
roughness={finish.roughness}
metalness={finish.metalness}
emissive={finish.emissive}
emissiveIntensity={finish.emissiveIntensity}
/>
</mesh>
{!showCanvas && (
<mesh position={[0, 0, frameDepth + 0.002]} renderOrder={2}>
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
<meshStandardMaterial
color={reviewed ? '#fff6cc' : '#f5f0e6'}
roughness={reviewed ? 0.75 : 0.95}
metalness={reviewed ? 0.15 : 0}
/>
</mesh>
)}
{showCanvas ? (
<CanvasCover
width={width}
height={height}
frameDepth={frameDepth}
faceZ={faceZ}
matBorder={matBorder}
hovered={hovered}
/>
) : (
<mesh position={[0, 0, frameDepth + faceZ]} renderOrder={20}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={texture}
color="#ffffff"
toneMapped={false}
polygonOffset
polygonOffsetFactor={-4}
polygonOffsetUnits={-4}
/>
</mesh>
)}
{hasInfluenceLinks && (
<InfluencePictureLamp
frameHeight={height}
matBorder={matBorder}
frameDepth={frameDepth}
highlighted={hovered}
/>
)}
</group>
);
}
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 (
<mesh position={position} rotation={rotation} receiveShadow renderOrder={0}>
<boxGeometry args={[w, h, WALL_THICKNESS]} />
<meshStandardMaterial color={color} roughness={0.92} />
</mesh>
);
}
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 (
<group key={side} position={[x, leafY, faceZ]}>
<mesh castShadow>
<boxGeometry args={[leafW, leafH, 0.055]} />
<meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
</mesh>
{/* Raised panel */}
<mesh position={[0, 0.08, 0.03]}>
<boxGeometry args={[leafW - panelInset * 2, leafH * 0.38, 0.012]} />
<meshStandardMaterial color={woodGrain} roughness={0.58} metalness={0.03} />
</mesh>
<mesh position={[0, -leafH * 0.22, 0.03]}>
<boxGeometry args={[leafW - panelInset * 2, leafH * 0.32, 0.012]} />
<meshStandardMaterial color={woodDark} roughness={0.65} metalness={0.02} />
</mesh>
{/* Stile edges */}
<mesh position={[side === 'left' ? leafW / 2 - 0.025 : -leafW / 2 + 0.025, 0, 0.028]}>
<boxGeometry args={[0.05, leafH, 0.008]} />
<meshStandardMaterial color={woodDark} roughness={0.7} />
</mesh>
{/* Brass handle */}
<mesh position={[side === 'left' ? leafW / 2 - 0.1 : -leafW / 2 + 0.1, 0, 0.038]} rotation={[0, 0, side === 'left' ? 0.08 : -0.08]}>
<boxGeometry args={[0.04, 0.22, 0.025]} />
<meshStandardMaterial
color={brass}
roughness={0.22}
metalness={0.88}
emissive={brassEmissive}
emissiveIntensity={highlight ? 0.15 : 0}
/>
</mesh>
</group>
);
};
return (
<group position={position}>
{/* Wall reveal — depth into opening */}
<mesh position={[0, frameFullH / 2, 0.02]}>
<boxGeometry args={[openingW - 0.04, frameFullH - 0.04, WALL_THICKNESS - 0.04]} />
<meshStandardMaterial color="#1a1410" roughness={0.95} />
</mesh>
{/* Warm vestibule glow beyond doors */}
<pointLight position={[0, openingH * 0.55, 0.22]} intensity={highlight ? 1.1 : 0.65} distance={4.5} color={corridorGlow} />
<mesh position={[0, openingH * 0.55, 0.1]}>
<planeGeometry args={[openingW * 0.85, openingH * 0.9]} />
<meshStandardMaterial
color="#fff4e8"
emissive="#ffdcb0"
emissiveIntensity={highlight ? 0.35 : 0.18}
roughness={0.9}
/>
</mesh>
{/* Marble threshold */}
<mesh position={[0, 0.025, faceZ + 0.04]} receiveShadow>
<boxGeometry args={[frameW + 0.12, 0.05, 0.14]} />
<meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
</mesh>
<mesh position={[0, 0.052, faceZ + 0.05]}>
<boxGeometry args={[frameW + 0.08, 0.006, 0.1]} />
<meshStandardMaterial color="#b8860b" roughness={0.3} metalness={0.75} />
</mesh>
{/* Door leaves */}
{doorLeaf('left')}
{doorLeaf('right')}
{/* Center meeting stile / push bar */}
<mesh position={[0, leafY, faceZ + 0.042]}>
<boxGeometry args={[0.035, leafH * 0.72, 0.018]} />
<meshStandardMaterial
color={brass}
roughness={0.2}
metalness={0.9}
emissive={brassEmissive}
emissiveIntensity={highlight ? 0.2 : 0}
/>
</mesh>
{/* Side jambs */}
{([-1, 1] as const).map((sign) => (
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), frameFullH / 2, faceZ + 0.01]} castShadow>
<boxGeometry args={[jamb, frameFullH, 0.1]} />
<meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
</mesh>
))}
{/* Header lintel */}
<mesh position={[0, openingH + header / 2, faceZ + 0.01]} castShadow>
<boxGeometry args={[frameW, header, 0.1]} />
<meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
</mesh>
{/* Transom — frosted museum glass */}
<mesh position={[0, openingH + header + transomH / 2, faceZ + 0.02]}>
<boxGeometry args={[openingW - 0.06, transomH - 0.06, 0.025]} />
<meshStandardMaterial
color="#f5f0e8"
emissive={highlight ? '#fff0d8' : '#ffeacc'}
emissiveIntensity={highlight ? 0.25 : 0.12}
roughness={0.15}
metalness={0.05}
transparent
opacity={0.88}
/>
</mesh>
{/* Transom mullions */}
{[-0.35, 0, 0.35].map((ox) => (
<mesh key={ox} position={[ox, openingH + header + transomH / 2, faceZ + 0.035]}>
<boxGeometry args={[0.04, transomH - 0.1, 0.012]} />
<meshStandardMaterial color={woodDark} roughness={0.6} />
</mesh>
))}
{/* Limestone surround */}
{([-1, 1] as const).map((sign) => (
<mesh
key={`sur-${sign}`}
position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
castShadow
>
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
<meshStandardMaterial color={stone} roughness={0.88} />
</mesh>
))}
<mesh position={[0, frameFullH + 0.04, faceZ - 0.01]} castShadow>
<boxGeometry args={[frameW + surround * 2, 0.12, 0.08]} />
<meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
</mesh>
{/* Crown on surround */}
<mesh position={[0, frameFullH + 0.12, faceZ - 0.005]}>
<boxGeometry args={[frameW + surround * 2 + 0.06, 0.08, 0.1]} />
<meshStandardMaterial color={stoneDark} roughness={0.5} metalness={0.2} />
</mesh>
{/* Brass EXIT plaque */}
<group position={[0, openingH + header + transomH + 0.18, faceZ - 0.02]}>
<mesh
onClick={handleActivate}
onPointerOver={setHover(true)}
onPointerOut={setHover(false)}
>
<boxGeometry args={[0.55, 0.14, 0.018]} />
<meshStandardMaterial
color={brass}
roughness={0.25}
metalness={0.85}
emissive={brassEmissive}
emissiveIntensity={highlight ? 0.25 : 0.05}
/>
</mesh>
<Text
position={[0, 0, -0.012]}
rotation={[0, Math.PI, 0]}
fontSize={0.09}
color={highlight ? '#fff8e8' : '#3d2818'}
anchorX="center"
anchorY="middle"
onClick={handleActivate}
onPointerOver={setHover(true)}
onPointerOut={setHover(false)}
>
EXIT
</Text>
</group>
{/* Wall sconces */}
{([-1, 1] as const).map((sign) => (
<group key={`sconce-${sign}`} position={[sign * (frameW / 2 + surround + 0.18), openingH + 0.35, faceZ - 0.02]}>
<mesh>
<boxGeometry args={[0.08, 0.22, 0.1]} />
<meshStandardMaterial color={brass} roughness={0.3} metalness={0.8} />
</mesh>
<mesh position={[0, -0.08, -0.04]} rotation={[Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.07, 0.12, 8, 1, true]} />
<meshStandardMaterial
color="#fff4e0"
emissive="#ffdcb0"
emissiveIntensity={highlight ? 0.5 : 0.28}
roughness={0.4}
side={THREE.DoubleSide}
/>
</mesh>
<pointLight
position={[0, -0.12, -0.06]}
intensity={highlight ? 0.45 : 0.22}
distance={2.2}
color="#fff0d8"
/>
</group>
))}
{/* Hinges */}
{([-1, 1] as const).flatMap((side) =>
[0.35, 0.85, 1.35].map((hy) => (
<mesh
key={`hinge-${side}-${hy}`}
position={[side * (openingW / 2 + 0.02), hy, faceZ + 0.03]}
rotation={[0, 0, side === -1 ? 0 : Math.PI]}
>
<boxGeometry args={[0.025, 0.1, 0.04]} />
<meshStandardMaterial color={brass} roughness={0.28} metalness={0.85} />
</mesh>
))
)}
{/* Large invisible click target */}
<mesh
position={[0, frameFullH / 2, faceZ - 0.15]}
rotation={[0, Math.PI, 0]}
onClick={handleActivate}
onPointerOver={setHover(true)}
onPointerOut={setHover(false)}
>
<planeGeometry args={[DOOR_WIDTH * 2.2, frameFullH * 1.15]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
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 (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} receiveShadow>
<planeGeometry args={[floorW, floorD]} />
<meshStandardMaterial
map={texture}
roughness={0.38}
metalness={0.12}
envMapIntensity={0.55}
color="#c8a882"
/>
</mesh>
);
}
function ArtistHall({
layout,
artistName,
movementColor,
imageRevisions,
onPaintingClick,
onExitActivate,
nearExit,
}: {
layout: HallLayout;
artistName: string;
movementColor?: string;
imageRevisions?: Record<number, number>;
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 (
<group>
{/* Floor & ceiling — open centre, no furniture */}
<ParquetFloor width={width} depth={depth} />
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]}>
<planeGeometry args={[width + 0.4, depth + 0.4]} />
<meshStandardMaterial color="#2a2018" roughness={0.9} />
</mesh>
{/* Back wall */}
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
{/* Left wall */}
<GalleryWall
position={[-halfW, WALL_HEIGHT / 2, 0]}
size={[depth, WALL_HEIGHT]}
rotation={[0, Math.PI / 2, 0]}
color={walls.side}
/>
{/* Right wall */}
<GalleryWall
position={[halfW, WALL_HEIGHT / 2, 0]}
size={[depth, WALL_HEIGHT]}
rotation={[0, Math.PI / 2, 0]}
color={walls.side}
/>
{/* Front wall — two segments with door gap */}
<GalleryWall
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color={walls.main}
/>
<GalleryWall
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color={walls.main}
/>
{/* Wall section above door opening */}
<GalleryWall
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]}
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
color={walls.main}
/>
{/* Crown molding on back wall */}
<mesh position={[0, WALL_HEIGHT - 0.08, -halfD + WALL_THICKNESS / 2]}>
<boxGeometry args={[width, 0.14, 0.1]} />
<meshStandardMaterial color={walls.trim} roughness={0.5} metalness={0.25} />
</mesh>
<Text
position={[0, WALL_HEIGHT - 0.45, -halfD + WALL_THICKNESS + 0.02]}
fontSize={0.28}
color="#4a3020"
anchorX="center"
>
{artistName.toUpperCase()}
</Text>
{segments.map((seg) => (
<group key={`${seg.side}-${seg.label}`}>
{seg.paintings.map((painting, i) => (
<PaintingFrame
key={`${painting.id}-${imageRevisions?.[painting.id] ?? 0}`}
painting={painting}
position={seg.slots[i].position}
rotationY={seg.slots[i].rotationY}
maxWidth={seg.slots[i].maxW}
maxHeight={seg.slots[i].maxH}
wallSide={seg.slots[i].side}
imageRevision={imageRevisions?.[painting.id]}
onClick={() => onPaintingClick(painting.id)}
/>
))}
{seg.label && (
<Text
position={[
seg.side === 'back' ? 0 : seg.side === 'left' ? -halfW + 0.15 : halfW - 0.15,
WALL_HEIGHT - 0.85,
seg.side === 'back' ? -halfD + 0.15 : 0,
]}
rotation={[0, seg.side === 'left' ? Math.PI / 2 : seg.side === 'right' ? -Math.PI / 2 : 0, 0]}
fontSize={0.14}
color="#6b5344"
anchorX="center"
>
{seg.label}
</Text>
)}
</group>
))}
<ExitPortal
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
active={nearExit}
onActivate={onExitActivate}
wallColor={walls.main}
trimColor={walls.trim}
/>
<pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={0.5} color="#fff5e8" distance={width + depth} />
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={0.35} color="#fff0dd" distance={Math.max(12, depth * 0.75)} />
{depth > 14 && (
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={0.3} color="#fff0dd" distance={14} />
)}
</group>
);
}
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) => (
<div className="exit-nav-column">
<h3>{title}</h3>
{loading && <p className="exit-nav-loading">Loading</p>}
{!loading && groups.length === 0 && <p className="exit-nav-empty">{emptyHint}</p>}
{!loading &&
groups.map((group) => (
<div key={group.movement_id ?? group.movement_name} className="exit-nav-movement">
<h4 style={{ borderColor: group.movement_color }}>{group.movement_name}</h4>
<ul>
{group.artists.map((artist) => (
<li key={artist.id}>
<button type="button" onClick={() => onSelect(artist.id)}>
<img src={imageUrl(artist.portrait_path)} alt="" />
<span>
{artist.name}
{artist.birth_year && (
<small>
{artist.birth_year}
{artist.death_year ? `${artist.death_year}` : ''}
</small>
)}
</span>
</button>
</li>
))}
</ul>
</div>
))}
</div>
);
return (
<div className="exit-nav-overlay" role="dialog" aria-modal="true">
<div className="exit-nav-panel">
<header className="exit-nav-header">
<h2>Choose your path</h2>
<button type="button" className="exit-nav-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
<div className="exit-nav-columns">
{renderColumn(
'Predecessors',
navigation?.predecessors ?? [],
'No documented predecessors via painting influences.'
)}
{renderColumn(
'Successors',
navigation?.successors ?? [],
'No documented successors via painting influences.'
)}
</div>
</div>
</div>
);
}
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<ArtistNavigation | null>(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<void>((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<Set<string>>(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<HTMLDivElement>) => {
if (!active || showExitNav || e.button !== 0) return;
dragStartRef.current = { x: e.clientX, y: e.clientY };
};
const handleCanvasPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
dragStartRef.current = null;
dragTurnActive.current = false;
setIsLooking(false);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
};
return (
<div className="virtual-gallery">
<div className="gallery-header">
<button className="gallery-back-btn" onClick={onBack}> Back to Timeline</button>
<div className="gallery-title-block">
<h2>{artist.name}</h2>
<p className="gallery-career-path">Personal hall · {paintings.length} works on the walls</p>
</div>
<div className="gallery-header-meta">
<button type="button" className="gallery-exit-btn" onClick={openExitNav}>
Exit
</button>
<button className="gallery-bio-btn" onClick={onBioClick}>Biography</button>
</div>
</div>
<div
className={`gallery-canvas-container${isLooking ? ' gallery-canvas-dragging' : ''}`}
onPointerDown={handleCanvasPointerDown}
onPointerMove={handleCanvasPointerMove}
onPointerUp={endCanvasDrag}
onPointerLeave={endCanvasDrag}
onPointerCancel={endCanvasDrag}
>
{syncStatus && (
<div className="gallery-loading-overlay gallery-sync-badge">
<p>{syncStatus}</p>
</div>
)}
{!showExitNav && (
<div className="gallery-exit-hint">
Click the exit door, <kbd>E</kbd>, or <strong>Exit </strong> above
</div>
)}
<Canvas
shadows
frameloop={active ? 'always' : 'never'}
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
>
<color attach="background" args={['#0d0906']} />
<fog attach="fog" args={['#0d0906', 18, fogFar]} />
<ambientLight intensity={0.42} />
<directionalLight position={[3, 9, 4]} intensity={0.65} castShadow shadow-mapSize={[1024, 1024]} />
<Suspense fallback={null}>
<Environment preset="apartment" environmentIntensity={0.15} />
</Suspense>
<ArtistHall
layout={layout}
artistName={artist.name}
movementColor={artist.movement_color}
imageRevisions={imageRevisions}
onPaintingClick={onPaintingClick}
onExitActivate={openExitNav}
nearExit={nearExit}
/>
<CameraController position={camPos} target={camTarget} />
</Canvas>
</div>
{showExitNav && (
<NavigationPanel
navigation={navigation}
loading={navLoading}
onSelect={handleNavigate}
onClose={() => setShowExitNav(false)}
/>
)}
<div className="gallery-controls">
<div className="control-pad">
<button onClick={() => moveCamera(-0.35, 0, 0)} title="Walk forward"></button>
<div className="control-row">
<button onClick={() => moveCamera(0, 0, TURN_SPEED * 4)} title="Turn left"></button>
<button onClick={() => moveCamera(0.35, 0, 0)} title="Walk back"></button>
<button onClick={() => moveCamera(0, 0, -TURN_SPEED * 4)} title="Turn right"></button>
</div>
</div>
<div className="gallery-instructions">
<h4>{artist.name}&apos;s Hall</h4>
<ul>
<li><kbd>W</kbd> / <kbd></kbd> Walk forward</li>
<li><kbd>S</kbd> / <kbd></kbd> Walk back</li>
<li><kbd>A</kbd> / <kbd></kbd> / <kbd>Q</kbd> Turn left</li>
<li><kbd>D</kbd> / <kbd></kbd> Turn right</li>
<li>Drag on the view to look around</li>
<li>Click a painting to view details and influences</li>
<li>Golden lamps mark works linked in the influence graph</li>
<li>Click the exit door or <kbd>E</kbd> to visit related artists</li>
</ul>
</div>
</div>
</div>
);
}