fix(ComparisonView): fix character morphing thresholds and add tracking support

This commit is contained in:
Ilia Mashkov
2026-04-20 10:52:28 +03:00
parent f9f96e2797
commit 141126530d
4 changed files with 138 additions and 91 deletions
@@ -95,11 +95,13 @@ export class CharacterComparisonEngine {
#lastText = '';
#lastFontA = '';
#lastFontB = '';
#lastSpacing = 0;
#lastSize = 0;
// Cached layout results
#lastWidth = -1;
#lastLineHeight = -1;
#lastResult: ComparisonResult | null = null;
#lastResult = $state<ComparisonResult | null>(null);
constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
@@ -116,6 +118,8 @@ export class CharacterComparisonEngine {
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
* @param width Available line width in pixels.
* @param lineHeight Line height in pixels (passed directly to pretext).
* @param spacing Letter spacing in em (from typography settings).
* @param size Current font size in pixels (used to convert spacing em to px).
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
*/
layout(
@@ -124,12 +128,21 @@ export class CharacterComparisonEngine {
fontB: string,
width: number,
lineHeight: number,
spacing: number = 0,
size: number = 16,
): ComparisonResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB;
const spacingPx = spacing * size;
const isFontChange = text !== this.#lastText
|| fontA !== this.#lastFontA
|| fontB !== this.#lastFontB
|| spacing !== this.#lastSpacing
|| size !== this.#lastSize;
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
if (!isFontChange && !isLayoutChange && this.#lastResult) {
@@ -140,11 +153,13 @@ export class CharacterComparisonEngine {
if (isFontChange) {
this.#preparedA = prepareWithSegments(text, fontA);
this.#preparedB = prepareWithSegments(text, fontB);
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB);
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx);
this.#lastText = text;
this.#lastFontA = fontA;
this.#lastFontB = fontB;
this.#lastSpacing = spacing;
this.#lastSize = size;
}
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
@@ -175,7 +190,6 @@ export class CharacterComparisonEngine {
continue;
}
// PERFORMANCE: Reuse segmenter results if possible, but for now just optimize the loop
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
const advA = intA.breakableFitAdvances[sIdx];
@@ -186,8 +200,12 @@ export class CharacterComparisonEngine {
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
const char = graphemes[gIdx];
const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
let wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
let wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
// Apply letter spacing (tracking) to the width of each character
wA += spacingPx;
wB += spacingPx;
chars.push({
char,
@@ -219,66 +237,86 @@ export class CharacterComparisonEngine {
}
/**
* Calculates character proximity and direction relative to a slider position.
* Calculates character states for an entire line in a single sequential pass.
*
* Uses the most recent `layout()` result — must be called after `layout()`.
* No DOM calls are made; all geometry is derived from cached layout data.
* Walks characters left-to-right, accumulating the running x position using
* each character's actual rendered width: `widthB` for already-morphed characters
* (isPast=true) and `widthA` for upcoming ones. This ensures thresholds stay
* aligned with the visual DOM layout even when the two fonts have different widths.
*
* @param lineIndex Zero-based index of the line within the last layout result.
* @param charIndex Zero-based index of the character within that line's `chars` array.
* @param line A single laid-out line from the last layout result.
* @param sliderPos Current slider position as a percentage (0100) of `containerWidth`.
* @param containerWidth Total container width in pixels, used to convert pixel offsets to %.
* @returns `proximity` in [0, 1] (1 = slider exactly over char center) and
* `isPast` (true when the slider has already passed the char center).
* @param containerWidth Total container width in pixels.
* @returns Per-character `proximity` and `isPast` in the same order as `line.chars`.
*/
getCharState(
lineIndex: number,
charIndex: number,
getLineCharStates(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
): { proximity: number; isPast: boolean } {
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
return { proximity: 0, isPast: false };
): Array<{ proximity: number; isPast: boolean }> {
if (!line) {
return [];
}
const line = this.#lastResult.lines[lineIndex];
const char = line.chars[charIndex];
if (!char) {
return { proximity: 0, isPast: false };
}
// Center the comparison on the unified width
// In the UI, lines are centered. So we need to calculate the global X.
const lineXOffset = (containerWidth - line.width) / 2;
const charCenterX = lineXOffset + char.xA + (char.widthA / 2);
const charGlobalPercent = (charCenterX / containerWidth) * 100;
const distance = Math.abs(sliderPos - charGlobalPercent);
const chars = line.chars;
const n = chars.length;
const sliderX = (sliderPos / 100) * containerWidth;
const range = 5;
// Prefix sums of widthA (left chars will be past → use widthA).
// Suffix sums of widthB (right chars will not be past → use widthB).
// This lets us compute, for each char i, what the total line width and
// char center would be at the exact moment the slider crosses that char:
// left side (0..i-1) already past → font A widths
// right side (i+1..n-1) not yet past → font B widths
const prefA = new Float64Array(n + 1);
const sufB = new Float64Array(n + 1);
for (let i = 0; i < n; i++) { prefA[i + 1] = prefA[i] + chars[i].widthA; }
for (let i = n - 1; i >= 0; i--) { sufB[i] = sufB[i + 1] + chars[i].widthB; }
// Per-char threshold: slider x at which this char should toggle isPast.
const thresholds = new Float64Array(n);
for (let i = 0; i < n; i++) {
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
const xOffset = (containerWidth - totalWidth) / 2;
thresholds[i] = xOffset + prefA[i] + chars[i].widthA / 2;
}
// Determine isPast for each char at the current slider position.
const isPastArr = new Uint8Array(n);
for (let i = 0; i < n; i++) { isPastArr[i] = sliderX > thresholds[i] ? 1 : 0; }
// Compute visual positions based on actual rendered widths (font A if past, B if not).
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
const xOffset = (containerWidth - totalRendered) / 2;
let currentX = xOffset;
return chars.map((char, i) => {
const isPast = isPastArr[i] === 1;
const charWidth = isPast ? char.widthA : char.widthB;
const visualCenter = currentX + charWidth / 2;
const charGlobalPercent = (visualCenter / containerWidth) * 100;
const distance = Math.abs(sliderPos - charGlobalPercent);
const proximity = Math.max(0, 1 - distance / range);
const isPast = sliderPos > charGlobalPercent;
currentX += charWidth;
return { proximity, isPast };
});
}
/**
* Internal helper to merge two prepared texts into a "worst-case" unified version
*/
#createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments {
#createUnifiedPrepared(
a: PreparedTextWithSegments,
b: PreparedTextWithSegments,
spacingPx: number = 0,
): PreparedTextWithSegments {
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
const intA = a as any;
const intB = b as any;
const unified = { ...intA };
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]));
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]) + spacingPx);
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
Math.max(w, intB.lineEndFitAdvances[i])
Math.max(w, intB.lineEndFitAdvances[i]) + spacingPx
);
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
Math.max(w, intB.lineEndPaintAdvances[i])
Math.max(w, intB.lineEndPaintAdvances[i]) + spacingPx
);
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
@@ -287,13 +325,13 @@ export class CharacterComparisonEngine {
return null;
}
if (!advA) {
return advB;
return advB.map((w: number) => w + spacingPx);
}
if (!advB) {
return advA;
return advA.map((w: number) => w + spacingPx);
}
return advA.map((w: number, j: number) => Math.max(w, advB[j]));
return advA.map((w: number, j: number) => Math.max(w, advB[j]) + spacingPx);
});
return unified;
@@ -109,56 +109,52 @@ describe('CharacterComparisonEngine', () => {
expect(r2).not.toBe(r1);
});
it('getCharState returns proximity 1 when slider is exactly over char center', () => {
// 'A' only: FontA width=10. Container=500px. Line centered.
// lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider).
// charCenterX = lineXOffset + xA + widthA/2.
// Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5
// charGlobalPercent = (252.5 / 500) * 100 = 50.5
// distance = |50.5 - 50.5| = 0 => proximity = 1
it('getLineCharStates returns proximity 1 when slider is exactly over char center', () => {
const containerWidth = 500;
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
// Recalculate expected percent manually:
const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case)
const lineXOffset = (containerWidth - lineWidth) / 2;
const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2;
const charPercent = (charCenterX / containerWidth) * 100;
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
// Single char: no neighbors → totalWidth = widthA, threshold = containerWidth/2.
// When isPast=false, visual center = (containerWidth - widthB)/2 + widthB/2 = containerWidth/2.
// So proximity=1 at exactly 50%.
const charPercent = 50;
const state = engine.getCharState(0, 0, charPercent, containerWidth);
expect(state.proximity).toBe(1);
expect(state.isPast).toBe(false);
const states = engine.getLineCharStates(result.lines[0], charPercent, containerWidth);
expect(states[0]?.proximity).toBe(1);
expect(states[0]?.isPast).toBe(false);
});
it('getCharState returns proximity 0 when slider is far from char', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
// Slider at 0%, char is near 50% — distance > 5 range => proximity = 0
const state = engine.getCharState(0, 0, 0, 500);
expect(state.proximity).toBe(0);
it('getLineCharStates returns proximity 0 when slider is far from char', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 0, 500);
expect(states[0]?.proximity).toBe(0);
});
it('getCharState isPast is true when slider has passed char center', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(0, 0, 100, 500);
expect(state.isPast).toBe(true);
it('getLineCharStates isPast is true when slider has passed char center', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 100, 500);
expect(states[0]?.isPast).toBe(true);
});
it('getCharState returns safe default for out-of-range lineIndex', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(99, 0, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
it('getLineCharStates returns empty array for out-of-range lineIndex', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
// Passing an undefined object because the index doesn't exist.
const states = engine.getLineCharStates(result.lines[99], 50, 500);
expect(states).toEqual([]);
});
it('getCharState returns safe default for out-of-range charIndex', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(0, 99, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
it('getLineCharStates returns empty array before layout() has been called', () => {
// Passing an undefined object because layout() hasn't been called.
const states = engine.getLineCharStates(undefined as any, 50, 500);
expect(states).toEqual([]);
});
it('getCharState returns safe default before layout() has been called', () => {
const state = engine.getCharState(0, 0, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
it('getLineCharStates returns safe defaults for all chars', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 50, 500);
expect(states.length).toBeGreaterThan(0);
for (const s of states) {
expect(s.proximity).toBeGreaterThanOrEqual(0);
expect(s.proximity).toBeLessThanOrEqual(1);
expect(typeof s.isPast).toBe('boolean');
}
});
});
@@ -48,6 +48,7 @@ $effect(() => {
<span
class="char-wrap"
style:font-size="{typography.renderedSize}px"
style:margin-right="{typography.spacing}em"
style:will-change={proximity > 0 ? 'transform' : 'auto'}
>
{#each [0, 1] as s (s)}
@@ -53,6 +53,7 @@ const isMobile = $derived(responsive?.isMobile ?? false);
let isDragging = $state(false);
let isTypographyMenuOpen = $state(false);
let containerWidth = $state(0);
// New high-performance layout engine
const comparisonEngine = new CharacterComparisonEngine();
@@ -127,6 +128,7 @@ $effect(() => {
const _weight = typography.weight;
const _size = typography.renderedSize;
const _height = typography.height;
const _spacing = typography.spacing;
if (container && fontA && fontB) {
// PRETEXT API strings: "weight sizepx family"
@@ -137,14 +139,17 @@ $effect(() => {
const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
const availableWidth = width - padding;
const lineHeight = _size * 1.2; // Approximate
const lineHeight = _size * _height;
containerWidth = width;
layoutResult = comparisonEngine.layout(
_text,
fontAStr,
fontBStr,
availableWidth,
lineHeight,
_spacing,
_size,
);
}
});
@@ -157,12 +162,15 @@ $effect(() => {
if (container && fontA && fontB) {
const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
containerWidth = width;
layoutResult = comparisonEngine.layout(
comparisonStore.text,
`${typography.weight} ${typography.renderedSize}px "${fontA.name}"`,
`${typography.weight} ${typography.renderedSize}px "${fontB.name}"`,
width - padding,
typography.renderedSize * 1.2,
typography.renderedSize * typography.height,
typography.spacing,
typography.renderedSize,
);
}
};
@@ -239,11 +247,15 @@ const scaleClass = $derived(
my-auto
"
>
{#each layoutResult.lines as line, lineIndex}
{#each layoutResult.lines as line}
{@const lineStates = comparisonEngine.getLineCharStates(line, sliderPos, containerWidth)}
<Line chars={line.chars}>
{#snippet character({ char, index })}
{@const { proximity, isPast } = comparisonEngine.getCharState(lineIndex, index, sliderPos, container?.offsetWidth ?? 0)}
<Character {char} {proximity} {isPast} />
<Character
{char}
proximity={lineStates[index]?.proximity ?? 0}
isPast={lineStates[index]?.isPast ?? false}
/>
{/snippet}
</Line>
{/each}