mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 08:18:44 +00:00
fix: align wheel labels and compose env
This commit is contained in:
1 parent
3f5ed700bb
commit
da92e9b443
6 files changed
+219
-51
No files matched your search
+10
-9
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user