Improve 3D gallery UX, placeholders, and image accuracy.

Add multi-row dynamic halls, eye-level camera, canvas covers for missing works, preserved view when returning from detail, and corrected image overrides for Kauffman and Raphael. Update documentation and add fetched painting assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-19 11:40:08 +03:00
co-authored by Cursor
parent 08f99d7a29
commit 792a0b1a77
42 changed files with 618 additions and 219 deletions
+20 -2
View File
@@ -50,7 +50,8 @@
}
.gallery-back-btn,
.gallery-bio-btn {
.gallery-bio-btn,
.gallery-exit-btn {
padding: 8px 16px;
border: 1px solid #c9a96e;
background: rgba(201, 169, 110, 0.15);
@@ -62,8 +63,14 @@
transition: background 0.2s;
}
.gallery-exit-btn {
border-color: #d4af37;
color: #f5e6c8;
}
.gallery-back-btn:hover,
.gallery-bio-btn:hover {
.gallery-bio-btn:hover,
.gallery-exit-btn:hover {
background: rgba(201, 169, 110, 0.35);
}
@@ -71,6 +78,12 @@
flex: 1;
min-height: 0;
position: relative;
cursor: grab;
touch-action: none;
}
.gallery-canvas-container.gallery-canvas-dragging {
cursor: grabbing;
}
.gallery-loading-overlay {
@@ -187,6 +200,11 @@
pointer-events: none;
}
.gallery-exit-hint strong {
color: #d4af37;
font-weight: normal;
}
.gallery-exit-hint kbd {
padding: 1px 6px;
border: 1px solid rgba(201, 169, 110, 0.5);
+451 -147
View File
@@ -14,6 +14,7 @@ import './VirtualGallery.css';
interface Props {
data: ArtistDetail;
active?: boolean;
onPaintingClick: (paintingId: number) => void;
onNavigateArtist: (artistId: number) => void;
onBack: () => void;
@@ -22,17 +23,23 @@ interface Props {
const WALL_HEIGHT = 4.2;
const WALL_THICKNESS = 0.18;
const MOUNT_OFFSET = 0.06;
const MOUNT_OFFSET = 0.16;
const WALL_STANDOFF = 0.07;
const BACK_WALL_EXTRA = 0.05;
const FRAME_FACE_Z = 0.018;
const EYE_HEIGHT = 1.65;
const HANG_HEIGHT = 1.55;
const FRAME_GAP = 0.18;
const MIN_FRAME_W = 0.45;
const MAX_FRAME_W = 1.05;
const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 9;
const MAX_FRAMES_PER_WALL = 10;
const ROW_GAP = 0.2;
const WALL_PADDING = 1.4;
const DOOR_WIDTH = 2.4;
const DOOR_HEIGHT = 2.5;
const TURN_SPEED = 0.032;
const MOUSE_TURN_SENSITIVITY = 0.004;
const DRAG_START_THRESHOLD_PX = 5;
type WallSide = 'back' | 'left' | 'right';
@@ -41,6 +48,7 @@ interface FrameSlot {
rotationY: number;
maxW: number;
maxH: number;
side: WallSide;
}
interface WallSegment {
@@ -74,115 +82,185 @@ function layoutRow(count: number, span: number) {
});
}
return { slots, spanNeeded: Math.max(span, rowWidth + padding) };
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
}
function wallRowHeights(count: number, span: number, rows: number) {
const perRow = Math.ceil(count / rows);
const heights: number[] = [];
for (let r = 0; r < rows; r++) {
const inRow = Math.min(perRow, count - r * perRow);
const { slots } = layoutRow(inRow, span);
heights.push(slots[0]?.maxH ?? MAX_FRAME_H);
}
return heights;
}
function wallStackHeight(heights: number[]) {
return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0);
}
function fitsOnWall(count: number, span: number, rows: number) {
const perRow = Math.ceil(count / rows);
const available = span - WALL_PADDING;
const frameW = Math.min(
MAX_FRAME_W,
(available - (perRow - 1) * FRAME_GAP) / Math.max(perRow, 1)
);
if (frameW < MIN_FRAME_W) return false;
const totalHeight = wallStackHeight(wallRowHeights(count, 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(count: number, span: number) {
for (let rows = 1; rows <= count; rows++) {
if (fitsOnWall(count, span, rows)) return rows;
}
return count;
}
function minSpanForWall(count: number) {
if (count === 0) return MIN_HALL_SIZE;
let best = Infinity;
for (let rows = 1; rows <= count; rows++) {
const perRow = Math.ceil(count / rows);
const span = perRow * MIN_FRAME_W + (perRow - 1) * FRAME_GAP + WALL_PADDING;
if (fitsOnWall(count, span, rows)) {
best = Math.min(best, span);
}
}
return Math.max(MIN_HALL_SIZE, best === Infinity ? MIN_HALL_SIZE : best);
}
function distributePaintingsAcrossWalls(paintings: Painting[]) {
const sorted = [...paintings].sort((a, b) => (a.year || 0) - (b.year || 0));
const walls: Painting[][] = [[], [], []];
sorted.forEach((p, i) => walls[i % 3].push(p));
return walls;
}
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
): FrameSlot[] {
const count = paintings.length;
if (count === 0) return [];
const rows = rowCountForWall(count, span);
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(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 byPeriod = new Map<number, Painting[]>();
for (const p of paintings) {
const key = p.period_id || 0;
const list = byPeriod.get(key) || [];
list.push(p);
byPeriod.set(key, list);
}
const periodGroups: { label: string; paintings: Painting[] }[] = [];
for (const period of periods) {
const list = byPeriod.get(period.id) || [];
if (list.length > 0) {
periodGroups.push({
label: period.name,
paintings: [...list].sort((a, b) => (a.year || 0) - (b.year || 0)),
});
}
}
const other = byPeriod.get(0) || [];
if (other.length > 0) {
periodGroups.push({
label: 'Other Works',
paintings: [...other].sort((a, b) => (a.year || 0) - (b.year || 0)),
});
}
if (periodGroups.length === 0 && paintings.length > 0) {
periodGroups.push({
label: 'Works',
paintings: [...paintings].sort((a, b) => (a.year || 0) - (b.year || 0)),
});
}
const walls: WallSide[] = ['back', 'left', 'right'];
const wallBuckets: { label: string; paintings: Painting[] }[][] = [[], [], []];
const wallPaintings = distributePaintingsAcrossWalls(paintings);
periodGroups.forEach((group, i) => {
wallBuckets[i % 3].push(group);
});
let width = minSpanForWall(wallPaintings[0].length);
let depth = Math.max(minSpanForWall(wallPaintings[1].length), minSpanForWall(wallPaintings[2].length));
let width = MIN_HALL_SIZE;
let depth = MIN_HALL_SIZE;
const segments: WallSegment[] = [];
const addWallFrames = (side: WallSide, groups: { label: string; paintings: Painting[] }[]) => {
if (groups.length === 0) return;
const flat: Painting[] = [];
const labels: string[] = [];
for (const g of groups) {
for (const p of g.paintings) {
if (flat.length >= MAX_FRAMES_PER_WALL) break;
flat.push(p);
}
if (flat.length <= MAX_FRAMES_PER_WALL) labels.push(g.label);
}
const label = labels.join(' · ');
const span = side === 'back' ? width : depth;
const { spanNeeded } = layoutRow(flat.length, span);
if (side === 'back') depth = Math.max(depth, spanNeeded);
else width = Math.max(width, spanNeeded);
segments.push({ side, label, paintings: flat, slots: [] });
};
walls.forEach((side, i) => addWallFrames(side, wallBuckets[i]));
for (let i = 0; i < 24; i++) {
const nextWidth = minSpanForWall(wallPaintings[0].length);
const nextDepth = Math.max(
minSpanForWall(wallPaintings[1].length),
minSpanForWall(wallPaintings[2].length)
);
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 y = HANG_HEIGHT;
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
for (const seg of segments) {
const row = layoutRow(seg.paintings.length, seg.side === 'back' ? width : depth);
seg.slots = row.slots.map((s) => {
if (seg.side === 'back') {
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: 0,
position: [s.offset, y, -halfD + inset],
};
}
if (seg.side === 'left') {
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: Math.PI / 2,
position: [-halfW + inset, y, s.offset],
};
}
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: -Math.PI / 2,
position: [halfW - inset, y, s.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
),
}));
return { width, depth, segments };
}
@@ -197,6 +275,108 @@ function computeFrameSize(aspect: number, maxW: number, maxH: number) {
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 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);
@@ -220,6 +400,12 @@ function usePaintingTexture(url: string | null) {
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;
@@ -247,6 +433,7 @@ function PaintingFrame({
rotationY,
maxWidth,
maxHeight,
wallSide,
onClick,
}: {
painting: Painting;
@@ -254,6 +441,7 @@ function PaintingFrame({
rotationY: number;
maxWidth: number;
maxHeight: number;
wallSide: WallSide;
onClick: () => void;
}) {
const [hovered, setHovered] = useState(false);
@@ -261,15 +449,20 @@ function PaintingFrame({
const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight);
const frameDepth = 0.06;
const matBorder = 0.05;
const url = galleryImageUrl(painting);
const hasImage = paintingHasGalleryImage(painting);
const url = hasImage ? galleryImageUrl(painting) : null;
const { texture, failed } = usePaintingTexture(url);
const showImage = hasImage && !failed && !!texture;
const showCanvas = !showImage;
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]);
}, [texture, showImage]);
return (
<group position={position} rotation={[0, rotationY, 0]}>
@@ -277,14 +470,15 @@ function PaintingFrame({
position={[0, height / 2 + 0.25, 0.3]}
angle={0.5}
penumbra={0.75}
intensity={hovered ? 2.6 : 1.9}
intensity={showCanvas ? (hovered ? 0.9 : 0.55) : hovered ? 2.6 : 1.9}
distance={4.5}
color="#fff8ee"
color={showCanvas ? '#e8dcc8' : '#fff8ee'}
/>
<mesh
position={[0, 0, frameDepth / 2]}
castShadow
renderOrder={1}
onClick={(e) => {
e.stopPropagation();
onClick();
@@ -293,22 +487,42 @@ function PaintingFrame({
onPointerOut={() => setHovered(false)}
>
<boxGeometry args={[width + matBorder * 2 + 0.04, height + matBorder * 2 + 0.04, frameDepth]} />
<meshStandardMaterial color={hovered ? '#c9a227' : '#6b4f1d'} roughness={0.45} metalness={0.5} />
</mesh>
<mesh position={[0, 0, frameDepth + 0.002]}>
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
<meshStandardMaterial color="#f5f0e6" roughness={0.95} />
</mesh>
<mesh position={[0, 0, frameDepth + 0.006]} renderOrder={2}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={texture}
color={texture ? '#ffffff' : failed ? '#8a7355' : '#4a3828'}
toneMapped={false}
<meshStandardMaterial
color={showCanvas ? (hovered ? '#8a7355' : '#5a4520') : hovered ? '#c9a227' : '#6b4f1d'}
roughness={0.45}
metalness={showCanvas ? 0.15 : 0.5}
/>
</mesh>
{!showCanvas && (
<mesh position={[0, 0, frameDepth + 0.002]} renderOrder={2}>
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
<meshStandardMaterial color="#f5f0e6" roughness={0.95} />
</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>
)}
</group>
);
}
@@ -326,7 +540,7 @@ function GalleryWall({
}) {
const [w, h] = size;
return (
<mesh position={position} rotation={rotation} receiveShadow>
<mesh position={position} rotation={rotation} receiveShadow renderOrder={0}>
<boxGeometry args={[w, h, WALL_THICKNESS]} />
<meshStandardMaterial color={color} roughness={0.92} />
</mesh>
@@ -345,17 +559,14 @@ function ExitPortal({
const [hovered, setHovered] = useState(false);
const glow = active || hovered;
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
e.stopPropagation();
onActivate();
};
return (
<group position={position}>
<mesh
position={[0, DOOR_HEIGHT / 2, 0]}
onClick={(e) => {
e.stopPropagation();
onActivate();
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<mesh position={[0, DOOR_HEIGHT / 2, 0]} castShadow>
<boxGeometry args={[DOOR_WIDTH, DOOR_HEIGHT, 0.12]} />
<meshStandardMaterial
color={glow ? '#d4af37' : '#8b6914'}
@@ -365,11 +576,28 @@ function ExitPortal({
metalness={0.35}
/>
</mesh>
{/* Large invisible target — easier to click from anywhere in the hall */}
<mesh
position={[0, DOOR_HEIGHT / 2, -0.12]}
rotation={[0, Math.PI, 0]}
onClick={handleActivate}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<planeGeometry args={[DOOR_WIDTH * 3, DOOR_HEIGHT * 1.8]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<Text
position={[0, DOOR_HEIGHT + 0.25, 0.08]}
position={[0, DOOR_HEIGHT + 0.25, -0.06]}
rotation={[0, Math.PI, 0]}
fontSize={0.22}
color={glow ? '#f5e6c8' : '#c9a96e'}
anchorX="center"
onClick={handleActivate}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
EXIT
</Text>
@@ -460,6 +688,7 @@ function ArtistHall({
rotationY={seg.slots[i].rotationY}
maxWidth={seg.slots[i].maxW}
maxHeight={seg.slots[i].maxH}
wallSide={seg.slots[i].side}
onClick={() => onPaintingClick(painting.id)}
/>
))}
@@ -493,6 +722,11 @@ function ArtistHall({
);
}
function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
pos.y = EYE_HEIGHT;
target.y = EYE_HEIGHT;
}
function CameraController({
position,
target,
@@ -502,8 +736,9 @@ function CameraController({
}) {
const { camera } = useThree();
useFrame(() => {
levelHorizontalView(position, target);
camera.position.lerp(position, 0.12);
camera.lookAt(target);
camera.lookAt(target.x, EYE_HEIGHT, target.z);
});
return null;
}
@@ -579,6 +814,7 @@ function NavigationPanel({
export default function VirtualGallery({
data,
active = true,
onPaintingClick,
onNavigateArtist,
onBack,
@@ -592,6 +828,7 @@ export default function VirtualGallery({
const [navigation, setNavigation] = useState<ArtistNavigation | null>(null);
const [navLoading, setNavLoading] = useState(false);
const [nearExit, setNearExit] = useState(false);
const [isLooking, setIsLooking] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -632,13 +869,15 @@ export default function VirtualGallery({
[layout.depth]
);
const initialTarget = useMemo(
() => new THREE.Vector3(0, HANG_HEIGHT, -layout.depth / 4),
() => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth / 4),
[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;
@@ -688,6 +927,8 @@ export default function VirtualGallery({
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);
@@ -698,9 +939,19 @@ export default function VirtualGallery({
);
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') && nearExit && !showExitNav) {
if ((e.key === 'e' || e.key === 'E') && !showExitNav) {
openExitNav();
}
};
@@ -710,10 +961,14 @@ export default function VirtualGallery({
const interval = setInterval(() => {
const keys = keysPressed.current;
if (keys.has('ArrowUp') || keys.has('w')) moveCamera(-0.1, 0, 0);
if (keys.has('ArrowDown') || keys.has('s')) moveCamera(0.1, 0, 0);
if (keys.has('ArrowLeft') || keys.has('a')) moveCamera(0, -0.08, 0);
if (keys.has('ArrowRight') || keys.has('d')) moveCamera(0, 0.08, 0);
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 () => {
@@ -721,13 +976,45 @@ export default function VirtualGallery({
window.removeEventListener('keyup', onKeyUp);
clearInterval(interval);
};
}, [moveCamera, nearExit, showExitNav, openExitNav]);
}, [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">
@@ -737,20 +1024,36 @@ export default function VirtualGallery({
<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">
<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>
)}
{nearExit && !showExitNav && (
<div className="gallery-exit-hint">At the exit click the doorway or press <kbd>E</kbd></div>
{!showExitNav && (
<div className="gallery-exit-hint">
Click the exit door, <kbd>E</kbd>, or <strong>Exit </strong> above
</div>
)}
<Canvas shadows camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}>
<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, 55]} />
<ambientLight intensity={0.42} />
@@ -782,19 +1085,20 @@ export default function VirtualGallery({
<div className="control-pad">
<button onClick={() => moveCamera(-0.35, 0, 0)} title="Walk forward"></button>
<div className="control-row">
<button onClick={() => moveCamera(0, -0.28, 0)} title="Step left"></button>
<button onClick={() => moveCamera(0.35, 0, 0)} title="Walk toward exit"></button>
<button onClick={() => moveCamera(0, 0.28, 0)} title="Step right"></button>
<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 into the room</li>
<li><kbd>S</kbd> / <kbd></kbd> Walk toward the exit</li>
<li><kbd>A</kbd> / <kbd>D</kbd> Step sideways along the walls</li>
<li>Paintings hang on three walls the centre stays open</li>
<li>Click a painting for details, or use the exit to visit related artists</li>
<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 the exit door or <kbd>E</kbd> to visit related artists</li>
</ul>
</div>
</div>
+14
View File
@@ -3,6 +3,20 @@
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%);
}
.gallery-session-suspended {
position: fixed;
inset: 0;
z-index: 0;
visibility: hidden;
pointer-events: none;
}
.home-overlay {
position: relative;
z-index: 10;
min-height: 100vh;
}
.site-header {
text-align: center;
padding: 24px 16px 8px;
+84 -63
View File
@@ -16,6 +16,9 @@ type View =
export default function HomePage() {
const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
null
);
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
const [viewStart, setViewStart] = useState(-800);
const [viewEnd, setViewEnd] = useState(2025);
@@ -36,6 +39,14 @@ export default function HomePage() {
.catch(() => {});
}, []);
useEffect(() => {
if (view.type === 'gallery') {
setGallerySession({ artistId: view.artistId, data: view.data });
} else if (view.type === 'timeline') {
setGallerySession(null);
}
}, [view]);
const loadTimelineData = useCallback(async (start: number, end: number) => {
try {
setLoading(true);
@@ -65,6 +76,7 @@ export default function HomePage() {
const handleArtistClick = async (artistId: number) => {
try {
const data = await api.getArtist(artistId);
setGallerySession({ artistId, data });
setView({ type: 'gallery', artistId, data });
} catch {
setError('Failed to load artist gallery.');
@@ -89,71 +101,80 @@ export default function HomePage() {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo });
};
if (view.type === 'gallery') {
return (
<VirtualGallery
data={view.data}
onPaintingClick={handlePaintingClick}
onNavigateArtist={handleArtistClick}
onBack={() => setView({ type: 'timeline' })}
onBioClick={() => handleBioClick(view.data, view)}
/>
);
}
if (view.type === 'painting') {
return (
<PaintingDetailView
data={view.data}
onBack={() => setView(view.returnTo)}
onPaintingClick={handlePaintingClick}
onArtistBio={async () => {
const artistData = await api.getArtist(view.data.painting.artist_id);
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}}
/>
);
}
if (view.type === 'bio') {
return (
<ArtistBio
artist={view.data.artist}
onBack={() => setView(view.returnTo)}
onEnterGallery={() => setView({ type: 'gallery', artistId: view.artistId, data: view.data })}
/>
);
}
const galleryActive = view.type === 'gallery';
return (
<div className="home-page">
<header className="site-header">
<h1>Virtual Art Gallery</h1>
<p className="site-subtitle">Explore the flowing connections of art history</p>
</header>
<Timeline
eras={timelineData.eras}
viewStart={viewStart}
viewEnd={viewEnd}
onViewChange={handleViewChange}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
/>
{error && <div className="error-banner">{error}</div>}
{loading ? (
<div className="loading">Loading art history...</div>
) : (
<MovementBands
movements={timelineData.movements}
artists={artists}
viewStart={viewStart}
viewEnd={viewEnd}
onArtistClick={handleArtistClick}
/>
<>
{gallerySession && (
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
<VirtualGallery
data={gallerySession.data}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onNavigateArtist={handleArtistClick}
onBack={() => setView({ type: 'timeline' })}
onBioClick={() => handleBioClick(gallerySession.data, { type: 'gallery', ...gallerySession })}
/>
</div>
)}
</div>
{view.type === 'painting' && (
<div className="home-overlay">
<PaintingDetailView
data={view.data}
onBack={() => setView(view.returnTo)}
onPaintingClick={handlePaintingClick}
onArtistBio={async () => {
const artistData = await api.getArtist(view.data.painting.artist_id);
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}}
/>
</div>
)}
{view.type === 'bio' && (
<div className="home-overlay">
<ArtistBio
artist={view.data.artist}
onBack={() => setView(view.returnTo)}
onEnterGallery={() =>
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
}
/>
</div>
)}
{view.type === 'timeline' && (
<div className="home-page">
<header className="site-header">
<h1>Virtual Art Gallery</h1>
<p className="site-subtitle">Explore the flowing connections of art history</p>
</header>
<Timeline
eras={timelineData.eras}
viewStart={viewStart}
viewEnd={viewEnd}
onViewChange={handleViewChange}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
/>
{error && <div className="error-banner">{error}</div>}
{loading ? (
<div className="loading">Loading art history...</div>
) : (
<MovementBands
movements={timelineData.movements}
artists={artists}
viewStart={viewStart}
viewEnd={viewEnd}
onArtistClick={handleArtistClick}
/>
)}
</div>
)}
</>
);
}