fix: align wheel labels and compose env

This commit is contained in:
tiennm99 committed 2026-07-06 22:50:11 +07:00
1 parent 3f5ed700bb
commit da92e9b443
6 files changed
+219 -51

No files matched your search

+10 -9
View File
@@ -2,17 +2,18 @@ services:
wheelofnames:
build: .
environment:
HOST: 0.0.0.0
PORT: 3000
API_TOKEN: change-me
MAX_CONCURRENT_RENDERS: 1
RENDER_TIMEOUT_MS: 15000
MAX_OPTIONS: 32
MAX_OPTION_CHARS: 40
NODE_ENV: "${NODE_ENV:-production}"
HOST: "${HOST:-0.0.0.0}"
PORT: "${PORT:-3000}"
API_TOKEN: "${API_TOKEN:-change-me}"
MAX_CONCURRENT_RENDERS: "${MAX_CONCURRENT_RENDERS:-1}"
RENDER_TIMEOUT_MS: "${RENDER_TIMEOUT_MS:-15000}"
MAX_OPTIONS: "${MAX_OPTIONS:-32}"
MAX_OPTION_CHARS: "${MAX_OPTION_CHARS:-40}"
ports:
- "3000:3000"
- "${PORT:-3000}:${PORT:-3000}"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:${PORT:-3000}/api/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 5s
retries: 3
+3
View File
@@ -33,6 +33,9 @@ MAX_OPTION_CHARS=40
API_TOKEN=change-me
```
`compose.yml` forwards these values from the shell with `${VAR:-default}`
fallbacks, so unset or empty shell variables use the defaults above.
Start with 1-2 vCPU and 1-2 GB RAM. Increase only after render benchmarks show
the service is CPU-bound or concurrency-limited.
+30 -33
View File
@@ -4,27 +4,19 @@ import {Circle, Pie} from '@remotion/shapes';
import {
cssDegreesToPieRadians,
getFinalWheelRotationDegrees,
getReadableTextRotationDegrees,
getSliceCenterDegrees,
getSliceDegrees,
} from './wheel-layout.js';
import {getContrastingTextColor, getRadialLabelLayout} from './wheel-label-layout.js';
import {getTheme} from './themes.js';
const baseFont =
'Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
/**
* @typedef {import('../schemas/wheel-request.js').WheelRenderRequest} WheelRenderRequest
*/
/**
* @param {{text: string, maxLength?: number}} props
*/
const LabelText = ({text, maxLength = 24}) => {
const displayText = text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
return <>{displayText}</>;
};
/**
* @param {WheelRenderRequest} props
*/
@@ -49,6 +41,8 @@ export const WheelComposition = (props) => {
const size = Math.min(width, height);
const center = size / 2;
const radius = size * 0.41;
const centerRingRadius = size * 0.09;
const centerRadius = size * 0.06;
const sliceDegrees = getSliceDegrees(props.options.length);
const finalRotation = getFinalWheelRotationDegrees(props.options.length, props.winnerIndex);
const startRotation = -24;
@@ -56,9 +50,6 @@ export const WheelComposition = (props) => {
easing: Easing.out(Easing.cubic),
});
const labelRadius = radius * (props.options.length > 20 ? 0.64 : 0.58);
const fontSize = Math.max(10, Math.min(24, radius / Math.max(5.8, props.options.length * 0.33)));
return (
<AbsoluteFill
style={{
@@ -103,10 +94,15 @@ export const WheelComposition = (props) => {
const start = index * sliceDegrees;
const centerDegrees = getSliceCenterDegrees(props.options.length, index);
const isWinner = index === props.winnerIndex;
const color = theme.slices[index % theme.slices.length] ?? theme.slices[0];
const x = center + Math.cos((centerDegrees * Math.PI) / 180) * labelRadius;
const y = center + Math.sin((centerDegrees * Math.PI) / 180) * labelRadius;
const textRotation = getReadableTextRotationDegrees(centerDegrees);
const color = theme.slices[index % theme.slices.length] ?? theme.slices[0] ?? '#cccccc';
const label = getRadialLabelLayout({
center,
radius,
hubRadius: centerRingRadius,
centerDegrees,
optionCount: props.options.length,
text: option,
});
return (
<React.Fragment key={`${option}-${index}`}>
@@ -127,28 +123,29 @@ export const WheelComposition = (props) => {
<div
style={{
alignItems: 'center',
color: theme.text,
boxSizing: 'border-box',
color: getContrastingTextColor(color),
display: 'flex',
fontSize,
fontSize: label.fontSize,
fontWeight: 400,
height: fontSize * 1.35,
justifyContent: 'center',
left: x,
height: label.fontSize * 1.35,
justifyContent: 'flex-end',
left: label.x,
letterSpacing: 0,
lineHeight: 1,
maxWidth: radius * 0.68,
overflow: 'hidden',
padding: '0 4px',
padding: `0 ${Math.max(4, radius * 0.02)}px 0 0`,
position: 'absolute',
textAlign: 'center',
textShadow: `0 1px 0 ${theme.textHalo}, 0 -1px 0 ${theme.textHalo}, 1px 0 0 ${theme.textHalo}, -1px 0 0 ${theme.textHalo}`,
top: y,
transform: `translate(-50%, -50%) rotate(${textRotation}deg)`,
textAlign: 'right',
textOverflow: 'ellipsis',
top: label.y,
transform: `translate(-50%, -50%) rotate(${label.rotation}deg)`,
transformOrigin: '50% 50%',
whiteSpace: 'nowrap',
width: label.width,
}}
>
<LabelText text={option} />
{option}
</div>
</React.Fragment>
);
@@ -157,13 +154,13 @@ export const WheelComposition = (props) => {
<Circle
fill={theme.centerRing}
radius={size * 0.09}
style={{left: center - size * 0.09, position: 'absolute', top: center - size * 0.09}}
radius={centerRingRadius}
style={{left: center - centerRingRadius, position: 'absolute', top: center - centerRingRadius}}
/>
<Circle
fill={theme.center}
radius={size * 0.06}
style={{left: center - size * 0.06, position: 'absolute', top: center - size * 0.06}}
radius={centerRadius}
style={{left: center - centerRadius, position: 'absolute', top: center - centerRadius}}
/>
<div
style={{
+123
View File
@@ -0,0 +1,123 @@
/**
* @typedef {object} LabelTrack
* @property {number} startRadius
* @property {number} endRadius
* @property {number} midRadius
* @property {number} width
*/
/**
* @typedef {object} RadialLabelLayout
* @property {number} x
* @property {number} y
* @property {number} width
* @property {number} rotation
* @property {number} fontSize
*/
const minLabelWidth = 24;
/**
* @param {string} text
* @param {number} fontSize
* @returns {number}
*/
export const estimateTextWidth = (text, fontSize) => {
const widthUnits = Array.from(text).reduce((sum, char) => {
if (/[\s.,'`|!il:;]/u.test(char)) {
return sum + 0.32;
}
if (/[A-ZMW@#%&]/u.test(char)) {
return sum + 0.72;
}
return sum + 0.56;
}, 0);
return (widthUnits + 2) * fontSize;
};
/**
* @param {number} radius
* @param {number} hubRadius
* @returns {LabelTrack}
*/
export const getLabelTrack = (radius, hubRadius) => {
const startRadius = hubRadius + Math.max(8, radius * 0.08);
const endRadius = radius - Math.max(5, radius * 0.035);
const width = Math.max(minLabelWidth, endRadius - startRadius);
return {
startRadius,
endRadius: startRadius + width,
midRadius: startRadius + width / 2,
width,
};
};
/**
* @param {number} radius
* @param {number} optionCount
* @param {string} text
* @param {number} trackWidth
* @returns {number}
*/
export const getLabelFontSize = (radius, optionCount, text = '', trackWidth = Number.POSITIVE_INFINITY) => {
const baseSize = Math.max(10, Math.min(24, radius / Math.max(5.8, optionCount * 0.33)));
if (!text || !Number.isFinite(trackWidth)) {
return baseSize;
}
const estimatedWidth = estimateTextWidth(text, baseSize);
if (estimatedWidth <= trackWidth) {
return baseSize;
}
return Math.max(8, Math.floor(baseSize * (trackWidth / estimatedWidth)));
};
/**
* @param {object} input
* @param {number} input.center
* @param {number} input.radius
* @param {number} input.hubRadius
* @param {number} input.centerDegrees
* @param {number} input.optionCount
* @param {string} input.text
* @returns {RadialLabelLayout}
*/
export const getRadialLabelLayout = ({center, radius, hubRadius, centerDegrees, optionCount, text}) => {
const track = getLabelTrack(radius, hubRadius);
const radians = (centerDegrees * Math.PI) / 180;
return {
x: center + Math.cos(radians) * track.midRadius,
y: center + Math.sin(radians) * track.midRadius,
width: track.width,
rotation: centerDegrees,
fontSize: getLabelFontSize(radius, optionCount, text, track.width),
};
};
/**
* @param {string} color
* @returns {'#111827' | '#ffffff'}
*/
export const getContrastingTextColor = (color) => {
const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/iu.exec(color);
if (!match) {
return '#111827';
}
const redHex = match[1] ?? '00';
const greenHex = match[2] ?? '00';
const blueHex = match[3] ?? '00';
const red = Number.parseInt(redHex, 16) / 255;
const green = Number.parseInt(greenHex, 16) / 255;
const blue = Number.parseInt(blueHex, 16) / 255;
const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
return luminance > 0.56 ? '#111827' : '#ffffff';
};
-9
View File
@@ -34,12 +34,3 @@ export const getFinalWheelRotationDegrees = (optionCount, winnerIndex, fullTurns
* @returns {number}
*/
export const cssDegreesToPieRadians = (cssDegrees) => ((cssDegrees - 90) * Math.PI) / 180;
/**
* @param {number} degrees
* @returns {number}
*/
export const getReadableTextRotationDegrees = (degrees) => {
const normalized = ((degrees % fullTurnDegrees) + fullTurnDegrees) % fullTurnDegrees;
return normalized > 90 && normalized < 270 ? degrees + 180 : degrees;
};
+53
View File
@@ -0,0 +1,53 @@
import {describe, expect, test} from 'vitest';
import {
estimateTextWidth,
getContrastingTextColor,
getLabelFontSize,
getLabelTrack,
getRadialLabelLayout,
} from '../src/remotion/wheel-label-layout.js';
describe('wheel label layout', () => {
test('places radial labels so the text track ends near the outer rim', () => {
const radius = 210;
const hubRadius = 46;
const layout = getRadialLabelLayout({
center: 256,
radius,
hubRadius,
centerDegrees: 0,
optionCount: 8,
text: 'alice',
});
const track = getLabelTrack(radius, hubRadius);
expect(layout.x + layout.width / 2).toBeCloseTo(256 + track.endRadius);
expect(layout.rotation).toBe(0);
});
test('keeps left-side text aligned with the slice instead of flipping it upright', () => {
const layout = getRadialLabelLayout({
center: 256,
radius: 210,
hubRadius: 46,
centerDegrees: 180,
optionCount: 8,
text: 'left side',
});
expect(layout.rotation).toBe(180);
});
test('shrinks long labels to fit the radial track', () => {
const shortSize = getLabelFontSize(210, 8, 'alice', 140);
const longSize = getLabelFontSize(210, 8, 'a much longer wheel entry label', 140);
expect(longSize).toBeLessThan(shortSize);
expect(estimateTextWidth('alice', shortSize)).toBeLessThan(140);
});
test('uses contrast text colors for light and dark slices', () => {
expect(getContrastingTextColor('#facc15')).toBe('#111827');
expect(getContrastingTextColor('#7f1d1d')).toBe('#ffffff');
});
});