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:
2026-08-14 11:15:59 +08:00
parent a3095ead38
commit 8ebb9211c5
+60 -15
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import { useFrame } from '@react-three/fiber'; import { useFrame } from '@react-three/fiber';
import * as THREE from 'three'; import * as THREE from 'three';
@@ -11,8 +11,10 @@ import { CardObjectMesh } from './CardMesh';
const HALF_WINDOW = 3; const HALF_WINDOW = 3;
/** Angular spacing between adjacent cards in the arc, in radians. */ /** Angular spacing between adjacent cards in the arc, in radians. */
const ARC_STEP = 0.32; 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; 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 * 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 * 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 [active, setActive] = useState(0);
const hasCards = count > 0; const hasCards = count > 0;
const centerRef = useRef<THREE.Group>(null); 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 step = (dir: number) => setActive((a) => (a + dir + count) % count);
const radius = arcRadius(cardWidth);
const visible = useMemo( const visible = useMemo(
() => () =>
@@ -70,10 +77,15 @@ export default function DeckViewer({ object }: { object: TTSObject }) {
key={i} key={i}
card={card} card={card}
k={k} k={k}
radius={radius}
groupRef={k === 0 ? centerRef : undefined} groupRef={k === 0 ? centerRef : undefined}
/> />
))} ))}
<FitActive targetRef={centerRef} didFit={didFit} /> <FitActive
targetRef={centerRef}
didFit={didFit}
onMeasure={setCardWidth}
/>
</> </>
) : ( ) : (
<CardObjectMesh object={object} /> <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 * Fits the camera once, on the first frame, to frame the active card from the
* always settles in the same slot, so no refit is needed while navigating — * front. Runs in a frame callback (not an effect) so the active card has been
* that would restart the camera tween on every step and feel laggy. * 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({ function FitActive({
targetRef, targetRef,
didFit, didFit,
onMeasure,
}: { }: {
targetRef: RefObject<THREE.Group | null>; targetRef: RefObject<THREE.Group | null>;
didFit: RefObject<boolean>; didFit: RefObject<boolean>;
onMeasure: (width: number) => void;
}) { }) {
const bounds = useBounds(); 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; if (didFit.current) return;
didFit.current = true; didFit.current = true;
const node = targetRef.current; // The card's front face points toward +Z, so put the camera in front of it
if (node) bounds.refresh(node).fit(); // and look straight at it.
}, [bounds, targetRef, didFit]); bounds
.moveTo([center.x, center.y, center.z + distance])
.lookAt({ target: center });
});
return null; return null;
} }
@@ -110,17 +141,19 @@ function FitActive({
function CarouselCard({ function CarouselCard({
card, card,
k, k,
radius,
groupRef, groupRef,
}: { }: {
card: TTSObject; card: TTSObject;
k: number; k: number;
radius: number;
groupRef?: RefObject<THREE.Group | null>; groupRef?: RefObject<THREE.Group | null>;
}) { }) {
const localRef = useRef<THREE.Group>(null); const localRef = useRef<THREE.Group>(null);
const group = groupRef ?? localRef; const group = groupRef ?? localRef;
// Start at the target so the first render doesn't tween into place. // Start at the target so the first render doesn't tween into place.
const state = useRef(slotTransform(k)); const state = useRef(slotTransform(k, radius));
const target = useMemo(() => slotTransform(k), [k]); const target = useMemo(() => slotTransform(k, radius), [k, radius]);
useFrame((_, dt) => { useFrame((_, dt) => {
const g = group.current; const g = group.current;
@@ -153,18 +186,30 @@ interface Slots {
} }
/** World transform for a card at arc offset `k` (0 = front and center). */ /** 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; const ang = k * ARC_STEP;
// Side cards turn edge-on (album flow); the active card stays forward. // Side cards turn edge-on (album flow); the active card stays forward.
const turn = k === 0 ? 0 : Math.sign(k) * (Math.PI / 2); const turn = k === 0 ? 0 : Math.sign(k) * (Math.PI / 2);
return { return {
x: Math.sin(ang) * ARC_RADIUS, x: Math.sin(ang) * radius,
z: Math.cos(ang) * ARC_RADIUS, z: Math.cos(ang) * radius,
rot: turn, rot: turn,
scale: 1.15 - 0.15 * Math.abs(k), 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. */ /** Prev/next controls and a counter, rendered as the scene overlay. */
function CarouselControls({ function CarouselControls({
active, active,