fix(ComparisonView): fix character morphing thresholds and add tracking support
This commit is contained in:
+85
-47
@@ -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 (0–100) 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;
|
||||
|
||||
Reference in New Issue
Block a user