fix(web): frame deck carousel on active card
Fit the camera in a frame callback so the active card has settled into its arc slot first, and aim it straight at the card's front face instead of keeping the initial side angle. Measure the card's width and widen the arc radius so wide cards no longer clip their neighbors.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
@@ -11,8 +11,10 @@ import { CardObjectMesh } from './CardMesh';
|
||||
const HALF_WINDOW = 3;
|
||||
/** Angular spacing between adjacent cards in the arc, in radians. */
|
||||
const ARC_STEP = 0.32;
|
||||
/** Radius of the arc, in world units (large enough for card widths). */
|
||||
/** Minimum radius of the arc, in world units. */
|
||||
const ARC_RADIUS = 3.2;
|
||||
/** Extra clearance between the active card's edge and its neighbors, in world units. */
|
||||
const ARC_PADDING = 0.2;
|
||||
/**
|
||||
* A deck carousel: the deck's contained cards are fanned in a 3D arc with the
|
||||
* active card front and center. Prev/next controls step through the deck, each
|
||||
@@ -35,8 +37,13 @@ export default function DeckViewer({ object }: { object: TTSObject }) {
|
||||
const [active, setActive] = useState(0);
|
||||
const hasCards = count > 0;
|
||||
const centerRef = useRef<THREE.Group>(null);
|
||||
// The active card's world-space width, measured once it's laid out. Used to
|
||||
// widen the arc so neighbors clear the card's edges (a fixed radius only fits
|
||||
// square cards; wider cards clip their neighbors).
|
||||
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||||
|
||||
const step = (dir: number) => setActive((a) => (a + dir + count) % count);
|
||||
const radius = arcRadius(cardWidth);
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
@@ -70,10 +77,15 @@ export default function DeckViewer({ object }: { object: TTSObject }) {
|
||||
key={i}
|
||||
card={card}
|
||||
k={k}
|
||||
radius={radius}
|
||||
groupRef={k === 0 ? centerRef : undefined}
|
||||
/>
|
||||
))}
|
||||
<FitActive targetRef={centerRef} didFit={didFit} />
|
||||
<FitActive
|
||||
targetRef={centerRef}
|
||||
didFit={didFit}
|
||||
onMeasure={setCardWidth}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CardObjectMesh object={object} />
|
||||
@@ -83,25 +95,44 @@ export default function DeckViewer({ object }: { object: TTSObject }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fits the camera once, on mount, to frame the active card. The center card
|
||||
* always settles in the same slot, so no refit is needed while navigating —
|
||||
* that would restart the camera tween on every step and feel laggy.
|
||||
* Fits the camera once, on the first frame, to frame the active card from the
|
||||
* front. Runs in a frame callback (not an effect) so the active card has been
|
||||
* moved to its arc slot by its own `useFrame` first — otherwise the group is
|
||||
* still at the origin and the camera would frame the carousel center.
|
||||
*
|
||||
* The card's front face points toward +Z, so the camera is placed directly in
|
||||
* front of it and looks straight at it, rather than keeping its initial side
|
||||
* angle (which drei's `fit()` would do). The active card always settles in the
|
||||
* same slot, so no refit is needed while navigating — that would restart the
|
||||
* camera tween on every step and feel laggy.
|
||||
*/
|
||||
function FitActive({
|
||||
targetRef,
|
||||
didFit,
|
||||
onMeasure,
|
||||
}: {
|
||||
targetRef: RefObject<THREE.Group | null>;
|
||||
didFit: RefObject<boolean>;
|
||||
onMeasure: (width: number) => void;
|
||||
}) {
|
||||
const bounds = useBounds();
|
||||
|
||||
useEffect(() => {
|
||||
useFrame(() => {
|
||||
const node = targetRef.current;
|
||||
if (!node) return;
|
||||
bounds.refresh(node);
|
||||
const { size, center, distance } = bounds.getSize();
|
||||
// Report the active card's width so the arc radius can widen for wide
|
||||
// cards (see `arcRadius`). Runs every frame until the radius settles.
|
||||
onMeasure(size.x);
|
||||
if (didFit.current) return;
|
||||
didFit.current = true;
|
||||
const node = targetRef.current;
|
||||
if (node) bounds.refresh(node).fit();
|
||||
}, [bounds, targetRef, didFit]);
|
||||
// The card's front face points toward +Z, so put the camera in front of it
|
||||
// and look straight at it.
|
||||
bounds
|
||||
.moveTo([center.x, center.y, center.z + distance])
|
||||
.lookAt({ target: center });
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -110,17 +141,19 @@ function FitActive({
|
||||
function CarouselCard({
|
||||
card,
|
||||
k,
|
||||
radius,
|
||||
groupRef,
|
||||
}: {
|
||||
card: TTSObject;
|
||||
k: number;
|
||||
radius: number;
|
||||
groupRef?: RefObject<THREE.Group | null>;
|
||||
}) {
|
||||
const localRef = useRef<THREE.Group>(null);
|
||||
const group = groupRef ?? localRef;
|
||||
// Start at the target so the first render doesn't tween into place.
|
||||
const state = useRef(slotTransform(k));
|
||||
const target = useMemo(() => slotTransform(k), [k]);
|
||||
const state = useRef(slotTransform(k, radius));
|
||||
const target = useMemo(() => slotTransform(k, radius), [k, radius]);
|
||||
|
||||
useFrame((_, dt) => {
|
||||
const g = group.current;
|
||||
@@ -153,18 +186,30 @@ interface Slots {
|
||||
}
|
||||
|
||||
/** World transform for a card at arc offset `k` (0 = front and center). */
|
||||
function slotTransform(k: number): Slots {
|
||||
function slotTransform(k: number, radius: number): Slots {
|
||||
const ang = k * ARC_STEP;
|
||||
// Side cards turn edge-on (album flow); the active card stays forward.
|
||||
const turn = k === 0 ? 0 : Math.sign(k) * (Math.PI / 2);
|
||||
return {
|
||||
x: Math.sin(ang) * ARC_RADIUS,
|
||||
z: Math.cos(ang) * ARC_RADIUS,
|
||||
x: Math.sin(ang) * radius,
|
||||
z: Math.cos(ang) * radius,
|
||||
rot: turn,
|
||||
scale: 1.15 - 0.15 * Math.abs(k),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Arc radius that keeps the nearest neighbors clear of the active card's
|
||||
* edges. A neighbor at `k = 1` sits at `x = sin(ARC_STEP) * radius`, so the
|
||||
* radius must exceed `cardWidth / 2 / sin(ARC_STEP)` for the neighbor to clear
|
||||
* the card's half-width (plus padding). Falls back to the minimum when the
|
||||
* card width isn't known yet.
|
||||
*/
|
||||
function arcRadius(cardWidth: number | null): number {
|
||||
if (cardWidth == null) return ARC_RADIUS;
|
||||
return Math.max(ARC_RADIUS, (cardWidth / 2 + ARC_PADDING) / Math.sin(ARC_STEP));
|
||||
}
|
||||
|
||||
/** Prev/next controls and a counter, rendered as the scene overlay. */
|
||||
function CarouselControls({
|
||||
active,
|
||||
|
||||
Reference in New Issue
Block a user