Add guided tours and unify left-to-right hall wall hang.
Visitors walk published tours in a 3D hall with stop notes; curators edit drafts via Tour editor. All galleries (artist, movement, tour) place the first work left of the entrance view and the last on the right. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
48bd17e985
commit
5ddc3fd7f0
@@ -10,6 +10,7 @@ import type {
|
||||
ArtistPeriod,
|
||||
ArtistNavigation,
|
||||
MovementArtistGroup,
|
||||
TourGalleryDetail,
|
||||
} from '../types';
|
||||
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
|
||||
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||
@@ -75,7 +76,12 @@ interface MovementGalleryProps extends BaseGalleryProps {
|
||||
data: MovementGalleryDetail;
|
||||
}
|
||||
|
||||
type Props = ArtistGalleryProps | MovementGalleryProps;
|
||||
interface TourGalleryProps extends BaseGalleryProps {
|
||||
mode: 'tour';
|
||||
data: TourGalleryDetail;
|
||||
}
|
||||
|
||||
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
|
||||
|
||||
const WALL_HEIGHT = 4.2;
|
||||
const WALL_THICKNESS = 0.18;
|
||||
@@ -97,9 +103,6 @@ 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;
|
||||
@@ -290,36 +293,18 @@ function minSpanForWall(paintings: Painting[], maxRows: number = paintings.lengt
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit order along the side walls: first half on the left (starting at the
|
||||
* entrance, left of the opening view), second half on the right (ending at the
|
||||
* entrance). Back wall stays empty so first/last always sit on left/right.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = [...paintings].sort(comparePaintingsChronological);
|
||||
const mid = Math.ceil(ordered.length / 2);
|
||||
return [
|
||||
orderPaintingsForWallDisplay(back),
|
||||
orderPaintingsForWallDisplay(left),
|
||||
orderPaintingsForWallDisplay(right),
|
||||
[] as Painting[],
|
||||
ordered.slice(0, mid),
|
||||
ordered.slice(mid),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -377,12 +362,13 @@ function layoutWallSlots(
|
||||
position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA],
|
||||
});
|
||||
} else if (side === 'left') {
|
||||
// Flip along-wall offset so index 0 is at the entrance (+Z), left of view.
|
||||
slots.push({
|
||||
maxW: s.maxW,
|
||||
maxH: s.maxH,
|
||||
rotationY: Math.PI / 2,
|
||||
side,
|
||||
position: [-halfW + inset + WALL_STANDOFF, y, s.offset],
|
||||
position: [-halfW + inset + WALL_STANDOFF, y, -s.offset],
|
||||
});
|
||||
} else {
|
||||
slots.push({
|
||||
@@ -1690,16 +1676,38 @@ export default function VirtualGallery(props: Props) {
|
||||
} = props;
|
||||
|
||||
const isMovement = props.mode === 'movement';
|
||||
const hallKey = isMovement ? props.data.movement.id : props.data.artist.id;
|
||||
const hallTitle = isMovement ? props.data.movement.name : props.data.artist.name;
|
||||
const movementColor = isMovement ? props.data.movement.color : props.data.artist.movement_color;
|
||||
const isTour = props.mode === 'tour';
|
||||
const isWingedHall = isMovement || isTour;
|
||||
const hallKey = isTour
|
||||
? props.data.tour.id
|
||||
: isMovement
|
||||
? props.data.movement.id
|
||||
: props.data.artist.id;
|
||||
const hallTitle = isTour
|
||||
? props.data.tour.title
|
||||
: isMovement
|
||||
? props.data.movement.name
|
||||
: props.data.artist.name;
|
||||
const movementColor = isTour
|
||||
? DEFAULT_MOVEMENT_COLOR
|
||||
: isMovement
|
||||
? props.data.movement.color
|
||||
: props.data.artist.movement_color;
|
||||
const initialPaintings = useMemo(() => {
|
||||
if (props.mode === 'movement') {
|
||||
return [...props.data.paintings].sort(comparePaintingsChronological);
|
||||
}
|
||||
return props.data.paintings;
|
||||
}, [props.mode, props.mode === 'movement' ? props.data.movement.id : props.data.artist.id, props.data.paintings]);
|
||||
const initialPeriods = isMovement ? [] : props.data.periods;
|
||||
}, [
|
||||
props.mode,
|
||||
props.mode === 'tour'
|
||||
? props.data.tour.id
|
||||
: props.mode === 'movement'
|
||||
? props.data.movement.id
|
||||
: props.data.artist.id,
|
||||
props.data.paintings,
|
||||
]);
|
||||
const initialPeriods = props.mode === 'artist' ? props.data.periods : [];
|
||||
|
||||
const [paintings, setPaintings] = useState(initialPaintings);
|
||||
const [periods, setPeriods] = useState(initialPeriods);
|
||||
@@ -1779,8 +1787,8 @@ export default function VirtualGallery(props: Props) {
|
||||
);
|
||||
|
||||
const movementHalls = useMemo(
|
||||
() => (isMovement ? buildAllMovementHallLayouts(paintings) : []),
|
||||
[isMovement, paintings]
|
||||
() => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
|
||||
[isWingedHall, paintings]
|
||||
);
|
||||
|
||||
const [hallIndex, setHallIndex] = useState(0);
|
||||
@@ -1790,11 +1798,11 @@ export default function VirtualGallery(props: Props) {
|
||||
}, [hallKey]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (isMovement && movementHalls.length > 0) {
|
||||
if (isWingedHall && movementHalls.length > 0) {
|
||||
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
|
||||
}
|
||||
return buildHallLayout(paintings, periods);
|
||||
}, [isMovement, movementHalls, hallIndex, paintings, periods]);
|
||||
}, [isWingedHall, movementHalls, hallIndex, paintings, periods]);
|
||||
|
||||
const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0);
|
||||
|
||||
@@ -1809,7 +1817,7 @@ export default function VirtualGallery(props: Props) {
|
||||
return computeSideWallWindows(layout as MovementHallLayout, interiorStyle);
|
||||
}, [isMovement, interiorStyle, layout]);
|
||||
|
||||
const hasNextHall = isMovement && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
||||
const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
||||
|
||||
const halfW = layout.width / 2 - 0.55;
|
||||
const halfD = layout.depth / 2 - 0.35;
|
||||
@@ -1869,7 +1877,7 @@ export default function VirtualGallery(props: Props) {
|
||||
}, [hallKey, initialPos, initialTarget]);
|
||||
|
||||
const openExitNav = useCallback(async () => {
|
||||
if (isMovement) {
|
||||
if (isWingedHall) {
|
||||
setShowExitNav(true);
|
||||
return;
|
||||
}
|
||||
@@ -1883,9 +1891,9 @@ export default function VirtualGallery(props: Props) {
|
||||
} finally {
|
||||
setNavLoading(false);
|
||||
}
|
||||
}, [isMovement, onBack, isMovement ? undefined : props.data.artist.id]);
|
||||
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
||||
|
||||
const backExitZ = isMovement ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
|
||||
const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
|
||||
const frontPassageZ = layout.depth / 2 - 0.55;
|
||||
|
||||
const moveCamera = useCallback(
|
||||
@@ -1910,7 +1918,7 @@ export default function VirtualGallery(props: Props) {
|
||||
pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
|
||||
target.x = Math.max(-halfW, Math.min(halfW, target.x));
|
||||
|
||||
if (isMovement) {
|
||||
if (isWingedHall) {
|
||||
pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
|
||||
target.z = Math.max(-halfD, Math.min(halfD, target.z));
|
||||
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||
@@ -1930,7 +1938,7 @@ export default function VirtualGallery(props: Props) {
|
||||
setCamPos(pos);
|
||||
setCamTarget(target);
|
||||
},
|
||||
[halfW, halfD, exitZ, isMovement, backExitZ, frontPassageZ, hasNextHall]
|
||||
[halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1947,7 +1955,7 @@ export default function VirtualGallery(props: Props) {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
keysPressed.current.add(e.key);
|
||||
if ((e.key === 'e' || e.key === 'E') && !showExitNav) {
|
||||
if (isMovement && nearPassage && hasNextHall) {
|
||||
if (isWingedHall && nearPassage && hasNextHall) {
|
||||
goToNextHall();
|
||||
} else {
|
||||
openExitNav();
|
||||
@@ -1975,15 +1983,19 @@ export default function VirtualGallery(props: Props) {
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [active, moveCamera, showExitNav, openExitNav, isMovement, nearPassage, hasNextHall, goToNextHall]);
|
||||
}, [active, moveCamera, showExitNav, openExitNav, isWingedHall, nearPassage, hasNextHall, goToNextHall]);
|
||||
|
||||
const handleNavigate = (artistId: number) => {
|
||||
if (isMovement) return;
|
||||
if (isWingedHall) return;
|
||||
setShowExitNav(false);
|
||||
props.onNavigateArtist(artistId);
|
||||
};
|
||||
|
||||
const subtitle = isMovement
|
||||
const subtitle = isTour
|
||||
? `Guided tour · ${paintings.length} works${
|
||||
movementHalls.length > 1 ? ` · Wing ${hallIndex + 1}/${movementHalls.length}` : ''
|
||||
}`
|
||||
: isMovement
|
||||
? interiorStyle
|
||||
? `${interiorStyle.subtitle} · ${paintings.length} works${
|
||||
movementHalls.length > 1
|
||||
@@ -2029,7 +2041,7 @@ export default function VirtualGallery(props: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const exitHint = isMovement ? (
|
||||
const exitHint = isWingedHall ? (
|
||||
<>
|
||||
Back wall: <kbd>E</kbd> for wing navigator · Front arch: next wing
|
||||
{movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''}
|
||||
@@ -2039,11 +2051,15 @@ export default function VirtualGallery(props: Props) {
|
||||
);
|
||||
|
||||
const hallSubtitle =
|
||||
isMovement && 'yearLabel' in layout
|
||||
? `Wing ${hallIndex + 1} of ${movementHalls.length} · ${(layout as MovementHallLayout).yearLabel}`
|
||||
isWingedHall && 'yearLabel' in layout
|
||||
? `Wing ${hallIndex + 1} of ${movementHalls.length}${
|
||||
isMovement ? ` · ${(layout as MovementHallLayout).yearLabel}` : ''
|
||||
}`
|
||||
: undefined;
|
||||
|
||||
const instructionsTitle = isMovement
|
||||
const instructionsTitle = isTour
|
||||
? `${hallTitle} · Guided tour`
|
||||
: isMovement
|
||||
? interiorStyle
|
||||
? `${hallTitle} · ${interiorStyle.label}`
|
||||
: `${hallTitle} Gallery`
|
||||
@@ -2059,9 +2075,9 @@ export default function VirtualGallery(props: Props) {
|
||||
</div>
|
||||
<div className="gallery-header-meta">
|
||||
<button type="button" className="gallery-exit-btn" onClick={openExitNav}>
|
||||
{isMovement ? 'Wings / Exit →' : 'Exit →'}
|
||||
{isWingedHall ? 'Wings / Exit →' : 'Exit →'}
|
||||
</button>
|
||||
{!isMovement && (
|
||||
{!isWingedHall && (
|
||||
<button className="gallery-bio-btn" onClick={props.onBioClick}>Biography</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -2124,11 +2140,11 @@ export default function VirtualGallery(props: Props) {
|
||||
movementColor={movementColor}
|
||||
interiorStyle={interiorStyle}
|
||||
imageRevisions={imageRevisions}
|
||||
showCaptions={isMovement}
|
||||
showCaptions={isWingedHall}
|
||||
onPaintingClick={onPaintingClick}
|
||||
onExitActivate={openExitNav}
|
||||
nearExit={nearExit}
|
||||
movementMode={isMovement}
|
||||
movementMode={isWingedHall}
|
||||
computedWindows={computedWindows}
|
||||
hasNextHall={hasNextHall}
|
||||
onNextHall={goToNextHall}
|
||||
@@ -2139,7 +2155,7 @@ export default function VirtualGallery(props: Props) {
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
{!isMovement && showExitNav && (
|
||||
{!isWingedHall && showExitNav && (
|
||||
<NavigationPanel
|
||||
navigation={navigation}
|
||||
loading={navLoading}
|
||||
@@ -2148,7 +2164,7 @@ export default function VirtualGallery(props: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMovement && showExitNav && (
|
||||
{isWingedHall && showExitNav && (
|
||||
<MovementHallNavPanel
|
||||
movementName={hallTitle}
|
||||
halls={movementHalls}
|
||||
@@ -2176,8 +2192,8 @@ export default function VirtualGallery(props: Props) {
|
||||
<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>
|
||||
{isMovement ? (
|
||||
<li>Click a painting to view details{isTour ? ' and tour notes' : ' and influences'}</li>
|
||||
{isWingedHall ? (
|
||||
<>
|
||||
<li>Date and artist labels appear below each frame</li>
|
||||
<li>Works hang on left & right walls — up to ~55 per wing</li>
|
||||
|
||||
Reference in New Issue
Block a user