From da92e9b44348218d7971b18b38e2b131f5de2208 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 6 Jul 2026 22:50:11 +0700 Subject: [PATCH] fix: align wheel labels and compose env --- renderer/compose.yml | 19 +-- renderer/docs/deployment.md | 3 + renderer/src/remotion/WheelComposition.jsx | 63 +++++----- renderer/src/remotion/wheel-label-layout.js | 123 ++++++++++++++++++++ renderer/src/remotion/wheel-layout.js | 9 -- renderer/test/wheel-label-layout.test.js | 53 +++++++++ 6 files changed, 219 insertions(+), 51 deletions(-) create mode 100644 renderer/src/remotion/wheel-label-layout.js create mode 100644 renderer/test/wheel-label-layout.test.js diff --git a/renderer/compose.yml b/renderer/compose.yml index 3b63ef9..9cd9ca1 100644 --- a/renderer/compose.yml +++ b/renderer/compose.yml @@ -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 diff --git a/renderer/docs/deployment.md b/renderer/docs/deployment.md index a32f060..549277f 100644 --- a/renderer/docs/deployment.md +++ b/renderer/docs/deployment.md @@ -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. diff --git a/renderer/src/remotion/WheelComposition.jsx b/renderer/src/remotion/WheelComposition.jsx index 0e1825a..4f31710 100644 --- a/renderer/src/remotion/WheelComposition.jsx +++ b/renderer/src/remotion/WheelComposition.jsx @@ -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 ( { 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 ( @@ -127,28 +123,29 @@ export const WheelComposition = (props) => {
- + {option}
); @@ -157,13 +154,13 @@ export const WheelComposition = (props) => {
{ + 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'; +}; diff --git a/renderer/src/remotion/wheel-layout.js b/renderer/src/remotion/wheel-layout.js index 62e5bc9..5c2bd4d 100644 --- a/renderer/src/remotion/wheel-layout.js +++ b/renderer/src/remotion/wheel-layout.js @@ -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; -}; diff --git a/renderer/test/wheel-label-layout.test.js b/renderer/test/wheel-label-layout.test.js new file mode 100644 index 0000000..7d09a6b --- /dev/null +++ b/renderer/test/wheel-label-layout.test.js @@ -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'); + }); +});