feat(remotion): rework winner celebration and spin motion

The animation's weakest moment was its final frame, which is also the frame
most chat clients show as the GIF's poster image.

Celebration:
- Confetti launches from two cannons framing the wheel instead of bursting
  inside the hub, using closed-form drag ballistics so chips decelerate and
  flutter rather than flying a clean parabola. The field previously drained
  from 70 particles to 3 before the GIF ended; it now stays populated, and a
  2500ms hold ends with 68 on screen instead of 0.
- The opening frame rendered at opacity 0, throwing away the one frame where
  particles are still clustered. Removed the fade-in and made the tail
  frame-relative and quantized to bound its palette cost.
- Chips streak along their direction of travel in proportion to speed, faking
  the motion blur a GIF cannot carry. A second volley, sized to the
  celebration window, keeps long holds from thinning out.
- Each theme carries its own confetti palette. Slice colors are tuned to hold
  dark text over a large area, which left five of six mono colors under 2.4:1
  against the background and invisible at particle size.
- The winner's name appears over the hub during the hold. At a 200px chat
  display the pointer is ~17px and a slice label ~9px, so the poster frame did
  not say who won.
- Winner emphasis is an opaque stroke, not a drop-shadow. A filter on a Pie
  paints outside the shape, so later siblings overpainted it and only one
  neighbour ever showed the halo.

Motion:
- Turn count scales with the frame budget and slice width. A fixed 5 turns put
  peak speed at ~40deg per frame regardless of option count, so 65 of 98
  frames advanced more than a full slice at 32 options and the wheel appeared
  to run backwards. Turn counts floor rather than round, since rounding up
  reintroduces the aliasing, and stay integral so the winner still lands on
  the detent.
- The spin launch now blends into the deceleration at a matched slope. The
  previous curve started at maximum speed, and a naive ease-in jumped from 18
  to 41deg per frame in a single frame.
- A damped recoil rocks the wheel into its detent, resolving to exactly zero
  so the landing and the flapper's rest position are unaffected.
- The pointer is drawn as SVG with a real outline and a tick as slices pass.
  clip-path had been discarding its border and shadow entirely.

Legibility:
- Labels flip by their rest-frame angle so every name reads upright in the
  final frame. This reverses an earlier decision to leave them slice-aligned;
  three of eight names rendered mirrored on a default wheel.
- Label text renders inside a promoted compositing layer, which switches
  Chromium from LCD sub-pixel to grayscale antialiasing. Sub-pixel fringes put
  2178 colored pixels into an all-grayscale theme and consumed 255 of 256
  palette entries. Promoting the one rotating container rather than each label
  keeps render time flat on dense wheels.
- Line boxes leave room for stacked Vietnamese diacritics and shrink to fit
  rather than clipping.
- Slice colors are ordered for colorblind separation, and the last slice
  avoids reusing the first slice's color when the option count leaves a
  remainder of one.
This commit is contained in:
tiennm99 committed 2026-07-25 15:18:04 +07:00
1 parent ad3e50b7f4
commit f9accfd70a
10 files changed
+1017 -114

No files matched your search

+25 -7
View File
@@ -3,12 +3,11 @@ import {createConfettiParticles, getConfettiParticleState} from './confetti-layo
/**
* Winner-celebration confetti burst. Renders nothing before `startFrame`, then
* fires a deterministic paper-strip burst from the wheel center through the end
* of the composition.
* fires a deterministic two-cannon burst through the end of the composition.
*
* @param {object} props
* @param {number} props.startFrame frame the burst begins on (winner reveal)
* @param {string[]} props.colors palette (theme slice colors)
* @param {string[]} props.colors palette (theme confetti colors)
* @param {number|string} props.seed reproducibility seed (e.g. winner index)
* @param {number} [props.count] particle count
*/
@@ -24,22 +23,34 @@ export const Confetti = ({startFrame, colors, seed, count = 70}) => {
const lifetimeFrames = Math.max(1, durationInFrames - 1 - startFrame);
const localFrame = frame - startFrame;
const seconds = localFrame / fps;
const progress = localFrame / lifetimeFrames;
const opacity = interpolate(progress, [0, 0.06, 0.7, 1], [0, 1, 1, 0], {
// No fade-in: the opening frame is the only one where particles are still
// clustered at the muzzles, and fading it out threw the pop away.
const fadeFrames = Math.min(4, Math.max(1, Math.round(lifetimeFrames * 0.25)));
const fade = interpolate(localFrame, [lifetimeFrames - fadeFrames, lifetimeFrames], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
// Quantized so the tail introduces at most a handful of blended colors: a
// 256-entry GIF palette cannot afford a fresh alpha level on every frame.
const opacity = Math.ceil(fade * 4) / 4;
const particles = createConfettiParticles({
colors,
count,
random: (key) => random(`${seed}-${key}`),
size,
windowSeconds: lifetimeFrames / fps,
});
return (
<AbsoluteFill style={{opacity, pointerEvents: 'none', zIndex: 10}}>
{particles.map((particle, index) => {
// Staggered launch: an unlaunched particle would otherwise stack on its
// muzzle and the pile reads as a clump of paper sitting in the corner.
if (seconds < particle.delay) {
return null;
}
const state = getConfettiParticleState(particle, seconds);
return (
@@ -47,12 +58,19 @@ export const Confetti = ({startFrame, colors, seed, count = 70}) => {
key={`confetti-${index}`}
style={{
background: particle.color,
borderRadius: 1,
// Hard 1px ring, no blur: separates chips from same-colored
// slices without adding a palette entry, since white already
// strokes the slice borders.
boxShadow: '0 0 0 1px #ffffff',
height: particle.height,
left: state.x,
position: 'absolute',
top: state.y,
transform: `translate(-50%, -50%) rotate(${state.rotation}deg)`,
// scaleX runs along the body's own long axis, and the rotation is
// already aligned with travel while streaking, so this elongates
// in the direction of motion and leaves the short axis at its
// quantisation-safe minimum.
transform: `translate(-50%, -50%) rotate(${state.rotation}deg) scaleX(${state.stretch})`,
transformOrigin: '50% 50%',
width: particle.width,
}}
+128 -29
View File
@@ -4,13 +4,20 @@ import {Circle, Pie} from '@remotion/shapes';
import {
cssDegreesToPieRadians,
getFinalWheelRotationDegrees,
getPointerDeflectionDegrees,
getSliceCenterDegrees,
getSliceDegrees,
getSpinRecoilDegrees,
getSpinTurns,
getWheelRotationDegrees,
rightPointerClipPath,
} from './wheel-layout.js';
import {getRadialLabelLayout} from './wheel-label-layout.js';
import {getTheme} from './themes.js';
import {
estimateTextWidth,
getLabelLines,
getRadialLabelLayout,
labelLineHeightRatio,
} from './wheel-label-layout.js';
import {getSliceColorIndex, getTheme} from './themes.js';
import {Confetti} from './Confetti.jsx';
const baseFont =
@@ -28,13 +35,18 @@ export const WheelComposition = (props) => {
const {durationInFrames, fps, width, height} = useVideoConfig();
const theme = getTheme(props.theme);
const spinFrames = Math.max(1, durationInFrames - Math.round((props.holdMs / 1000) * fps));
const holdStartFrame = spinFrames;
const settle = interpolate(frame, [holdStartFrame, durationInFrames - 1], [0, 1], {
// Any ease-out ends at zero speed, so the wheel is already visually stopped a
// few frames before it mathematically settles. Firing the celebration there
// lands it on the perceived stop instead of after a pause, and costs only a
// fraction of a degree of rotation.
const revealFrame = Math.max(0, spinFrames - Math.round(fps * 0.33));
const revealProgress = interpolate(frame, [revealFrame, durationInFrames - 1], [0, 1], {
easing: Easing.out(Easing.sin),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const winnerPulse = frame >= holdStartFrame ? Math.sin(settle * Math.PI) : 0;
const winnerPulse = frame >= revealFrame ? Math.sin(revealProgress * Math.PI) : 0;
const isRevealed = frame >= revealFrame;
const size = Math.min(width, height);
const center = size / 2;
@@ -42,13 +54,49 @@ export const WheelComposition = (props) => {
const centerRingRadius = size * 0.09;
const centerRadius = size * 0.06;
const sliceDegrees = getSliceDegrees(props.options.length);
const finalRotation = getFinalWheelRotationDegrees(props.options.length, props.winnerIndex);
const winnerCenterDegrees = getSliceCenterDegrees(props.options.length, props.winnerIndex);
// Turn count scales with the frame budget and the slice width: a dense wheel
// spun as far as a sparse one moves more than a whole wedge per frame and
// aliases into a backwards-looking blur.
const spinTurns = getSpinTurns(spinFrames, props.options.length);
const finalRotation = getFinalWheelRotationDegrees(
props.options.length,
props.winnerIndex,
spinTurns,
);
const startRotation = -24;
const rotation = getWheelRotationDegrees({
finalRotationDegrees: finalRotation,
frame,
spinFrames,
startRotationDegrees: startRotation,
const rotation =
getWheelRotationDegrees({
finalRotationDegrees: finalRotation,
frame,
spinFrames,
startRotationDegrees: startRotation,
}) + getSpinRecoilDegrees(frame, spinFrames, sliceDegrees);
const pointerDeflection = getPointerDeflectionDegrees(rotation, sliceDegrees);
const paletteLength = theme.slices.length;
// The width estimator is calibrated against the 700-weight slice labels, so
// the 800-weight pill runs wider than it predicts. Budgeting for that keeps
// the browser from re-wrapping past the two lines chosen here.
const pillBoldWidthFactor = 1.12;
const pillMaxWidth = size * 0.58;
const pillBaseFontSize = size * 0.075;
const winnerText = props.options[props.winnerIndex] ?? '';
const winnerLines = getLabelLines(winnerText, pillBaseFontSize, pillMaxWidth, 2);
const longestWinnerWidth = winnerLines.reduce(
(widest, line) => Math.max(widest, estimateTextWidth(line, pillBaseFontSize)),
0,
) * pillBoldWidthFactor;
const winnerFontSize = Math.max(
size * 0.038,
longestWinnerWidth > pillMaxWidth
? pillBaseFontSize * (pillMaxWidth / longestWinnerWidth)
: pillBaseFontSize,
);
const pillPop = interpolate(frame, [revealFrame, revealFrame + 4], [0, 1], {
easing: Easing.out(Easing.cubic),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
@@ -80,13 +128,19 @@ export const WheelComposition = (props) => {
}}
/>
{/* translateZ promotes this one layer, which switches Chromium from LCD
sub-pixel text antialiasing to grayscale for every label inside it.
Sub-pixel AA emits saturated color fringes on each glyph edge that
consume most of the 256-entry GIF palette. Promoting the labels
individually works too, but costs one layer per option and doubles
render time on a dense wheel. */}
<div
style={{
height: size,
left: 0,
position: 'absolute',
top: 0,
transform: `rotate(${rotation}deg)`,
transform: `rotate(${rotation}deg) translateZ(0)`,
transformOrigin: '50% 50%',
width: size,
}}
@@ -95,7 +149,8 @@ export const WheelComposition = (props) => {
const start = index * sliceDegrees;
const centerDegrees = getSliceCenterDegrees(props.options.length, index);
const isWinner = index === props.winnerIndex;
const color = theme.slices[index % theme.slices.length] ?? theme.slices[0] ?? '#cccccc';
const colorIndex = getSliceColorIndex(index, props.options.length, paletteLength);
const color = theme.slices[colorIndex] ?? theme.slices[0] ?? '#cccccc';
const label = getRadialLabelLayout({
center,
radius,
@@ -103,6 +158,7 @@ export const WheelComposition = (props) => {
centerDegrees,
optionCount: props.options.length,
text: option,
winnerCenterDegrees,
});
return (
@@ -112,10 +168,13 @@ export const WheelComposition = (props) => {
progress={1 / props.options.length}
radius={radius}
rotation={cssDegreesToPieRadians(start)}
stroke="#ffffff"
strokeWidth={2}
// An opaque stroke instead of a blurred drop-shadow: a filter
// on a Pie paints outside the shape, so later siblings
// overpaint it and only one neighbour shows the halo. A stroke
// also stays legible after GIF quantisation.
stroke={isWinner && isRevealed ? theme.winnerStroke : theme.sliceStroke}
strokeWidth={isWinner && isRevealed ? 4 + Math.round(winnerPulse * 3) : 2}
style={{
filter: isWinner && frame >= holdStartFrame ? `drop-shadow(0 0 ${10 + winnerPulse * 10}px ${theme.winnerGlow})` : undefined,
left: center - radius,
position: 'absolute',
top: center - radius,
@@ -134,7 +193,7 @@ export const WheelComposition = (props) => {
justifyContent: 'center',
left: label.x,
letterSpacing: 0,
lineHeight: 1,
lineHeight: labelLineHeightRatio,
overflow: 'hidden',
padding: `0 ${label.horizontalPadding}px`,
position: 'absolute',
@@ -167,23 +226,63 @@ export const WheelComposition = (props) => {
radius={centerRadius}
style={{left: center - centerRadius, position: 'absolute', top: center - centerRadius}}
/>
<div
{/* Drawn as SVG so the outline actually renders: clip-path discards a
border and a box-shadow along with everything else it clips. */}
<svg
width={size * 0.1}
height={size * 0.105}
viewBox="0 0 100 105"
style={{
background: theme.pointer,
border: `2px solid ${theme.pointerStroke}`,
boxShadow: '0 7px 16px rgba(15, 23, 42, 0.24)',
clipPath: rightPointerClipPath,
height: size * 0.085,
left: center + radius - size * 0.02,
left: center + radius - size * 0.045,
position: 'absolute',
top: center - size * 0.0425,
width: size * 0.105,
top: center - size * 0.0525,
transform: `rotate(${pointerDeflection}deg)`,
transformOrigin: '100% 50%',
zIndex: 4,
}}
/>
>
<polygon
points="4,52.5 96,5 96,100"
fill={theme.pointer}
stroke={theme.pointerStroke}
strokeWidth={7}
strokeLinejoin="round"
/>
</svg>
{isRevealed && (
<div
style={{
alignItems: 'center',
background: theme.winnerPillBg,
borderRadius: size * 0.03,
color: theme.winnerPillText,
display: 'flex',
fontSize: winnerFontSize,
fontWeight: 800,
justifyContent: 'center',
left: '50%',
lineHeight: 1.2,
outline: `${Math.round(size * 0.008)}px solid ${theme.winnerPillRing}`,
padding: `${size * 0.022}px ${size * 0.042}px`,
position: 'absolute',
textAlign: 'center',
top: '50%',
transform: `translate(-50%, -50%) scale(${0.72 + 0.28 * pillPop}) translateZ(0)`,
// `pre`, not `pre-line`: the two lines above are already chosen to
// fit, and letting the browser re-wrap them turned a long name
// into a four-line block that covered the wheel.
whiteSpace: 'pre',
// Above the confetti layer so the announcement is never occluded.
zIndex: 12,
}}
>
{winnerLines.join('\n')}
</div>
)}
</div>
<Confetti colors={theme.slices} seed={props.winnerIndex} startFrame={holdStartFrame} />
<Confetti colors={theme.confetti} seed={props.winnerIndex} startFrame={revealFrame} />
</AbsoluteFill>
);
};
+182 -22
View File
@@ -4,13 +4,95 @@
* Remotion renders every GIF frame independently in headless Chromium, so the
* animation must be a pure function of the frame number. These helpers turn a
* seeded random source into stable particle descriptors and evaluate their
* ballistic path at an arbitrary time, keeping the visual identical on every
* render of the same seed.
* flight at an arbitrary time, keeping the visual identical on every render of
* the same seed.
*
* Motion is air-drag ballistics evaluated in closed form rather than stepped
* integration: paper decelerates hard after launch instead of flying a clean
* parabola, and a closed form keeps every frame independent.
*/
/** Canvas size the physics constants below are tuned for. */
export const CONFETTI_REFERENCE_SIZE = 512;
/** Downward acceleration in px/s^2 at the reference size. */
const gravity = 340;
/**
* Launch speed range in px/s at the reference size.
*
* Capped by the frame rate rather than by how a cannon behaves: at 15fps a
* particle travelling 800px/s covers 53px per frame, and a GIF has no motion
* blur to tie those samples together. Past roughly 3x the body length per frame
* the burst reads as disconnected dots instead of moving paper.
*/
const minSpeed = 600;
const maxSpeed = 820;
/**
* Drag coefficient range in 1/s. Terminal fall speed is `gravity / drag`, so
* this range is what keeps particles in frame for the whole celebration. Small
* chips get more drag than large ones, which reads as depth.
*/
const minDrag = 1.4;
const maxDrag = 2.0;
/**
* Launch timing. A cannon fires most of its charge at once and then trails, so
* `firstVolleyShare` of the particles leave at t=0 — which is what makes the
* opening frame a compact pop rather than an empty canvas — and the rest spread
* over `launchWindowSeconds`, weighted toward the start.
*/
const launchWindowSeconds = 0.38;
// Raised to offset the share diverted to the second volley below, so the
// opening frame keeps its compact pop.
const firstVolleyShare = 0.5;
/**
* Second volley. This share of the particles launches partway through the
* celebration so a long hold does not end on a thinning field. Expressed as a
* fraction of the window rather than in seconds, so a short hold collapses the
* second wave into the first instead of firing it after the GIF has ended.
*/
const secondWaveShare = 0.22;
const secondWaveWindowStart = 0.42;
const secondWaveWindowEnd = 0.55;
/**
* Motion streak. A GIF carries no motion blur, so a fast chip samples as a
* detached dot. Stretching it along its direction of travel restores the visual
* link between frames. Capped well short of a rain-streak look.
*/
const maxStretch = 1.8;
// Set above the launch speed range so fast and slow chips streak by different
// amounts. Tuned to the reference speed range: at 620 every particle pinned to
// the cap for the first three frames and the burst lost its size variety.
const stretchReferenceSpeed = 1200;
/** Cannon muzzle positions as a fraction of the canvas. */
const cannonInset = 0.08;
const cannonHeight = 0.8;
/** Body size range in px at the reference size. */
const minWidth = 6;
const maxWidth = 18;
/**
* Minimum rendered thickness in px, unscaled: below ~3px a chip survives GIF
* quantisation as a single dim row of pixels, or vanishes.
*/
const minHeight = 3;
const maxHeight = 26;
/** Tumble rate range in deg/s. Capped so 15fps samples a rotation, not a flicker. */
const maxSpin = 300;
/** Lateral flutter, in Hz and px at the reference size. */
const minSwayHz = 0.5;
const maxSwayHz = 0.9;
const minSwayAmplitude = 8;
const maxSwayAmplitude = 20;
/**
* @param {number} t normalized value in [0, 1]
* @param {number} min
@@ -19,51 +101,91 @@ export const CONFETTI_REFERENCE_SIZE = 512;
*/
const lerp = (t, min, max) => min + t * (max - min);
/**
* @param {number} value
* @param {number} min
* @param {number} max
* @returns {number}
*/
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
/**
* @typedef {object} ConfettiParticle
* @property {string} color
* @property {number} delay s before this particle launches
* @property {number} drag 1/s air resistance
* @property {number} gravity px/s^2 downward acceleration
* @property {number} height px
* @property {number} originX px
* @property {number} originY px
* @property {number} rotationSpeed deg/s
* @property {number} startRotation deg
* @property {number} swayAmplitude px
* @property {number} swayHz cycles/s
* @property {number} swayPhase rad
* @property {number} vx px/s
* @property {number} vy px/s (negative points up on screen)
* @property {number} width px
*/
/**
* Builds the confetti particles bursting outward and upward from the wheel
* center. Physics scale with `size` relative to {@link CONFETTI_REFERENCE_SIZE}.
* Builds the confetti particles for a two-cannon burst framing the wheel.
*
* Alternating particles launch from the lower-left and lower-right corners and
* arc inward across the disc. Launching from the corners rather than the hub
* keeps the dense opening frames over plain background, where the pop is
* legible, and clear of the winner pill that covers the hub during the hold.
*
* @param {object} params
* @param {number} params.count number of particles
* @param {number} params.size canvas edge length in px
* @param {string[]} params.colors palette to cycle through
* @param {(seed: string) => number} params.random deterministic [0, 1) source
* @param {number} [params.windowSeconds] length of the celebration, for pacing
* the second volley
* @returns {ConfettiParticle[]}
*/
export const createConfettiParticles = ({count, size, colors, random}) => {
export const createConfettiParticles = ({count, size, colors, random, windowSeconds = 0}) => {
const scale = size / CONFETTI_REFERENCE_SIZE;
const center = size / 2;
const palette = colors.length > 0 ? colors : ['#f97316'];
return Array.from({length: count}, (_, index) => {
const rand = (/** @type {string} */ key) => random(`${index}-${key}`);
// Fan upward: -160deg..-20deg in screen coords (0deg right, -90deg up).
const angleRadians = (lerp(rand('angle'), -160, -20) * Math.PI) / 180;
const speed = lerp(rand('speed'), 320, 820) * scale;
const width = lerp(rand('width'), 6, 12) * scale;
const fromLeft = index % 2 === 0;
// Bias size small so a few large chips anchor the burst at 15fps while most
// particles stay light enough to flutter.
const sizeNorm = rand('width') ** 1.7;
const width = lerp(sizeNorm, minWidth, maxWidth) * scale;
// Mirror the left cannon's fan: 0deg points right, negative points up.
const spread = rand('angle');
const angleDegrees = fromLeft ? lerp(spread, -78, -40) : lerp(spread, -140, -102);
const angleRadians = (angleDegrees * Math.PI) / 180;
const speed = lerp(rand('speed'), minSpeed, maxSpeed) * scale;
const delayRoll = rand('delay');
const firstWaveDelay =
delayRoll < firstVolleyShare
? 0
: ((delayRoll - firstVolleyShare) / (1 - firstVolleyShare)) ** 1.5 * launchWindowSeconds;
const secondWaveDelay =
windowSeconds * lerp(rand('wave'), secondWaveWindowStart, secondWaveWindowEnd);
// A particle joins the second volley only when that actually lands later
// than the normal spread; on a short hold the two waves coincide, which is
// the right behaviour rather than a case to special-case.
const inSecondWave = rand('waveRoll') < secondWaveShare;
return {
color: palette[Math.floor(rand('color') * palette.length)] ?? palette[0] ?? '#f97316',
gravity: 1500 * scale,
height: width * lerp(rand('ratio'), 0.4, 1),
originX: center + lerp(rand('originX'), -0.06, 0.06) * size,
originY: center + lerp(rand('originY'), -0.04, 0.02) * size,
rotationSpeed: lerp(rand('spin'), -540, 540),
delay: inSecondWave ? Math.max(firstWaveDelay, secondWaveDelay) : firstWaveDelay,
drag: lerp(1 - sizeNorm, minDrag, maxDrag),
gravity: gravity * scale,
height: clamp(width * lerp(rand('ratio'), 0.25, 2.6), minHeight, maxHeight * scale),
originX: (fromLeft ? cannonInset : 1 - cannonInset) * size,
originY: cannonHeight * size,
rotationSpeed: lerp(rand('spin'), -maxSpin, maxSpin),
startRotation: lerp(rand('rotation'), 0, 360),
swayAmplitude: lerp(rand('sway'), minSwayAmplitude, maxSwayAmplitude) * scale,
swayHz: lerp(rand('hz'), minSwayHz, maxSwayHz),
swayPhase: rand('phase') * Math.PI * 2,
vx: Math.cos(angleRadians) * speed,
vy: Math.sin(angleRadians) * speed,
width,
@@ -72,14 +194,52 @@ export const createConfettiParticles = ({count, size, colors, random}) => {
};
/**
* Evaluates a particle's position and rotation at `seconds` after the burst.
* Evaluates a particle's position, orientation and streak at `seconds` after the
* burst.
*
* Closed-form solution of `v' = g - drag * v`, so position depends only on the
* elapsed time and never on a previous frame. Before its launch delay a
* particle sits at the muzzle.
*
* `stretch` and `rotation` together fake motion blur. While a chip is moving
* fast it is drawn elongated and aligned with its travel direction; as it slows
* the stretch relaxes to 1 and the orientation blends back to its own tumble.
* Without this a chip crossing 50px between frames reads as two unrelated dots.
*
* @param {ConfettiParticle} particle
* @param {number} seconds elapsed since the burst started
* @returns {{rotation: number, x: number, y: number}}
* @returns {{rotation: number, stretch: number, x: number, y: number}}
*/
export const getConfettiParticleState = (particle, seconds) => ({
rotation: particle.startRotation + particle.rotationSpeed * seconds,
x: particle.originX + particle.vx * seconds,
y: particle.originY + particle.vy * seconds + 0.5 * particle.gravity * seconds * seconds,
});
export const getConfettiParticleState = (particle, seconds) => {
const t = Math.max(0, seconds - particle.delay);
const fade = Math.exp(-particle.drag * t);
const decay = 1 - fade;
const terminalSpeed = particle.gravity / particle.drag;
// Sway eases in so particles do not start mid-swing at the muzzle.
const sway =
Math.sin(particle.swayPhase + t * particle.swayHz * Math.PI * 2) *
particle.swayAmplitude *
Math.min(1, t * 2);
// Velocity of the same closed form, differentiated.
const velocityX = particle.vx * fade;
const velocityY = particle.vy * fade + terminalSpeed * decay;
const speedNow = Math.hypot(velocityX, velocityY);
const stretch = 1 + Math.min(maxStretch - 1, speedNow / stretchReferenceSpeed);
// Blend from travel-aligned while streaking to free tumble once settled.
const alignment = Math.min(1, Math.max(0, (stretch - 1) / (maxStretch - 1)));
const travelDegrees = (Math.atan2(velocityY, velocityX) * 180) / Math.PI;
const tumbleDegrees = particle.startRotation + particle.rotationSpeed * t;
// Shortest path: tumble grows without bound while travel wraps at +/-180, so
// a raw difference would swing the chip the long way around.
const towardTravel = (((travelDegrees - tumbleDegrees) % 360) + 540) % 360 - 180;
return {
rotation: tumbleDegrees + alignment * towardTravel,
stretch,
x: particle.originX + (particle.vx / particle.drag) * decay + sway,
y:
particle.originY +
((particle.vy - terminalSpeed) / particle.drag) * decay +
terminalSpeed * t,
};
};
+61 -6
View File
@@ -1,3 +1,13 @@
/**
* Palette definitions for the rendered wheel.
*
* Slice orders are chosen so that neighbouring wedges stay distinguishable
* under normal vision and under deuteranopia/protanopia simulation, because a
* 200px GIF in a chat client is the realistic viewing size. `confetti` is kept
* separate from `slices`: slice colors are tuned to carry dark label text at
* large areas, which makes several of them far too light to read as 8px paper
* chips against the background.
*/
export const themes = {
classic: {
background: '#f8fafc',
@@ -8,8 +18,13 @@ export const themes = {
pointerStroke: '#991b1b',
text: '#111827',
textHalo: 'rgba(255, 255, 255, 0.88)',
winnerGlow: 'rgba(250, 204, 21, 0.45)',
slices: ['#f97316', '#14b8a6', '#facc15', '#60a5fa', '#f472b6', '#a78bfa', '#34d399', '#fb7185'],
sliceStroke: '#ffffff',
winnerStroke: '#111827',
winnerPillBg: '#111827',
winnerPillText: '#ffffff',
winnerPillRing: '#facc15',
slices: ['#f97316', '#14b8a6', '#f472b6', '#60a5fa', '#facc15', '#a78bfa', '#fb7185', '#34d399'],
confetti: ['#f97316', '#14b8a6', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#10b981', '#f43f5e'],
},
festival: {
background: '#fff7ed',
@@ -20,8 +35,13 @@ export const themes = {
pointerStroke: '#7f1d1d',
text: '#1f2937',
textHalo: 'rgba(255, 251, 235, 0.92)',
winnerGlow: 'rgba(251, 146, 60, 0.5)',
slices: ['#fb7185', '#f59e0b', '#84cc16', '#06b6d4', '#a78bfa', '#f472b6', '#22c55e', '#fb923c'],
sliceStroke: '#ffffff',
winnerStroke: '#7f1d1d',
winnerPillBg: '#7f1d1d',
winnerPillText: '#fffbeb',
winnerPillRing: '#f59e0b',
slices: ['#fb7185', '#06b6d4', '#84cc16', '#a78bfa', '#f59e0b', '#f472b6', '#fb923c', '#22c55e'],
confetti: ['#f43f5e', '#ea580c', '#65a30d', '#0891b2', '#7c3aed', '#db2777', '#16a34a', '#d97706'],
},
mono: {
background: '#f4f4f5',
@@ -32,8 +52,17 @@ export const themes = {
pointerStroke: '#09090b',
text: '#18181b',
textHalo: 'rgba(250, 250, 250, 0.9)',
winnerGlow: 'rgba(161, 161, 170, 0.45)',
slices: ['#e4e4e7', '#a1a1aa', '#d4d4d8', '#b8b8bf', '#f4f4f5', '#c4c4ca'],
// Grey dividers instead of white: the lightest slices sit within 1.1:1 of
// the background, so a white stroke leaves the wheel with no silhouette.
sliceStroke: '#71717a',
winnerStroke: '#18181b',
winnerPillBg: '#18181b',
winnerPillText: '#fafafa',
winnerPillRing: '#a1a1aa',
slices: ['#87878f', '#cacad0', '#9d9da5', '#e1e1e5', '#b3b3ba', '#f9f9fa'],
// Inverted to darks: every slice color in this theme is within 2.4:1 of the
// background and disappears at particle size.
confetti: ['#18181b', '#3f3f46', '#52525b', '#71717a', '#a1a1aa'],
},
};
@@ -41,3 +70,29 @@ export const themes = {
* @param {'classic' | 'festival' | 'mono'} name
*/
export const getTheme = (name) => themes[name] ?? themes.classic;
/**
* Picks which palette entry a slice uses.
*
* Cycling the palette by index alone makes the first and last slice share a
* color whenever the option count leaves a remainder of one, and those two are
* neighbours on the wheel, so they merge into a single double-width wedge
* separated only by a hairline stroke. The last slice is moved to the opposite
* side of the palette instead.
*
* @param {number} index slice index
* @param {number} optionCount total slices
* @param {number} paletteLength available colors
* @returns {number}
*/
export const getSliceColorIndex = (index, optionCount, paletteLength) => {
if (paletteLength <= 0) {
return 0;
}
if (index === optionCount - 1 && optionCount % paletteLength === 1) {
return Math.floor(paletteLength / 2);
}
return index % paletteLength;
};
+44 -4
View File
@@ -23,6 +23,16 @@ const minLabelWidth = 24;
const minLabelFontSize = 8;
const preferredLabelFontSize = 14;
/**
* Line box as a multiple of the font size.
*
* Vietnamese stacks a tone mark above and a dot below the same vowel, so a
* ratio of exactly 1 leaves marks from adjacent wrapped lines touching, and any
* font fallback with taller metrics clips against the label's `overflow:
* hidden`. Exported so the composition renders the same box this module sizes.
*/
export const labelLineHeightRatio = 1.16;
/**
* @param {string} text
* @param {number} fontSize
@@ -175,13 +185,25 @@ export const getLabelFontSize = (radius, optionCount, text = '', trackWidth = Nu
* @param {number} input.centerDegrees
* @param {number} input.optionCount
* @param {string} input.text
* @param {number} [input.winnerCenterDegrees] center angle of the winning slice
* @returns {RadialLabelLayout}
*/
export const getRadialLabelLayout = ({center, radius, hubRadius, centerDegrees, optionCount, text}) => {
export const getRadialLabelLayout = ({
center,
radius,
hubRadius,
centerDegrees,
optionCount,
text,
winnerCenterDegrees = 0,
}) => {
const track = getLabelTrack(radius, hubRadius);
const radians = (centerDegrees * Math.PI) / 180;
const baseFontSize = getLabelFontSize(radius, optionCount);
const availableHeight = (2 * Math.PI * track.midRadius) / optionCount;
// Counted against the bare font size, not the line box: the fitted size below
// shrinks the text if the taller boxes overflow, so charging the ratio here
// too would drop a line that still fits once shrunk.
const maxLines = Math.max(1, Math.min(3, Math.floor(availableHeight / baseFontSize)));
const horizontalPadding = Math.max(4, radius * 0.02);
const contentWidth = Math.max(1, track.width - horizontalPadding * 2);
@@ -197,16 +219,34 @@ export const getRadialLabelLayout = ({center, radius, hubRadius, centerDegrees,
const fontSize = shouldWrap
? getLabelFontSize(radius, optionCount, longestLine, contentWidth)
: singleLineFontSize;
// Shrink to fit the taller line box rather than letting `overflow: hidden`
// crop the last line.
const blockHeight = fontSize * labelLineHeightRatio * lines.length;
const fittedFontSize =
blockHeight > availableHeight
? Math.max(minLabelFontSize, Math.floor(fontSize * (availableHeight / blockHeight)))
: fontSize;
// Labels stay radial, but a slice whose rest-frame angle points left would
// render its text mirrored. Flipping by the angle the slice lands at makes
// every label upright in the final frame, which is the frame chat clients
// show as the GIF's poster image.
//
// The boundary is inclusive at 90 and exclusive at 270 so the two labels that
// land exactly vertical — which happens on every option count divisible by
// four — both read in the same direction. Neither is mirrored either way, but
// opposite reading directions make the pair look like a mistake.
const restAngle = (((centerDegrees - winnerCenterDegrees) % 360) + 360) % 360;
const flip = restAngle >= 90 && restAngle < 270 ? 180 : 0;
return {
x: center + Math.cos(radians) * track.midRadius,
y: center + Math.sin(radians) * track.midRadius,
width: track.width,
contentWidth,
height: Math.min(availableHeight, fontSize * lines.length),
height: Math.min(availableHeight, fittedFontSize * labelLineHeightRatio * lines.length),
horizontalPadding,
rotation: centerDegrees,
fontSize,
rotation: centerDegrees + flip,
fontSize: fittedFontSize,
lines,
};
};
+126 -5
View File
@@ -1,5 +1,7 @@
export const fullTurnDegrees = 360;
export const rightPointerClipPath = 'polygon(0 50%, 100% 0, 100% 100%)';
/** Turns the wheel makes before landing on the winner. */
export const defaultFullTurns = 5;
/**
* @param {number} optionCount
@@ -24,7 +26,7 @@ export const getSliceCenterDegrees = (optionCount, index) =>
* @param {number} fullTurns
* @returns {number}
*/
export const getFinalWheelRotationDegrees = (optionCount, winnerIndex, fullTurns = 7) =>
export const getFinalWheelRotationDegrees = (optionCount, winnerIndex, fullTurns = defaultFullTurns) =>
fullTurns * fullTurnDegrees - getSliceCenterDegrees(optionCount, winnerIndex);
/**
@@ -44,16 +46,74 @@ export const cssDegreesToPieRadians = (cssDegrees) => ((cssDegrees - 270) * Math
*/
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
/**
* Fraction of the spin spent accelerating from rest.
*
* A pure ease-out starts at maximum speed, which pushes the wheel past one
* slice per frame at 15fps and reads as a strobe or a backwards spin. A short
* launch ramp keeps peak speed under the slice width.
*/
const accelFraction = 0.12;
/** Deceleration curve exponent; higher means a longer, softer approach. */
const decelExponent = 2.2;
/**
* Speed at the handoff between launch and deceleration, in progress units.
*
* Solved so the two segments share a slope there. Without this the wheel jumps
* from ~18 to ~41 degrees per frame in a single frame and looks like it gets a
* second push.
*/
const handoffSlope = decelExponent / (1 - accelFraction + (decelExponent * accelFraction) / 2);
/** Progress already covered when the launch ramp ends. */
const handoffProgress = (handoffSlope * accelFraction) / 2;
/** Widest turn count allowed, so a sparse wheel does not spin absurdly. */
const maxFullTurns = 7;
/**
* Turns to spin so the wheel never advances more than one slice per frame.
*
* Peak speed is `handoffSlope * turns * 360 / spinFrames` degrees per frame.
* Holding that at or under one slice (`360 / optionCount`) means a viewer can
* always track which wedge is passing; above it the wheel aliases and appears to
* jitter or run backwards. Solving for turns gives the expression below.
*
* Floors rather than rounds: rounding 5.63 up to 6 turns on an 8-option wheel
* would push peak speed to 47.9deg against a 45deg slice, i.e. introduce the
* very aliasing this prevents.
*
* The result must stay an integer. The winner lands under the pointer only
* because `turns * 360` is a whole number of revolutions; 2.8 turns would leave
* the wheel 288 degrees from its detent.
*
* @param {number} spinFrames frames available before the hold
* @param {number} optionCount number of slices
* @returns {number} integer turn count
*/
export const getSpinTurns = (spinFrames, optionCount) =>
Math.max(
1,
Math.min(maxFullTurns, Math.floor(spinFrames / (handoffSlope * Math.max(1, optionCount)))),
);
/**
* @param {number} frame
* @param {number} spinFrames
* @returns {number}
*/
export const getSpinProgress = (frame, spinFrames) => {
const safeSpinFrames = Math.max(1, spinFrames);
const progress = clamp(frame / safeSpinFrames, 0, 1);
const progress = clamp(frame / Math.max(1, spinFrames), 0, 1);
return 1 - (1 - progress) ** 3;
if (progress < accelFraction) {
return (handoffSlope * progress * progress) / (2 * accelFraction);
}
const decelProgress = (progress - accelFraction) / (1 - accelFraction);
return handoffProgress + (1 - (1 - decelProgress) ** decelExponent) * (1 - handoffProgress);
};
/**
@@ -67,3 +127,64 @@ export const getSpinProgress = (frame, spinFrames) => {
export const getWheelRotationDegrees = ({frame, spinFrames, startRotationDegrees, finalRotationDegrees}) =>
startRotationDegrees +
(finalRotationDegrees - startRotationDegrees) * getSpinProgress(frame, spinFrames);
/** Portion of the spin over which the wheel rocks into its detent. */
const recoilFraction = 0.18;
/** Peak rock in degrees, before the slice-width cap below. */
const maxRecoilDegrees = 2.2;
/**
* Rotation offset that rocks the wheel into its final position.
*
* A pure ease-out creeps to a halt, which reads as the wheel being switched off
* rather than coming to rest. This overlays one damped swing over the tail of
* the spin: the wheel drifts a little past its detent, comes back, and settles.
*
* Kept as an offset on top of the rotation rather than folded into
* `getSpinProgress` so progress stays monotonic in [0, 1]. The damping term
* forces the offset to exactly 0 at `spinFrames`, so the winner still lands
* precisely under the pointer and the flapper still parks neutral.
*
* @param {number} frame
* @param {number} spinFrames
* @param {number} sliceDegrees width of one slice
* @returns {number} degrees to add to the wheel rotation
*/
export const getSpinRecoilDegrees = (frame, spinFrames, sliceDegrees) => {
const safeSpinFrames = Math.max(1, spinFrames);
const startFrame = safeSpinFrames * (1 - recoilFraction);
if (frame <= startFrame || frame >= safeSpinFrames) {
return 0;
}
const progress = (frame - startFrame) / (safeSpinFrames - startFrame);
// Capped against the slice so a 2-option wheel does not visibly swing and a
// dense one never rocks a neighbouring wedge under the pointer.
const amplitude = Math.min(maxRecoilDegrees, sliceDegrees * 0.25);
return amplitude * Math.sin(progress * Math.PI * 2) * (1 - progress) ** 1.5;
};
/**
* Deflection of the pointer flapper as slices pass under it.
*
* The flapper is pushed aside as a slice edge arrives and springs back over the
* rest of the slice, which is the cue that sells the wheel as a physical
* object. Resolves to exactly 0 at rest: the final rotation always leaves a
* remainder of half a slice, so `phase` is 0.5 and the flapper parks neutral
* for every winner and option count.
*
* @param {number} rotationDegrees current wheel rotation
* @param {number} sliceDegrees width of one slice
* @param {number} [maxDeflection] peak deflection in degrees
* @returns {number}
*/
export const getPointerDeflectionDegrees = (rotationDegrees, sliceDegrees, maxDeflection = 9) => {
const safeSliceDegrees = Math.max(1e-6, sliceDegrees);
const phase = (((rotationDegrees % safeSliceDegrees) + safeSliceDegrees) % safeSliceDegrees) / safeSliceDegrees;
const push = Math.max(0, 1 - phase * 3);
return push === 0 ? 0 : -maxDeflection * push;
};
+162 -24
View File
@@ -77,7 +77,44 @@ describe('confetti layout', () => {
expect(large.width).toBeCloseTo(small.width * 4);
});
test('reference size keeps gravity at the tuned constant', () => {
test('keeps particles thick enough to survive GIF quantization', () => {
for (const size of [384, 480, 512]) {
const particles = createConfettiParticles({
colors: ['#000'],
count: 70,
random: makeRandom(),
size,
});
for (const particle of particles) {
expect(particle.height).toBeGreaterThanOrEqual(3);
expect(particle.width).toBeGreaterThanOrEqual(3);
}
}
});
test('launches from two cannons framing the wheel, not from the hub', () => {
const size = CONFETTI_REFERENCE_SIZE;
const particles = createConfettiParticles({
colors: ['#000'],
count: 20,
random: makeRandom(),
size,
});
const hubRadius = size * 0.09;
for (const particle of particles) {
const distanceFromHub = Math.hypot(particle.originX - size / 2, particle.originY - size / 2);
expect(distanceFromHub).toBeGreaterThan(hubRadius);
// Upward launch: negative vy points up in screen coordinates.
expect(particle.vy).toBeLessThan(0);
// Inward launch: left cannon throws right, right cannon throws left.
expect(particle.originX < size / 2 ? particle.vx > 0 : particle.vx < 0).toBe(true);
}
});
test('drag gives a terminal fall speed instead of unbounded acceleration', () => {
const [particle] = createConfettiParticles({
colors: ['#000'],
count: 1,
@@ -85,32 +122,133 @@ describe('confetti layout', () => {
size: CONFETTI_REFERENCE_SIZE,
});
expect(particle?.gravity).toBe(1500);
if (!particle) {
throw new Error('expected one particle');
}
expect(particle.drag).toBeGreaterThan(0);
const terminalSpeed = particle.gravity / particle.drag;
const late = getConfettiParticleState(particle, 6);
const later = getConfettiParticleState(particle, 7);
expect(later.y - late.y).toBeCloseTo(terminalSpeed, 0);
});
test('particle starts at its origin and accelerates downward', () => {
const particle = {
color: '#000',
gravity: 1500,
height: 8,
originX: 100,
originY: 200,
rotationSpeed: 90,
startRotation: 10,
vx: 50,
vy: -300,
width: 8,
};
expect(getConfettiParticleState(particle, 0)).toEqual({
rotation: 10,
x: 100,
y: 200,
test('particle sits at its muzzle until its launch delay elapses', () => {
const [particle] = createConfettiParticles({
colors: ['#000'],
count: 4,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
});
const later = getConfettiParticleState(particle, 1);
expect(later.x).toBe(150);
expect(later.y).toBe(650); // 200 - 300 + 0.5 * 1500
expect(later.rotation).toBe(100);
if (!particle) {
throw new Error('expected one particle');
}
const atLaunch = getConfettiParticleState(particle, particle.delay);
expect(atLaunch.x).toBeCloseTo(particle.originX);
expect(atLaunch.y).toBeCloseTo(particle.originY);
// Orientation at launch is pulled toward the direction of travel, because
// that is where the streak is strongest. It is a blend, not a snap, so
// assert it sits closer to travel than the particle's own tumble does.
const travelDegrees = (Math.atan2(particle.vy, particle.vx) * 180) / Math.PI;
/** @param {number} degrees */
const offFromTravel = (degrees) =>
Math.abs((((degrees - travelDegrees) % 360) + 540) % 360 - 180);
expect(offFromTravel(atLaunch.rotation)).toBeLessThanOrEqual(
offFromTravel(particle.startRotation),
);
});
test('streaks while fast and relaxes to its own tumble once slow', () => {
const particles = createConfettiParticles({
colors: ['#000'],
count: 12,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
});
for (const particle of particles) {
const launch = getConfettiParticleState(particle, particle.delay);
const settled = getConfettiParticleState(particle, particle.delay + 3);
expect(launch.stretch).toBeGreaterThan(1);
expect(launch.stretch).toBeLessThanOrEqual(1.8);
expect(settled.stretch).toBeLessThan(launch.stretch);
expect(settled.stretch).toBeGreaterThanOrEqual(1);
}
});
test('keeps the streaked long axis short enough to read as paper', () => {
for (const size of [384, 480, 512]) {
const particles = createConfettiParticles({
colors: ['#000'],
count: 70,
random: makeRandom(),
size,
});
for (const particle of particles) {
const {stretch} = getConfettiParticleState(particle, particle.delay);
expect(particle.width * stretch).toBeLessThanOrEqual(40);
// Stretching runs along the long axis only, so the thin dimension still
// survives GIF quantization.
expect(particle.height).toBeGreaterThanOrEqual(3);
}
}
});
test('holds a second volley back so a long celebration does not thin out', () => {
const longWindow = createConfettiParticles({
colors: ['#000'],
count: 70,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
windowSeconds: 2.47,
});
const late = longWindow.filter((particle) => particle.delay > 0.8);
expect(late.length).toBeGreaterThan(8);
// Every particle must still launch inside the window it was built for.
for (const particle of longWindow) {
expect(particle.delay).toBeLessThan(2.47 * 0.6);
}
});
test('collapses the second volley into the first on a short celebration', () => {
const shortWindow = createConfettiParticles({
colors: ['#000'],
count: 70,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
windowSeconds: 0.47,
});
for (const particle of shortWindow) {
expect(particle.delay).toBeLessThan(0.47);
}
});
test('rises before it falls', () => {
const particles = createConfettiParticles({
colors: ['#000'],
count: 12,
random: makeRandom(),
size: CONFETTI_REFERENCE_SIZE,
});
for (const particle of particles) {
const launch = getConfettiParticleState(particle, particle.delay);
const soonAfter = getConfettiParticleState(particle, particle.delay + 0.15);
expect(soonAfter.y).toBeLessThan(launch.y);
}
});
});
+77 -2
View File
@@ -5,7 +5,9 @@ import {
getLabelLines,
getLabelTrack,
getRadialLabelLayout,
labelLineHeightRatio,
} from '../src/remotion/wheel-label-layout.js';
import {getFinalWheelRotationDegrees, getSliceCenterDegrees} from '../src/remotion/wheel-layout.js';
describe('wheel label layout', () => {
const readableExample = 'Chiều nay uống CraneTea';
@@ -50,7 +52,7 @@ describe('wheel label layout', () => {
expect(layout.rotation).toBe(centerDegrees);
});
test('keeps left-side text aligned with the slice instead of flipping it upright', () => {
test('flips labels that would rest mirrored so the poster frame reads upright', () => {
const layout = getRadialLabelLayout({
center: 256,
radius: 210,
@@ -58,9 +60,82 @@ describe('wheel label layout', () => {
centerDegrees: 180,
optionCount: 8,
text: 'left side',
winnerCenterDegrees: 0,
});
expect(layout.rotation).toBe(180);
expect(layout.rotation).toBe(360);
});
test('gives both exactly-vertical labels the same reading direction', () => {
// Option counts divisible by four always land two labels at exactly 90 and
// 270 degrees. Radial layout cannot make those horizontal, but it can stop
// them pointing opposite ways.
for (const optionCount of [4, 8, 12, 16, 20, 24, 32]) {
const winnerIndex = 1;
const winnerCenterDegrees = getSliceCenterDegrees(optionCount, winnerIndex);
const restRotation = getFinalWheelRotationDegrees(optionCount, winnerIndex);
/** @type {number[]} */
const verticalScreenAngles = [];
for (let index = 0; index < optionCount; index += 1) {
const layout = getRadialLabelLayout({
center: 256,
radius: 210,
hubRadius: 46,
centerDegrees: getSliceCenterDegrees(optionCount, index),
optionCount,
text: 'name',
winnerCenterDegrees,
});
const screenAngle = (((layout.rotation + restRotation) % 360) + 360) % 360;
if (Math.abs(screenAngle - 90) < 0.001 || Math.abs(screenAngle - 270) < 0.001) {
verticalScreenAngles.push(Math.round(screenAngle));
}
}
expect(verticalScreenAngles).toHaveLength(2);
expect(new Set(verticalScreenAngles).size).toBe(1);
}
});
test('leaves every label upright once the wheel rests on the winner', () => {
for (const optionCount of [2, 3, 6, 8, 9, 16]) {
for (const winnerIndex of [0, 1, optionCount - 1]) {
const winnerCenterDegrees = getSliceCenterDegrees(optionCount, winnerIndex);
for (let index = 0; index < optionCount; index += 1) {
const layout = getRadialLabelLayout({
center: 256,
radius: 210,
hubRadius: 46,
centerDegrees: getSliceCenterDegrees(optionCount, index),
optionCount,
text: 'name',
winnerCenterDegrees,
});
// Angle the text actually sits at once the wheel has stopped.
const restRotation = getFinalWheelRotationDegrees(optionCount, winnerIndex);
const screenAngle = (((layout.rotation + restRotation) % 360) + 360) % 360;
expect(screenAngle > 90 && screenAngle < 270).toBe(false);
}
}
}
});
test('fits the taller line box instead of cropping the last line', () => {
// Vietnamese stacks a mark above and below the same vowel, so the rendered
// block is taller than fontSize x lines and must still fit the wedge.
for (const optionCount of [2, 4, 8, 12, 16, 24, 32]) {
const layout = get512Layout({optionCount});
const availableHeight =
(2 * Math.PI * getLabelTrack(supportedRadius, supportedHubRadius).midRadius) / optionCount;
const renderedHeight = layout.fontSize * labelLineHeightRatio * layout.lines.length;
expect(renderedHeight).toBeLessThanOrEqual(availableHeight);
expect(layout.height).toBeLessThanOrEqual(availableHeight);
}
});
test('keeps short labels on one line at the base size', () => {
+175 -14
View File
@@ -2,11 +2,13 @@ import {describe, expect, test} from 'vitest';
import {
cssDegreesToPieRadians,
getFinalWheelRotationDegrees,
getPointerDeflectionDegrees,
getSliceCenterDegrees,
getSliceDegrees,
getSpinProgress,
getSpinRecoilDegrees,
getSpinTurns,
getWheelRotationDegrees,
rightPointerClipPath,
} from '../src/remotion/wheel-layout.js';
describe('wheel layout', () => {
@@ -45,26 +47,185 @@ describe('wheel layout', () => {
expect(getRenderedStartDegrees(270)).toBe(270);
});
test('eases spin progress once across the spin duration', () => {
test('starts from rest, rises monotonically, and ends exactly on target', () => {
expect(getSpinProgress(0, 100)).toBe(0);
expect(getSpinProgress(25, 100)).toBeCloseTo(0.578125);
expect(getSpinProgress(50, 100)).toBeCloseTo(0.875);
expect(getSpinProgress(100, 100)).toBe(1);
expect(getSpinProgress(125, 100)).toBe(1);
let previous = -1;
for (let frame = 0; frame <= 100; frame += 1) {
const progress = getSpinProgress(frame, 100);
expect(progress).toBeGreaterThanOrEqual(previous);
previous = progress;
}
});
test('maps eased spin progress to wheel rotation', () => {
const rotation = getWheelRotationDegrees({
finalRotationDegrees: 696,
frame: 25,
spinFrames: 100,
startRotationDegrees: -24,
});
/**
* Per-frame rotation change over a whole spin, including the settle recoil.
*
* @param {number} optionCount
* @param {number} spinFrames
*/
const getPerFrameRotation = (optionCount, spinFrames) => {
const sliceDegrees = getSliceDegrees(optionCount);
const finalRotationDegrees = getFinalWheelRotationDegrees(
optionCount,
0,
getSpinTurns(spinFrames, optionCount),
);
/** @param {number} at */
const rotationAt = (at) =>
getWheelRotationDegrees({
finalRotationDegrees,
frame: at,
spinFrames,
startRotationDegrees: -24,
}) + getSpinRecoilDegrees(at, spinFrames, sliceDegrees);
/** @type {number[]} */
const deltas = [];
expect(rotation).toBeCloseTo(392.25);
for (let frame = 1; frame <= spinFrames; frame += 1) {
deltas.push(Math.abs(rotationAt(frame) - rotationAt(frame - 1)));
}
return deltas;
};
test('picks an integer turn count within bounds for every configuration', () => {
for (const spinFrames of [1, 36, 78, 98, 200]) {
for (let optionCount = 2; optionCount <= 32; optionCount += 1) {
const turns = getSpinTurns(spinFrames, optionCount);
expect(Number.isInteger(turns)).toBe(true);
expect(turns).toBeGreaterThanOrEqual(1);
expect(turns).toBeLessThanOrEqual(7);
}
}
});
test('defines a right-side pointer that points into the wheel', () => {
expect(rightPointerClipPath).toBe('polygon(0 50%, 100% 0, 100% 100%)');
test('never advances a full slice in one frame when the frame budget allows it', () => {
for (const spinFrames of [78, 98, 200]) {
for (let optionCount = 2; optionCount <= 32; optionCount += 1) {
const deltas = getPerFrameRotation(optionCount, spinFrames);
expect(Math.max(...deltas)).toBeLessThan(getSliceDegrees(optionCount));
}
}
});
test('documents the dense short-duration wheels that cannot avoid aliasing', () => {
// 3000ms at 12fps leaves 36 spin frames. Even a single turn outruns a thin
// slice there, so this is a boundary of the format, not a tuning miss.
// Asserted so the boundary cannot move without someone noticing.
const spinFrames = 36;
/** @param {number} optionCount */
const aliases = (optionCount) =>
Math.max(...getPerFrameRotation(optionCount, spinFrames)) >= getSliceDegrees(optionCount);
// The cutoff sits at 17 options: one turn over 36 frames peaks at ~22.2deg
// per frame, which is wider than a 17-slice wedge.
for (let optionCount = 2; optionCount <= 16; optionCount += 1) {
expect(aliases(optionCount)).toBe(false);
}
for (let optionCount = 17; optionCount <= 32; optionCount += 1) {
expect(aliases(optionCount)).toBe(true);
}
});
test('blends the launch into the deceleration without a speed jump', () => {
const deltas = getPerFrameRotation(8, 98);
// A slope mismatch at the handoff shows up as a single large step in
// per-frame speed, which reads as the wheel getting a second push. The
// launch ramp itself gains only a few degrees per frame.
for (let index = 1; index < deltas.length; index += 1) {
const previous = deltas[index - 1] ?? 0;
const current = deltas[index] ?? 0;
expect(current - previous).toBeLessThan(6);
}
});
test('leaves only a few sub-perceptible frames before the wheel settles', () => {
const deltas = getPerFrameRotation(8, 98);
expect(deltas.filter((delta) => delta < 1.5).length).toBeLessThanOrEqual(8);
});
test('lands the winner exactly under the pointer at every option count', () => {
for (const spinFrames of [36, 78, 98, 200]) {
for (let optionCount = 2; optionCount <= 32; optionCount += 1) {
const sliceDegrees = getSliceDegrees(optionCount);
for (const winnerIndex of [0, 1, optionCount - 1]) {
const finalRotationDegrees = getFinalWheelRotationDegrees(
optionCount,
winnerIndex,
getSpinTurns(spinFrames, optionCount),
);
const restRotation =
getWheelRotationDegrees({
finalRotationDegrees,
frame: spinFrames,
spinFrames,
startRotationDegrees: -24,
}) + getSpinRecoilDegrees(spinFrames, spinFrames, sliceDegrees);
const winnerCenter = getSliceCenterDegrees(optionCount, winnerIndex);
const screenAngle = (((winnerCenter + restRotation) % 360) + 360) % 360;
expect(screenAngle).toBeCloseTo(0);
}
}
}
});
test('rocks into the detent and resolves to exactly zero', () => {
const spinFrames = 98;
const sliceDegrees = 45;
expect(getSpinRecoilDegrees(spinFrames, spinFrames, sliceDegrees)).toBe(0);
expect(getSpinRecoilDegrees(spinFrames + 5, spinFrames, sliceDegrees)).toBe(0);
expect(getSpinRecoilDegrees(0, spinFrames, sliceDegrees)).toBe(0);
/** @type {number[]} */
const offsets = [];
for (let frame = 0; frame <= spinFrames; frame += 1) {
offsets.push(getSpinRecoilDegrees(frame, spinFrames, sliceDegrees));
}
// One full swing: past the detent, then back through it.
expect(Math.max(...offsets)).toBeGreaterThan(0);
expect(Math.min(...offsets)).toBeLessThan(0);
// Never far enough to bring a neighbouring slice near the pointer.
expect(Math.max(...offsets.map(Math.abs))).toBeLessThanOrEqual(sliceDegrees * 0.25);
});
test('scales the rock down so a sparse wheel does not swing', () => {
// A 2-option wheel has 180deg slices; a quarter of that would be a lurch.
const wide = getSpinRecoilDegrees(92, 98, 180);
const narrow = getSpinRecoilDegrees(92, 98, 11.25);
expect(Math.abs(wide)).toBeLessThanOrEqual(2.2);
expect(Math.abs(narrow)).toBeLessThanOrEqual(11.25 * 0.25);
});
test('parks the pointer flapper neutral at rest for every winner', () => {
for (const optionCount of [2, 3, 5, 6, 7, 8, 9, 12, 16, 32]) {
for (let winnerIndex = 0; winnerIndex < optionCount; winnerIndex += 1) {
const restRotation = getFinalWheelRotationDegrees(optionCount, winnerIndex);
expect(getPointerDeflectionDegrees(restRotation, getSliceDegrees(optionCount))).toBe(0);
}
}
});
test('deflects the flapper as a slice edge passes and springs back', () => {
const sliceDegrees = 45;
expect(getPointerDeflectionDegrees(0, sliceDegrees)).toBe(-9);
expect(getPointerDeflectionDegrees(sliceDegrees / 3, sliceDegrees)).toBe(0);
expect(getPointerDeflectionDegrees(sliceDegrees * 0.75, sliceDegrees)).toBe(0);
expect(getPointerDeflectionDegrees(-sliceDegrees, sliceDegrees)).toBe(-9);
});
});
+37 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, test} from 'vitest';
import {themes} from '../src/remotion/themes.js';
import {getSliceColorIndex, themes} from '../src/remotion/themes.js';
/**
* @param {string} color
@@ -31,4 +31,40 @@ describe('wheel themes', () => {
test.each(Object.entries(themes))('%s uses one readable label color across every slice', (_name, theme) => {
expect(theme.slices.every((slice) => getContrastRatio(theme.text, slice) >= 4.5)).toBe(true);
});
test.each(Object.entries(themes))('%s keeps confetti visible against the background', (_name, theme) => {
// Slice colors are tuned to carry dark text over a large area, which leaves
// several of them indistinguishable from the background at particle size.
for (const color of theme.confetti) {
expect(getContrastRatio(color, theme.background)).toBeGreaterThanOrEqual(2);
}
});
test.each(Object.entries(themes))('%s gives the wheel a visible silhouette', (_name, theme) => {
// Either the slices separate from the background on their own, or the
// divider stroke has to carry the outline.
for (const slice of theme.slices) {
const sliceSeparates = getContrastRatio(slice, theme.background) >= 1.2;
const strokeSeparates = getContrastRatio(theme.sliceStroke, theme.background) >= 3;
expect(sliceSeparates || strokeSeparates).toBe(true);
}
});
test.each(Object.entries(themes))('%s reads the winner pill over its own background', (_name, theme) => {
expect(getContrastRatio(theme.winnerPillText, theme.winnerPillBg)).toBeGreaterThanOrEqual(4.5);
});
test.each(Object.entries(themes))('%s never puts one color on adjacent slices', (_name, theme) => {
for (let optionCount = 2; optionCount <= 32; optionCount += 1) {
const colors = Array.from({length: optionCount}, (_unused, index) =>
theme.slices[getSliceColorIndex(index, optionCount, theme.slices.length)],
);
for (let index = 0; index < optionCount; index += 1) {
// Cyclic: the last slice touches the first one on the wheel.
expect(colors[index]).not.toBe(colors[(index + 1) % optionCount]);
}
}
});
});