From d57009e63b9fe51738ff86c25d19d0005c65a407 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 15 Jul 2026 12:21:14 +0700 Subject: [PATCH] feat(renderer): add bold wrapped wheel labels --- renderer/src/remotion/WheelComposition.jsx | 15 ++- renderer/src/remotion/wheel-label-layout.js | 114 ++++++++++++++++- renderer/test/wheel-label-layout.test.js | 129 +++++++++++++++++++- 3 files changed, 245 insertions(+), 13 deletions(-) diff --git a/renderer/src/remotion/WheelComposition.jsx b/renderer/src/remotion/WheelComposition.jsx index 5e7050a..f7ef090 100644 --- a/renderer/src/remotion/WheelComposition.jsx +++ b/renderer/src/remotion/WheelComposition.jsx @@ -127,25 +127,28 @@ export const WheelComposition = (props) => { color: getContrastingTextColor(color), display: 'flex', fontSize: label.fontSize, - fontWeight: 400, - height: label.fontSize * 1.35, + flexDirection: 'column', + fontWeight: 700, + height: label.height, justifyContent: 'center', left: label.x, letterSpacing: 0, lineHeight: 1, overflow: 'hidden', - padding: `0 ${Math.max(4, radius * 0.02)}px`, + padding: `0 ${label.horizontalPadding}px`, position: 'absolute', textAlign: 'center', - textOverflow: 'ellipsis', top: label.y, transform: `translate(-50%, -50%) rotate(${label.rotation}deg)`, transformOrigin: '50% 50%', - whiteSpace: 'nowrap', width: label.width, }} > - {option} + {label.lines.map((line, lineIndex) => ( +
+ {line} +
+ ))} ); diff --git a/renderer/src/remotion/wheel-label-layout.js b/renderer/src/remotion/wheel-label-layout.js index 710e1f4..fec4603 100644 --- a/renderer/src/remotion/wheel-label-layout.js +++ b/renderer/src/remotion/wheel-label-layout.js @@ -11,11 +11,16 @@ * @property {number} x * @property {number} y * @property {number} width + * @property {number} contentWidth + * @property {number} height + * @property {number} horizontalPadding * @property {number} rotation * @property {number} fontSize + * @property {string[]} lines */ const minLabelWidth = 24; +const minLabelFontSize = 8; /** * @param {string} text @@ -38,6 +43,90 @@ export const estimateTextWidth = (text, fontSize) => { return (widthUnits + 2) * fontSize; }; +/** + * @param {string[]} units + * @param {number} lineCount + * @param {(units: string[]) => string} joinUnits + * @returns {string[][]} + */ +const getLineCandidates = (units, lineCount, joinUnits) => { + /** @type {string[][]} */ + const candidates = []; + + /** + * @param {number} start + * @param {string[]} lines + */ + const visit = (start, lines) => { + const remainingLines = lineCount - lines.length; + if (remainingLines === 1) { + candidates.push([...lines, joinUnits(units.slice(start))]); + return; + } + + const lastEnd = units.length - remainingLines + 1; + for (let end = start + 1; end <= lastEnd; end += 1) { + visit(end, [...lines, joinUnits(units.slice(start, end))]); + } + }; + + visit(0, []); + return candidates; +}; + +/** + * @param {string[][]} candidates + * @param {number} fontSize + * @returns {string[]} + */ +const getMostBalancedLines = (candidates, fontSize) => { + const scored = candidates.map((lines, index) => { + const widths = lines.map((line) => estimateTextWidth(line, fontSize)); + const longest = Math.max(...widths); + const shortest = Math.min(...widths); + + return {index, lines, longest, spread: longest - shortest}; + }); + + scored.sort((left, right) => left.longest - right.longest || left.spread - right.spread || left.index - right.index); + return scored[0]?.lines ?? ['']; +}; + +/** + * Selects deterministic line breaks without depending on browser text wrapping. + * + * @param {string} text + * @param {number} fontSize + * @param {number} trackWidth + * @param {number} [maxLines] + * @returns {string[]} + */ +export const getLabelLines = (text, fontSize, trackWidth, maxLines = 3) => { + const normalized = text.trim().replace(/\s+/gu, ' '); + if (!normalized || estimateTextWidth(normalized, fontSize) <= trackWidth) { + return [normalized]; + } + + const words = normalized.split(' '); + const boundedMaxLines = Math.max(1, Math.min(Math.floor(maxLines), words.length > 1 ? words.length : Number.POSITIVE_INFINITY)); + const segmenter = new Intl.Segmenter('en', {granularity: 'grapheme'}); + const units = words.length > 1 ? words : Array.from(segmenter.segment(normalized), ({segment}) => segment); + /** @type {(parts: string[]) => string} */ + const joinUnits = words.length > 1 ? (parts) => parts.join(' ') : (parts) => parts.join(''); + const lineLimit = Math.max(1, Math.min(boundedMaxLines, units.length)); + let bestLines = [normalized]; + + for (let lineCount = 2; lineCount <= lineLimit; lineCount += 1) { + const candidates = getLineCandidates(units, lineCount, joinUnits); + bestLines = getMostBalancedLines(candidates, fontSize); + if (Math.max(...bestLines.map((line) => estimateTextWidth(line, fontSize))) <= trackWidth) { + return bestLines; + } + } + + return bestLines; +}; + /** * @param {number} radius * @param {number} hubRadius @@ -75,7 +164,7 @@ export const getLabelFontSize = (radius, optionCount, text = '', trackWidth = Nu return baseSize; } - return Math.max(8, Math.floor(baseSize * (trackWidth / estimatedWidth))); + return Math.max(minLabelFontSize, Math.floor(baseSize * (trackWidth / estimatedWidth))); }; /** @@ -91,13 +180,34 @@ export const getLabelFontSize = (radius, optionCount, text = '', trackWidth = Nu 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; + 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); + const singleLine = getLabelLines(text, baseFontSize, contentWidth, 1); + const singleLineFontSize = getLabelFontSize(radius, optionCount, singleLine[0], contentWidth); + const shouldWrap = maxLines > 1 && singleLineFontSize === minLabelFontSize; + const lines = shouldWrap ? getLabelLines(text, baseFontSize, contentWidth, maxLines) : singleLine; + const longestLine = lines.reduce( + (longest, line) => + estimateTextWidth(line, baseFontSize) > estimateTextWidth(longest, baseFontSize) ? line : longest, + '', + ); + const fontSize = shouldWrap + ? getLabelFontSize(radius, optionCount, longestLine, contentWidth) + : singleLineFontSize; return { x: center + Math.cos(radians) * track.midRadius, y: center + Math.sin(radians) * track.midRadius, width: track.width, + contentWidth, + height: Math.min(availableHeight, fontSize * lines.length), + horizontalPadding, rotation: centerDegrees, - fontSize: getLabelFontSize(radius, optionCount, text, track.width), + fontSize, + lines, }; }; diff --git a/renderer/test/wheel-label-layout.test.js b/renderer/test/wheel-label-layout.test.js index d13939c..6973525 100644 --- a/renderer/test/wheel-label-layout.test.js +++ b/renderer/test/wheel-label-layout.test.js @@ -3,6 +3,7 @@ import { estimateTextWidth, getContrastingTextColor, getLabelFontSize, + getLabelLines, getLabelTrack, getRadialLabelLayout, } from '../src/remotion/wheel-label-layout.js'; @@ -43,12 +44,130 @@ describe('wheel label layout', () => { 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); + 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(longSize).toBeLessThan(shortSize); - expect(estimateTextWidth('alice', shortSize)).toBeLessThan(140); + 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('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); }); test('uses contrast text colors for light and dark slices', () => {