fix(remotion): tie the winner celebration to the winning slice

Draw the winner outline as an unfilled pie after every slice: slices paint
in index order, so the next one covered the shared radial edge and the
outline stopped short of wrapping the wedge.

Derive the announcement colors from the winning slice - its own color as
the chip, a deepened version as the frame and the wedge outline - instead
of a fixed dark chip that read as unrelated to the wheel.

Raise the confetti above the announcement so the burst is no longer hidden
behind it, and steepen the launch fan so the field arcs around the name
rather than settling on it: horizontal reach is vx / drag, which parked the
flat fan at mid-canvas. Measured over the hold, particle samples inside the
pill drop from 22.4% to 6.2% while disc coverage holds at 85.5%.
This commit is contained in:
tiennm99 committed 2026-07-27 17:42:38 +07:00
1 parent 5ed267d228
commit 3b28e46069
7 files changed
+269 -39

No files matched your search

+4 -1
View File
@@ -43,7 +43,10 @@ export const Confetti = ({startFrame, colors, seed, count = 70}) => {
});
return (
<AbsoluteFill style={{opacity, pointerEvents: 'none', zIndex: 10}}>
// Above the winner pill: the pill covers the hub the burst arcs across, and
// behind it the celebration lost most of its particles at exactly the moment
// it fires.
<AbsoluteFill style={{opacity, pointerEvents: 'none', zIndex: 20}}>
{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.
+39 -15
View File
@@ -17,7 +17,7 @@ import {
getRadialLabelLayout,
labelLineHeightRatio,
} from './wheel-label-layout.js';
import {getSliceColorIndex, getTheme} from './themes.js';
import {getSliceColor, getTheme, getWinnerPalette} from './themes.js';
import {Confetti} from './Confetti.jsx';
const baseFont =
@@ -74,7 +74,11 @@ export const WheelComposition = (props) => {
}) + getSpinRecoilDegrees(frame, spinFrames, sliceDegrees);
const pointerDeflection = getPointerDeflectionDegrees(rotation, sliceDegrees);
const paletteLength = theme.slices.length;
const winnerPalette = getWinnerPalette(
theme,
getSliceColor(theme, props.winnerIndex, props.options.length),
);
const winnerOutlineWidth = 4 + Math.round(winnerPulse * 3);
// 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.
@@ -148,9 +152,7 @@ export const WheelComposition = (props) => {
{props.options.map((option, index) => {
const start = index * sliceDegrees;
const centerDegrees = getSliceCenterDegrees(props.options.length, index);
const isWinner = index === props.winnerIndex;
const colorIndex = getSliceColorIndex(index, props.options.length, paletteLength);
const color = theme.slices[colorIndex] ?? theme.slices[0] ?? '#cccccc';
const color = getSliceColor(theme, index, props.options.length);
const label = getRadialLabelLayout({
center,
radius,
@@ -168,12 +170,8 @@ export const WheelComposition = (props) => {
progress={1 / props.options.length}
radius={radius}
rotation={cssDegreesToPieRadians(start)}
// 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}
stroke={theme.sliceStroke}
strokeWidth={2}
style={{
left: center - radius,
position: 'absolute',
@@ -214,6 +212,28 @@ export const WheelComposition = (props) => {
</React.Fragment>
);
})}
{/* Drawn after every slice rather than as a stroke on the winning one:
slices paint in index order, so the next slice's fill and divider
cover the shared radial edge and the outline stops short of
wrapping the wedge. An unfilled pie on top traces all three edges.
An opaque stroke, not a blurred filter — a filter on a Pie paints
outside the shape and a stroke survives GIF quantisation. */}
{isRevealed && (
<Pie
fill="none"
progress={1 / props.options.length}
radius={radius}
rotation={cssDegreesToPieRadians(props.winnerIndex * sliceDegrees)}
stroke={winnerPalette.outline}
strokeWidth={winnerOutlineWidth}
style={{
left: center - radius,
position: 'absolute',
top: center - radius,
}}
/>
)}
</div>
<Circle
@@ -254,16 +274,17 @@ export const WheelComposition = (props) => {
<div
style={{
alignItems: 'center',
background: theme.winnerPillBg,
// Winner's own slice color, framed in a deepened version of it.
background: winnerPalette.background,
borderRadius: size * 0.03,
color: theme.winnerPillText,
color: winnerPalette.text,
display: 'flex',
fontSize: winnerFontSize,
fontWeight: 800,
justifyContent: 'center',
left: '50%',
lineHeight: 1.2,
outline: `${Math.round(size * 0.008)}px solid ${theme.winnerPillRing}`,
outline: `${Math.round(size * 0.008)}px solid ${winnerPalette.outline}`,
padding: `${size * 0.022}px ${size * 0.042}px`,
position: 'absolute',
textAlign: 'center',
@@ -273,7 +294,10 @@ export const WheelComposition = (props) => {
// 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.
// Above the wheel, below the confetti: chips passing in front sell
// the burst as happening around the announcement instead of being
// swallowed by it, and a chip covers any given glyph for a frame
// or two at most.
zIndex: 12,
}}
>
+18 -2
View File
@@ -73,6 +73,19 @@ const stretchReferenceSpeed = 1200;
const cannonInset = 0.08;
const cannonHeight = 0.8;
/**
* Launch fan for the left cannon, in degrees; 0 points right and negative
* points up. The right cannon mirrors it.
*
* Steep rather than flat: horizontal reach is `vx / drag`, so a flat fan parks
* the whole field around mid-canvas, which is exactly where the winner
* announcement sits. Angling the charge up trades that reach for height, and
* the chips rain down across the disc on either side of the pill instead of
* settling on top of it.
*/
const minLaunchDegrees = -86;
const maxLaunchDegrees = -54;
/** Body size range in px at the reference size. */
const minWidth = 6;
const maxWidth = 18;
@@ -134,7 +147,8 @@ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
* 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.
* legible, and spreads the field over the whole canvas rather than piling it on
* the winner announcement.
*
* @param {object} params
* @param {number} params.count number of particles
@@ -158,7 +172,9 @@ export const createConfettiParticles = ({count, size, colors, random, windowSeco
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 angleDegrees = fromLeft
? lerp(spread, minLaunchDegrees, maxLaunchDegrees)
: lerp(spread, -180 - minLaunchDegrees, -180 - maxLaunchDegrees);
const angleRadians = (angleDegrees * Math.PI) / 180;
const speed = lerp(rand('speed'), minSpeed, maxSpeed) * scale;
const delayRoll = rand('delay');
+101 -12
View File
@@ -17,12 +17,9 @@ export const themes = {
pointer: '#ef4444',
pointerStroke: '#991b1b',
text: '#111827',
textInverse: '#ffffff',
textHalo: 'rgba(255, 255, 255, 0.88)',
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'],
},
@@ -34,12 +31,9 @@ export const themes = {
pointer: '#dc2626',
pointerStroke: '#7f1d1d',
text: '#1f2937',
textInverse: '#fffbeb',
textHalo: 'rgba(255, 251, 235, 0.92)',
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'],
},
@@ -51,14 +45,11 @@ export const themes = {
pointer: '#27272a',
pointerStroke: '#09090b',
text: '#18181b',
textInverse: '#fafafa',
textHalo: 'rgba(250, 250, 250, 0.9)',
// 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.
@@ -96,3 +87,101 @@ export const getSliceColorIndex = (index, optionCount, paletteLength) => {
return index % paletteLength;
};
/**
* Resolves the color a slice is painted with.
*
* @param {{slices: string[]}} theme
* @param {number} index slice index
* @param {number} optionCount total slices
* @returns {string}
*/
export const getSliceColor = (theme, index, optionCount) =>
theme.slices[getSliceColorIndex(index, optionCount, theme.slices.length)] ??
theme.slices[0] ??
'#cccccc';
/**
* @param {string} hex `#rrggbb`
* @returns {number[]} channel values in [0, 255]
*/
const parseHex = (hex) =>
(hex.replace('#', '').match(/.{2}/gu) ?? []).map((channel) => Number.parseInt(channel, 16));
/**
* @param {number[]} channels
* @returns {string}
*/
const toHex = (channels) =>
`#${channels
.map((channel) => Math.round(Math.min(255, Math.max(0, channel))).toString(16).padStart(2, '0'))
.join('')}`;
/**
* @param {string} from
* @param {string} to
* @param {number} amount 0 keeps `from`, 1 returns `to`
* @returns {string}
*/
const mixColors = (from, to, amount) => {
const source = parseHex(from);
const target = parseHex(to);
return toHex(source.map((channel, index) => channel + ((target[index] ?? channel) - channel) * amount));
};
/**
* @param {string} hex
* @returns {number}
*/
const getRelativeLuminance = (hex) => {
const [red = 0, green = 0, blue = 0] = parseHex(hex)
.map((channel) => channel / 255)
.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
/**
* @param {string} foreground
* @param {string} background
* @returns {number} WCAG contrast ratio
*/
const getContrastRatio = (foreground, background) => {
const first = getRelativeLuminance(foreground);
const second = getRelativeLuminance(background);
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
};
/**
* How far the winner outline is pulled from the slice color toward the theme
* ink. Enough to clear 3:1 against every slice in every theme, while keeping
* the hue so the outline reads as belonging to the slice it frames.
*/
const winnerOutlineInkMix = 0.72;
/**
* Winner colors derived from the winning slice.
*
* The celebration is tied to the slice that actually won: the announcement
* carries that slice's color and both it and the wheel outline are framed in a
* deepened version of the same hue. A fixed dark chip read as an unrelated
* element pasted over the wheel.
*
* Ink is chosen by contrast rather than fixed, because a palette entry light
* enough to carry dark label text is not guaranteed to carry it at pill size in
* every future theme.
*
* @param {{slices: string[], text: string, textInverse: string}} theme
* @param {string} sliceColor color of the winning slice
* @returns {{background: string, outline: string, text: string}}
*/
export const getWinnerPalette = (theme, sliceColor) => ({
background: sliceColor,
outline: mixColors(sliceColor, theme.text, winnerOutlineInkMix),
text:
getContrastRatio(theme.text, sliceColor) >= getContrastRatio(theme.textInverse, sliceColor)
? theme.text
: theme.textInverse,
});