diff --git a/renderer/src/remotion/Confetti.jsx b/renderer/src/remotion/Confetti.jsx index 1017fc9..e340619 100644 --- a/renderer/src/remotion/Confetti.jsx +++ b/renderer/src/remotion/Confetti.jsx @@ -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 ( {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, }} diff --git a/renderer/src/remotion/WheelComposition.jsx b/renderer/src/remotion/WheelComposition.jsx index 3550e87..6a897ed 100644 --- a/renderer/src/remotion/WheelComposition.jsx +++ b/renderer/src/remotion/WheelComposition.jsx @@ -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. */}
{ 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}} /> -
+ > + + + + {isRevealed && ( +
+ {winnerLines.join('\n')} +
+ )}
- + ); }; diff --git a/renderer/src/remotion/confetti-layout.js b/renderer/src/remotion/confetti-layout.js index dbf2565..0299358 100644 --- a/renderer/src/remotion/confetti-layout.js +++ b/renderer/src/remotion/confetti-layout.js @@ -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, + }; +}; diff --git a/renderer/src/remotion/themes.js b/renderer/src/remotion/themes.js index b348b6b..0daec59 100644 --- a/renderer/src/remotion/themes.js +++ b/renderer/src/remotion/themes.js @@ -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; +}; diff --git a/renderer/src/remotion/wheel-label-layout.js b/renderer/src/remotion/wheel-label-layout.js index dd7c0c4..01661a2 100644 --- a/renderer/src/remotion/wheel-label-layout.js +++ b/renderer/src/remotion/wheel-label-layout.js @@ -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, }; }; diff --git a/renderer/src/remotion/wheel-layout.js b/renderer/src/remotion/wheel-layout.js index 949f302..1a91b0b 100644 --- a/renderer/src/remotion/wheel-layout.js +++ b/renderer/src/remotion/wheel-layout.js @@ -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; +}; diff --git a/renderer/test/confetti-layout.test.js b/renderer/test/confetti-layout.test.js index b5ef192..65e87be 100644 --- a/renderer/test/confetti-layout.test.js +++ b/renderer/test/confetti-layout.test.js @@ -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); + } }); }); diff --git a/renderer/test/wheel-label-layout.test.js b/renderer/test/wheel-label-layout.test.js index 00630fc..bbefdd0 100644 --- a/renderer/test/wheel-label-layout.test.js +++ b/renderer/test/wheel-label-layout.test.js @@ -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', () => { diff --git a/renderer/test/wheel-layout.test.js b/renderer/test/wheel-layout.test.js index 484e199..5a459a8 100644 --- a/renderer/test/wheel-layout.test.js +++ b/renderer/test/wheel-layout.test.js @@ -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); }); }); diff --git a/renderer/test/wheel-themes.test.js b/renderer/test/wheel-themes.test.js index 4c7c0e0..5d5b3c4 100644 --- a/renderer/test/wheel-themes.test.js +++ b/renderer/test/wheel-themes.test.js @@ -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]); + } + } + }); });