mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-15 12:15:05 +00:00
feat(renderer): add bold wrapped wheel labels
This commit is contained in:
1 parent
55f2142d65
commit
d57009e63b
3 files changed
+245
-13
No files matched your search
@@ -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) => (
|
||||
<div key={`${line}-${lineIndex}`} style={{flex: '0 0 auto', whiteSpace: 'nowrap'}}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user