mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-15 20:16:11 +00:00
revert: restore tree to 757e41d869f20acbecfee64642514878b18dd528
This commit is contained in:
1 parent
3b28e46069
commit
ffa9729683
31 files changed
+254
-3392
No files matched your search
@@ -20,5 +20,12 @@ export const createRenderSemaphore = (max) => {
|
||||
release() {
|
||||
active = Math.max(0, active - 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* @returns {{active: number, max: number}}
|
||||
*/
|
||||
state() {
|
||||
return {active, max};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import {AbsoluteFill, interpolate, random, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
import {createConfettiParticles, getConfettiParticleState} from './confetti-layout.js';
|
||||
|
||||
/**
|
||||
* Winner-celebration confetti burst. Renders nothing before `startFrame`, then
|
||||
* 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 confetti colors)
|
||||
* @param {number|string} props.seed reproducibility seed (e.g. winner index)
|
||||
* @param {number} [props.count] particle count
|
||||
*/
|
||||
export const Confetti = ({startFrame, colors, seed, count = 70}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {durationInFrames, fps, height, width} = useVideoConfig();
|
||||
|
||||
if (frame < startFrame) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const size = Math.min(width, height);
|
||||
const lifetimeFrames = Math.max(1, durationInFrames - 1 - startFrame);
|
||||
const localFrame = frame - startFrame;
|
||||
const seconds = localFrame / fps;
|
||||
// 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 (
|
||||
// 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.
|
||||
if (seconds < particle.delay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = getConfettiParticleState(particle, seconds);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`confetti-${index}`}
|
||||
style={{
|
||||
background: particle.color,
|
||||
// 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,
|
||||
// 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -4,21 +4,13 @@ import {Circle, Pie} from '@remotion/shapes';
|
||||
import {
|
||||
cssDegreesToPieRadians,
|
||||
getFinalWheelRotationDegrees,
|
||||
getPointerDeflectionDegrees,
|
||||
getSliceCenterDegrees,
|
||||
getSliceDegrees,
|
||||
getSpinRecoilDegrees,
|
||||
getSpinTurns,
|
||||
getWheelRotationDegrees,
|
||||
rightPointerClipPath,
|
||||
} from './wheel-layout.js';
|
||||
import {
|
||||
estimateTextWidth,
|
||||
getLabelLines,
|
||||
getRadialLabelLayout,
|
||||
labelLineHeightRatio,
|
||||
} from './wheel-label-layout.js';
|
||||
import {getSliceColor, getTheme, getWinnerPalette} from './themes.js';
|
||||
import {Confetti} from './Confetti.jsx';
|
||||
import {getRadialLabelLayout} from './wheel-label-layout.js';
|
||||
import {getTheme} from './themes.js';
|
||||
|
||||
const baseFont =
|
||||
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
@@ -35,18 +27,13 @@ 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));
|
||||
// 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], {
|
||||
const holdStartFrame = spinFrames;
|
||||
const settle = interpolate(frame, [holdStartFrame, durationInFrames - 1], [0, 1], {
|
||||
easing: Easing.out(Easing.sin),
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const winnerPulse = frame >= revealFrame ? Math.sin(revealProgress * Math.PI) : 0;
|
||||
const isRevealed = frame >= revealFrame;
|
||||
const winnerPulse = frame >= holdStartFrame ? Math.sin(settle * Math.PI) : 0;
|
||||
|
||||
const size = Math.min(width, height);
|
||||
const center = size / 2;
|
||||
@@ -54,53 +41,13 @@ export const WheelComposition = (props) => {
|
||||
const centerRingRadius = size * 0.09;
|
||||
const centerRadius = size * 0.06;
|
||||
const sliceDegrees = getSliceDegrees(props.options.length);
|
||||
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 finalRotation = getFinalWheelRotationDegrees(props.options.length, props.winnerIndex);
|
||||
const startRotation = -24;
|
||||
const rotation =
|
||||
getWheelRotationDegrees({
|
||||
finalRotationDegrees: finalRotation,
|
||||
frame,
|
||||
spinFrames,
|
||||
startRotationDegrees: startRotation,
|
||||
}) + getSpinRecoilDegrees(frame, spinFrames, sliceDegrees);
|
||||
const pointerDeflection = getPointerDeflectionDegrees(rotation, sliceDegrees);
|
||||
|
||||
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.
|
||||
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',
|
||||
const rotation = getWheelRotationDegrees({
|
||||
finalRotationDegrees: finalRotation,
|
||||
frame,
|
||||
spinFrames,
|
||||
startRotationDegrees: startRotation,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -132,19 +79,13 @@ 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) translateZ(0)`,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: '50% 50%',
|
||||
width: size,
|
||||
}}
|
||||
@@ -152,7 +93,8 @@ export const WheelComposition = (props) => {
|
||||
{props.options.map((option, index) => {
|
||||
const start = index * sliceDegrees;
|
||||
const centerDegrees = getSliceCenterDegrees(props.options.length, index);
|
||||
const color = getSliceColor(theme, index, props.options.length);
|
||||
const isWinner = index === props.winnerIndex;
|
||||
const color = theme.slices[index % theme.slices.length] ?? theme.slices[0] ?? '#cccccc';
|
||||
const label = getRadialLabelLayout({
|
||||
center,
|
||||
radius,
|
||||
@@ -160,7 +102,6 @@ export const WheelComposition = (props) => {
|
||||
centerDegrees,
|
||||
optionCount: props.options.length,
|
||||
text: option,
|
||||
winnerCenterDegrees,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -170,9 +111,10 @@ export const WheelComposition = (props) => {
|
||||
progress={1 / props.options.length}
|
||||
radius={radius}
|
||||
rotation={cssDegreesToPieRadians(start)}
|
||||
stroke={theme.sliceStroke}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={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,
|
||||
@@ -191,7 +133,7 @@ export const WheelComposition = (props) => {
|
||||
justifyContent: 'center',
|
||||
left: label.x,
|
||||
letterSpacing: 0,
|
||||
lineHeight: labelLineHeightRatio,
|
||||
lineHeight: 1,
|
||||
overflow: 'hidden',
|
||||
padding: `0 ${label.horizontalPadding}px`,
|
||||
position: 'absolute',
|
||||
@@ -212,28 +154,6 @@ 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
|
||||
@@ -246,67 +166,21 @@ export const WheelComposition = (props) => {
|
||||
radius={centerRadius}
|
||||
style={{left: center - centerRadius, position: 'absolute', top: center - centerRadius}}
|
||||
/>
|
||||
{/* 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"
|
||||
<div
|
||||
style={{
|
||||
left: center + radius - size * 0.045,
|
||||
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,
|
||||
position: 'absolute',
|
||||
top: center - size * 0.0525,
|
||||
transform: `rotate(${pointerDeflection}deg)`,
|
||||
transformOrigin: '100% 50%',
|
||||
top: center - size * 0.0425,
|
||||
width: size * 0.105,
|
||||
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',
|
||||
// Winner's own slice color, framed in a deepened version of it.
|
||||
background: winnerPalette.background,
|
||||
borderRadius: size * 0.03,
|
||||
color: winnerPalette.text,
|
||||
display: 'flex',
|
||||
fontSize: winnerFontSize,
|
||||
fontWeight: 800,
|
||||
justifyContent: 'center',
|
||||
left: '50%',
|
||||
lineHeight: 1.2,
|
||||
outline: `${Math.round(size * 0.008)}px solid ${winnerPalette.outline}`,
|
||||
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 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,
|
||||
}}
|
||||
>
|
||||
{winnerLines.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Confetti colors={theme.confetti} seed={props.winnerIndex} startFrame={revealFrame} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Deterministic confetti field for the winner celebration.
|
||||
*
|
||||
* 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
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
*/
|
||||
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 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 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
|
||||
* @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, windowSeconds = 0}) => {
|
||||
const scale = size / CONFETTI_REFERENCE_SIZE;
|
||||
const palette = colors.length > 0 ? colors : ['#f97316'];
|
||||
|
||||
return Array.from({length: count}, (_, index) => {
|
||||
const rand = (/** @type {string} */ key) => random(`${index}-${key}`);
|
||||
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, 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');
|
||||
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',
|
||||
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,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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, stretch: number, x: number, y: number}}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,13 +1,3 @@
|
||||
/**
|
||||
* 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',
|
||||
@@ -17,11 +7,9 @@ export const themes = {
|
||||
pointer: '#ef4444',
|
||||
pointerStroke: '#991b1b',
|
||||
text: '#111827',
|
||||
textInverse: '#ffffff',
|
||||
textHalo: 'rgba(255, 255, 255, 0.88)',
|
||||
sliceStroke: '#ffffff',
|
||||
slices: ['#f97316', '#14b8a6', '#f472b6', '#60a5fa', '#facc15', '#a78bfa', '#fb7185', '#34d399'],
|
||||
confetti: ['#f97316', '#14b8a6', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#10b981', '#f43f5e'],
|
||||
winnerGlow: 'rgba(250, 204, 21, 0.45)',
|
||||
slices: ['#f97316', '#14b8a6', '#facc15', '#60a5fa', '#f472b6', '#a78bfa', '#34d399', '#fb7185'],
|
||||
},
|
||||
festival: {
|
||||
background: '#fff7ed',
|
||||
@@ -31,11 +19,9 @@ export const themes = {
|
||||
pointer: '#dc2626',
|
||||
pointerStroke: '#7f1d1d',
|
||||
text: '#1f2937',
|
||||
textInverse: '#fffbeb',
|
||||
textHalo: 'rgba(255, 251, 235, 0.92)',
|
||||
sliceStroke: '#ffffff',
|
||||
slices: ['#fb7185', '#06b6d4', '#84cc16', '#a78bfa', '#f59e0b', '#f472b6', '#fb923c', '#22c55e'],
|
||||
confetti: ['#f43f5e', '#ea580c', '#65a30d', '#0891b2', '#7c3aed', '#db2777', '#16a34a', '#d97706'],
|
||||
winnerGlow: 'rgba(251, 146, 60, 0.5)',
|
||||
slices: ['#fb7185', '#f59e0b', '#84cc16', '#06b6d4', '#a78bfa', '#f472b6', '#22c55e', '#fb923c'],
|
||||
},
|
||||
mono: {
|
||||
background: '#f4f4f5',
|
||||
@@ -45,15 +31,9 @@ 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',
|
||||
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'],
|
||||
winnerGlow: 'rgba(161, 161, 170, 0.45)',
|
||||
slices: ['#e4e4e7', '#a1a1aa', '#d4d4d8', '#b8b8bf', '#f4f4f5', '#c4c4ca'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -61,127 +41,3 @@ 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
@@ -23,16 +23,6 @@ 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
|
||||
@@ -185,25 +175,13 @@ 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,
|
||||
winnerCenterDegrees = 0,
|
||||
}) => {
|
||||
export const getRadialLabelLayout = ({center, radius, hubRadius, centerDegrees, optionCount, text}) => {
|
||||
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);
|
||||
@@ -219,34 +197,16 @@ export const getRadialLabelLayout = ({
|
||||
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, fittedFontSize * labelLineHeightRatio * lines.length),
|
||||
height: Math.min(availableHeight, fontSize * lines.length),
|
||||
horizontalPadding,
|
||||
rotation: centerDegrees + flip,
|
||||
fontSize: fittedFontSize,
|
||||
rotation: centerDegrees,
|
||||
fontSize,
|
||||
lines,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
export const fullTurnDegrees = 360;
|
||||
|
||||
/** Turns the wheel makes before landing on the winner. */
|
||||
export const defaultFullTurns = 5;
|
||||
export const rightPointerClipPath = 'polygon(0 50%, 100% 0, 100% 100%)';
|
||||
|
||||
/**
|
||||
* @param {number} optionCount
|
||||
@@ -26,7 +24,7 @@ export const getSliceCenterDegrees = (optionCount, index) =>
|
||||
* @param {number} fullTurns
|
||||
* @returns {number}
|
||||
*/
|
||||
export const getFinalWheelRotationDegrees = (optionCount, winnerIndex, fullTurns = defaultFullTurns) =>
|
||||
export const getFinalWheelRotationDegrees = (optionCount, winnerIndex, fullTurns = 7) =>
|
||||
fullTurns * fullTurnDegrees - getSliceCenterDegrees(optionCount, winnerIndex);
|
||||
|
||||
/**
|
||||
@@ -46,74 +44,16 @@ 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 progress = clamp(frame / Math.max(1, spinFrames), 0, 1);
|
||||
const safeSpinFrames = Math.max(1, spinFrames);
|
||||
const progress = clamp(frame / safeSpinFrames, 0, 1);
|
||||
|
||||
if (progress < accelFraction) {
|
||||
return (handoffSlope * progress * progress) / (2 * accelFraction);
|
||||
}
|
||||
|
||||
const decelProgress = (progress - accelFraction) / (1 - accelFraction);
|
||||
|
||||
return handoffProgress + (1 - (1 - decelProgress) ** decelExponent) * (1 - handoffProgress);
|
||||
return 1 - (1 - progress) ** 3;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -127,64 +67,3 @@ 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;
|
||||
};
|
||||
@@ -1,25 +1,11 @@
|
||||
import {timingSafeEqual} from 'node:crypto';
|
||||
import {ZodError} from 'zod';
|
||||
import {isRenderTimeoutError} from '../lib/render-errors.js';
|
||||
import {pickWinnerIndex} from '../lib/winner.js';
|
||||
import {parseWheelRequest} from '../schemas/wheel-request.js';
|
||||
|
||||
/**
|
||||
* Length-guarded constant-time string comparison so token checks do not leak
|
||||
* information through response timing.
|
||||
*
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const safeEqual = (a, b) => {
|
||||
const aBuffer = Buffer.from(a);
|
||||
const bBuffer = Buffer.from(b);
|
||||
return aBuffer.length === bBuffer.length && timingSafeEqual(aBuffer, bBuffer);
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {import('../config.js').AppConfig} AppConfig
|
||||
* @typedef {import('../lib/render-semaphore.js').createRenderSemaphore} CreateRenderSemaphore
|
||||
* @typedef {import('../schemas/wheel-request.js').WheelRenderRequest} WheelRenderRequest
|
||||
*/
|
||||
|
||||
@@ -46,7 +32,7 @@ export const registerGifRoute = async (app, deps) => {
|
||||
app.post('/api/gif', async (request, reply) => {
|
||||
if (deps.config.apiToken) {
|
||||
const expected = `Bearer ${deps.config.apiToken}`;
|
||||
if (!safeEqual(request.headers.authorization ?? '', expected)) {
|
||||
if (request.headers.authorization !== expected) {
|
||||
return reply.code(401).send({error: 'unauthorized'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
export const themes = ['classic', 'festival', 'mono'];
|
||||
export const allowedSizes = [384, 480, 512];
|
||||
export const allowedFps = [12, 15, 20];
|
||||
const themeSchema = z.union([z.literal('classic'), z.literal('festival'), z.literal('mono')]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,7 +45,7 @@ export const buildServer = async (options = {}) => {
|
||||
return app;
|
||||
};
|
||||
|
||||
if (import.meta.filename === process.argv[1]) {
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const config = loadConfig();
|
||||
const app = await buildServer({config});
|
||||
await app.listen({host: config.host, port: config.port});
|
||||
|
||||
Reference in new issue
Block a user