fix(ComparisonView): fix character morphing thresholds and add tracking support
This commit is contained in:
+83
-45
@@ -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;
|
||||
// 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;
|
||||
|
||||
+35
-39
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user