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:
co-authored by
Cursor
parent
08f99d7a29
commit
792a0b1a77
@@ -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);
|
||||
|
||||
@@ -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}'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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user