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;
const proximity = Math.max(0, 1 - distance / range);
const isPast = sliderPos > charGlobalPercent;
return { proximity, isPast };
// 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);
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;