mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
The animation's weakest moment was its final frame, which is also the frame most chat clients show as the GIF's poster image. Celebration: - Confetti launches from two cannons framing the wheel instead of bursting inside the hub, using closed-form drag ballistics so chips decelerate and flutter rather than flying a clean parabola. The field previously drained from 70 particles to 3 before the GIF ended; it now stays populated, and a 2500ms hold ends with 68 on screen instead of 0. - The opening frame rendered at opacity 0, throwing away the one frame where particles are still clustered. Removed the fade-in and made the tail frame-relative and quantized to bound its palette cost. - Chips streak along their direction of travel in proportion to speed, faking the motion blur a GIF cannot carry. A second volley, sized to the celebration window, keeps long holds from thinning out. - Each theme carries its own confetti palette. Slice colors are tuned to hold dark text over a large area, which left five of six mono colors under 2.4:1 against the background and invisible at particle size. - The winner's name appears over the hub during the hold. At a 200px chat display the pointer is ~17px and a slice label ~9px, so the poster frame did not say who won. - Winner emphasis is an opaque stroke, not a drop-shadow. A filter on a Pie paints outside the shape, so later siblings overpainted it and only one neighbour ever showed the halo. Motion: - Turn count scales with the frame budget and slice width. A fixed 5 turns put peak speed at ~40deg per frame regardless of option count, so 65 of 98 frames advanced more than a full slice at 32 options and the wheel appeared to run backwards. Turn counts floor rather than round, since rounding up reintroduces the aliasing, and stay integral so the winner still lands on the detent. - The spin launch now blends into the deceleration at a matched slope. The previous curve started at maximum speed, and a naive ease-in jumped from 18 to 41deg per frame in a single frame. - A damped recoil rocks the wheel into its detent, resolving to exactly zero so the landing and the flapper's rest position are unaffected. - The pointer is drawn as SVG with a real outline and a tick as slices pass. clip-path had been discarding its border and shadow entirely. Legibility: - Labels flip by their rest-frame angle so every name reads upright in the final frame. This reverses an earlier decision to leave them slice-aligned; three of eight names rendered mirrored on a default wheel. - Label text renders inside a promoted compositing layer, which switches Chromium from LCD sub-pixel to grayscale antialiasing. Sub-pixel fringes put 2178 colored pixels into an all-grayscale theme and consumed 255 of 256 palette entries. Promoting the one rotating container rather than each label keeps render time flat on dense wheels. - Line boxes leave room for stacked Vietnamese diacritics and shrink to fit rather than clipping. - Slice colors are ordered for colorblind separation, and the last slice avoids reusing the first slice's color when the option count leaves a remainder of one.
320 lines
11 KiB
JavaScript
320 lines
11 KiB
JavaScript
import {describe, expect, test} from 'vitest';
|
|
import {
|
|
estimateTextWidth,
|
|
getLabelFontSize,
|
|
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';
|
|
const supportedSize = 512;
|
|
const supportedRadius = supportedSize * 0.41;
|
|
const supportedHubRadius = supportedSize * 0.09;
|
|
|
|
/**
|
|
* @param {object} [overrides]
|
|
* @param {number} [overrides.optionCount]
|
|
* @param {string} [overrides.text]
|
|
*/
|
|
const get512Layout = ({optionCount = 8, text = readableExample} = {}) =>
|
|
getRadialLabelLayout({
|
|
center: supportedSize / 2,
|
|
radius: supportedRadius,
|
|
hubRadius: supportedHubRadius,
|
|
centerDegrees: 0,
|
|
optionCount,
|
|
text,
|
|
});
|
|
|
|
test('centers radial label tracks on the slice center angle', () => {
|
|
const center = 256;
|
|
const radius = 210;
|
|
const hubRadius = 46;
|
|
const centerDegrees = 45;
|
|
const layout = getRadialLabelLayout({
|
|
center,
|
|
radius,
|
|
hubRadius,
|
|
centerDegrees,
|
|
optionCount: 8,
|
|
text: 'alice',
|
|
});
|
|
const track = getLabelTrack(radius, hubRadius);
|
|
const radians = (centerDegrees * Math.PI) / 180;
|
|
|
|
expect(layout.x).toBeCloseTo(center + Math.cos(radians) * track.midRadius);
|
|
expect(layout.y).toBeCloseTo(center + Math.sin(radians) * track.midRadius);
|
|
expect(layout.width).toBe(track.width);
|
|
expect(layout.rotation).toBe(centerDegrees);
|
|
});
|
|
|
|
test('flips labels that would rest mirrored so the poster frame reads upright', () => {
|
|
const layout = getRadialLabelLayout({
|
|
center: 256,
|
|
radius: 210,
|
|
hubRadius: 46,
|
|
centerDegrees: 180,
|
|
optionCount: 8,
|
|
text: 'left side',
|
|
winnerCenterDegrees: 0,
|
|
});
|
|
|
|
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', () => {
|
|
const layout = getRadialLabelLayout({
|
|
center: 256,
|
|
radius: 210,
|
|
hubRadius: 46,
|
|
centerDegrees: 0,
|
|
optionCount: 8,
|
|
text: 'alice',
|
|
});
|
|
|
|
expect(layout.lines).toEqual(['alice']);
|
|
expect(layout.fontSize).toBe(getLabelFontSize(210, 8));
|
|
});
|
|
|
|
test('fits smoke-fixture labels on one line at the minimum supported size', () => {
|
|
const size = 384;
|
|
const radius = size * 0.41;
|
|
const hubRadius = size * 0.09;
|
|
const expectedFontSizes = new Map([
|
|
['alpha', 21],
|
|
['beta', 22],
|
|
['gamma', 20],
|
|
['delta', 21],
|
|
]);
|
|
|
|
for (const [text, expectedFontSize] of expectedFontSizes) {
|
|
const layout = getRadialLabelLayout({
|
|
center: size / 2,
|
|
radius,
|
|
hubRadius,
|
|
centerDegrees: 0,
|
|
optionCount: 4,
|
|
text,
|
|
});
|
|
|
|
expect(layout.lines).toEqual([text]);
|
|
expect(layout.fontSize).toBe(expectedFontSize);
|
|
}
|
|
});
|
|
|
|
test('wraps long phrases into balanced word-boundary lines', () => {
|
|
const lines = getLabelLines('a much longer wheel entry label', 24, 140, 3);
|
|
const widths = lines.map((line) => estimateTextWidth(line, 24));
|
|
|
|
expect(lines).toHaveLength(3);
|
|
expect(lines.join(' ')).toBe('a much longer wheel entry label');
|
|
expect(Math.max(...widths) - Math.min(...widths)).toBeLessThan(80);
|
|
});
|
|
|
|
test('sizes wrapped labels from their longest rendered line', () => {
|
|
const layout = getRadialLabelLayout({
|
|
center: 256,
|
|
radius: 210,
|
|
hubRadius: 46,
|
|
centerDegrees: 0,
|
|
optionCount: 8,
|
|
text: 'a much longer wheel entry label',
|
|
});
|
|
const longestLine = layout.lines.reduce((longest, line) =>
|
|
estimateTextWidth(line, 24) > estimateTextWidth(longest, 24) ? line : longest,
|
|
'');
|
|
|
|
expect(layout.lines.length).toBeGreaterThan(1);
|
|
expect(layout.fontSize).toBe(getLabelFontSize(210, 8, longestLine, layout.contentWidth));
|
|
expect(layout.fontSize).toBeGreaterThan(
|
|
getLabelFontSize(210, 8, 'a much longer wheel entry label', layout.contentWidth),
|
|
);
|
|
expect(layout.height).toBeLessThanOrEqual((2 * Math.PI * getLabelTrack(210, 46).midRadius) / 8);
|
|
});
|
|
|
|
test.each([2, 8, 12, 16])(
|
|
'wraps the Vietnamese readability example when geometry permits at %i options',
|
|
(optionCount) => {
|
|
const layout = get512Layout({optionCount});
|
|
const availableHeight =
|
|
(2 * Math.PI * getLabelTrack(supportedRadius, supportedHubRadius).midRadius) / optionCount;
|
|
|
|
expect(layout.lines.length).toBeGreaterThanOrEqual(2);
|
|
expect(layout.lines.join(' ')).toBe(readableExample);
|
|
expect(layout.fontSize).toBeGreaterThanOrEqual(14);
|
|
expect(layout.height).toBeLessThanOrEqual(availableHeight);
|
|
expect(layout.lines.every((line) => estimateTextWidth(line, layout.fontSize) <= layout.contentWidth)).toBe(true);
|
|
},
|
|
);
|
|
|
|
test.each([
|
|
[9, 'WWWWWWWWWWWWWWWW'],
|
|
[10, 'WWWWWWWWWWWWWW'],
|
|
[11, 'WWWWWWWWWWWWW'],
|
|
[12, 'WWWWWWWWWWWW'],
|
|
[13, 'WWWWWWWWWWW'],
|
|
])('wraps an unbroken token whose one-line fit is %ipx', (oneLineFontSize, text) => {
|
|
const layout = get512Layout({text});
|
|
const track = getLabelTrack(supportedRadius, supportedHubRadius);
|
|
const contentWidth = track.width - Math.max(4, supportedRadius * 0.02) * 2;
|
|
|
|
expect(getLabelFontSize(supportedRadius, 8, text, contentWidth)).toBe(oneLineFontSize);
|
|
expect(layout.lines.length).toBeGreaterThanOrEqual(2);
|
|
expect(layout.lines.join('')).toBe(text);
|
|
expect(layout.fontSize).toBeGreaterThanOrEqual(14);
|
|
expect(layout.lines.every((line) => estimateTextWidth(line, layout.fontSize) <= layout.contentWidth)).toBe(true);
|
|
});
|
|
|
|
test('keeps an exactly 14px one-line fit on one line', () => {
|
|
const text = 'WWWWWWWWWW';
|
|
const layout = get512Layout({text});
|
|
|
|
expect(getLabelFontSize(supportedRadius, 8, text, layout.contentWidth)).toBe(14);
|
|
expect(layout.lines).toEqual([text]);
|
|
expect(layout.fontSize).toBe(14);
|
|
});
|
|
|
|
test.each([24, 32])('keeps the complete Vietnamese example bounded on one line at %i options', (optionCount) => {
|
|
const layout = get512Layout({optionCount});
|
|
const availableHeight =
|
|
(2 * Math.PI * getLabelTrack(supportedRadius, supportedHubRadius).midRadius) / optionCount;
|
|
|
|
expect(layout.lines).toEqual([readableExample]);
|
|
expect(layout.fontSize).toBeLessThan(14);
|
|
expect(layout.height).toBeLessThanOrEqual(availableHeight);
|
|
expect(estimateTextWidth(layout.lines[0] ?? '', layout.fontSize)).toBeLessThanOrEqual(layout.contentWidth);
|
|
});
|
|
|
|
test('keeps supported dense layouts to one bounded line', () => {
|
|
const size = 384;
|
|
const radius = size * 0.41;
|
|
const hubRadius = size * 0.09;
|
|
const optionCount = 32;
|
|
const layout = getRadialLabelLayout({
|
|
center: size / 2,
|
|
radius,
|
|
hubRadius,
|
|
centerDegrees: 0,
|
|
optionCount,
|
|
text: 'a label that reaches the configured limit',
|
|
});
|
|
const availableHeight = (2 * Math.PI * getLabelTrack(radius, hubRadius).midRadius) / optionCount;
|
|
|
|
expect(layout.lines).toEqual(['a label that reaches the configured limit']);
|
|
expect(layout.fontSize).toBe(8);
|
|
expect(layout.height).toBeLessThanOrEqual(availableHeight);
|
|
});
|
|
|
|
test('keeps the border box on the radial track and fits text to its padded content width', () => {
|
|
const radius = 384 * 0.41;
|
|
const hubRadius = 384 * 0.09;
|
|
const layout = getRadialLabelLayout({
|
|
center: 192,
|
|
radius,
|
|
hubRadius,
|
|
centerDegrees: 0,
|
|
optionCount: 4,
|
|
text: 'alpha',
|
|
});
|
|
|
|
const track = getLabelTrack(radius, hubRadius);
|
|
|
|
expect(layout.width).toBe(track.width);
|
|
expect(layout.horizontalPadding).toBe(Math.max(4, radius * 0.02));
|
|
expect(layout.contentWidth).toBe(layout.width - layout.horizontalPadding * 2);
|
|
expect(estimateTextWidth(layout.lines[0] ?? '', layout.fontSize)).toBeLessThanOrEqual(layout.contentWidth);
|
|
});
|
|
|
|
test('normalizes whitespace and splits unbroken text without breaking graphemes', () => {
|
|
expect(getLabelLines(' alpha beta\n gamma ', 24, 90, 3).join(' ')).toBe('alpha beta gamma');
|
|
|
|
const token = 'winner🏆winner🏆winner';
|
|
const lines = getLabelLines(token, 24, 90, 3);
|
|
|
|
expect(lines.length).toBeGreaterThan(1);
|
|
expect(lines.join('')).toBe(token);
|
|
expect(lines.every((line) => {
|
|
const firstCodeUnit = line.charCodeAt(0);
|
|
const lastCodeUnit = line.charCodeAt(line.length - 1);
|
|
|
|
return !(firstCodeUnit >= 0xdc00 && firstCodeUnit <= 0xdfff) && !(lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff);
|
|
})).toBe(true);
|
|
});
|
|
});
|