mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-13 22:16:33 +00:00
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:
1 parent
5ed267d228
commit
3b28e46069
7 files changed
+269
-39
No files matched your search
+9
-6
@@ -31,12 +31,15 @@ Response is `image/gif` with winner metadata headers:
|
||||
|
||||
`X-Wheel-Winner` is URL-encoded so non-ASCII labels are safe in HTTP headers.
|
||||
|
||||
The GIF ends on a winner celebration: the wheel settles, the winning slice is
|
||||
outlined, the winner's name appears over the hub, and a deterministic two-cannon
|
||||
confetti burst crosses the wheel. Each theme carries its own confetti palette,
|
||||
chosen for contrast against that theme's background rather than reusing the
|
||||
slice colors. The celebration starts a few frames before the wheel
|
||||
mathematically settles, so it lands on the perceived stop.
|
||||
The GIF ends on a winner celebration: the wheel settles, the winning wedge is
|
||||
outlined edge to edge, the winner's name appears over the hub, and a
|
||||
deterministic two-cannon confetti burst crosses the wheel in front of it. The
|
||||
announcement is colored from the winning slice — its own color as the chip, a
|
||||
deepened version of that color as the frame and as the wedge outline — so the
|
||||
theme picks the palette but the winner picks the accent. Each theme carries its
|
||||
own confetti palette, chosen for contrast against that theme's background rather
|
||||
than reusing the slice colors. The celebration starts a few frames before the
|
||||
wheel mathematically settles, so it lands on the perceived stop.
|
||||
|
||||
Labels stay radial but flip where needed so every name reads upright in the
|
||||
final frame, which is the frame most chat clients show as the GIF's poster
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
@@ -17,6 +17,21 @@ const makeRandom = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// Hash source for distribution checks: the cyclic stub above only spans ten
|
||||
// values, which is too coarse to say anything about where a field of 70
|
||||
// particles ends up.
|
||||
const makeHashRandom = (/** @type {number} */ seed) => (/** @type {string} */ key) => {
|
||||
let hash = 2166136261;
|
||||
const input = `${seed}-${key}`;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
hash ^= input.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
|
||||
return ((hash >>> 0) % 100000) / 100000;
|
||||
};
|
||||
|
||||
describe('confetti layout', () => {
|
||||
test('creates the requested particle count', () => {
|
||||
const particles = createConfettiParticles({
|
||||
@@ -114,6 +129,56 @@ describe('confetti layout', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('spreads the field around the winner announcement, not onto it', () => {
|
||||
const size = CONFETTI_REFERENCE_SIZE;
|
||||
const fps = 15;
|
||||
const holdFrames = 18;
|
||||
// The pill sits over the hub, measured as a fraction of the canvas at the
|
||||
// longest name that still fits two lines.
|
||||
const pill = {x0: 0.34 * size, x1: 0.66 * size, y0: 0.43 * size, y1: 0.57 * size};
|
||||
const discRadius = 0.41 * size;
|
||||
let onPill = 0;
|
||||
let onDisc = 0;
|
||||
let samples = 0;
|
||||
|
||||
for (let seed = 0; seed < 12; seed += 1) {
|
||||
const particles = createConfettiParticles({
|
||||
colors: ['#000'],
|
||||
count: 70,
|
||||
random: makeHashRandom(seed),
|
||||
size,
|
||||
windowSeconds: holdFrames / fps,
|
||||
});
|
||||
|
||||
for (let frame = 0; frame <= holdFrames; frame += 1) {
|
||||
const seconds = frame / fps;
|
||||
|
||||
for (const particle of particles) {
|
||||
if (seconds < particle.delay) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = getConfettiParticleState(particle, seconds);
|
||||
samples += 1;
|
||||
|
||||
if (state.x >= pill.x0 && state.x <= pill.x1 && state.y >= pill.y0 && state.y <= pill.y1) {
|
||||
onPill += 1;
|
||||
}
|
||||
|
||||
if (Math.hypot(state.x - size / 2, state.y - size / 2) <= discRadius) {
|
||||
onDisc += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confetti draws in front of the pill, so a flat launch fan that parks the
|
||||
// whole field at mid-canvas buries the winner's name.
|
||||
expect(onPill / samples).toBeLessThan(0.12);
|
||||
// ...without pushing the burst off the wheel it is celebrating.
|
||||
expect(onDisc / samples).toBeGreaterThan(0.6);
|
||||
});
|
||||
|
||||
test('drag gives a terminal fall speed instead of unbounded acceleration', () => {
|
||||
const [particle] = createConfettiParticles({
|
||||
colors: ['#000'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {getSliceColorIndex, themes} from '../src/remotion/themes.js';
|
||||
import {getSliceColor, getSliceColorIndex, getWinnerPalette, themes} from '../src/remotion/themes.js';
|
||||
|
||||
/**
|
||||
* @param {string} color
|
||||
@@ -51,8 +51,38 @@ describe('wheel themes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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 reads the winner name over any winning slice', (_name, theme) => {
|
||||
for (const slice of theme.slices) {
|
||||
const palette = getWinnerPalette(theme, slice);
|
||||
|
||||
expect(getContrastRatio(palette.text, palette.background)).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
test.each(Object.entries(themes))('%s frames the winner in its own hue', (_name, theme) => {
|
||||
for (const slice of theme.slices) {
|
||||
const palette = getWinnerPalette(theme, slice);
|
||||
|
||||
// The outline separates the pill from the wheel behind it and the winning
|
||||
// wedge from its neighbours, so it has to clear both the slice it frames
|
||||
// and the page.
|
||||
expect(getContrastRatio(palette.outline, slice)).toBeGreaterThanOrEqual(3);
|
||||
expect(getContrastRatio(palette.outline, theme.background)).toBeGreaterThanOrEqual(3);
|
||||
// Derived from the slice, not a fixed chip: two different winners must not
|
||||
// produce the same frame.
|
||||
expect(palette.outline).not.toBe(theme.text);
|
||||
}
|
||||
});
|
||||
|
||||
test.each(Object.entries(themes))('%s paints the winner pill in the winning slice color', (_name, theme) => {
|
||||
for (let optionCount = 2; optionCount <= 12; optionCount += 1) {
|
||||
for (let winnerIndex = 0; winnerIndex < optionCount; winnerIndex += 1) {
|
||||
const slice = getSliceColor(theme, winnerIndex, optionCount);
|
||||
|
||||
expect(theme.slices).toContain(slice);
|
||||
expect(getWinnerPalette(theme, slice).background).toBe(slice);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.each(Object.entries(themes))('%s never puts one color on adjacent slices', (_name, theme) => {
|
||||
|
||||
Reference in new issue
Block a user