From 141126530d2bd895adf53bd6692ea8266c979453 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 20 Apr 2026 10:52:28 +0300 Subject: [PATCH] fix(ComparisonView): fix character morphing thresholds and add tracking support --- .../CharacterComparisonEngine.svelte.ts | 132 +++++++++++------- .../CharacterComparisonEngine.test.ts | 74 +++++----- .../ui/Character/Character.svelte | 1 + .../ui/SliderArea/SliderArea.svelte | 22 ++- 4 files changed, 138 insertions(+), 91 deletions(-) diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts index 08d7490..a5ac2a2 100644 --- a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts @@ -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(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; diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts index d7f9fe7..09a4f5f 100644 --- a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts @@ -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'); + } }); }); diff --git a/src/widgets/ComparisonView/ui/Character/Character.svelte b/src/widgets/ComparisonView/ui/Character/Character.svelte index de3957b..cbd96d9 100644 --- a/src/widgets/ComparisonView/ui/Character/Character.svelte +++ b/src/widgets/ComparisonView/ui/Character/Character.svelte @@ -48,6 +48,7 @@ $effect(() => { 0 ? 'transform' : 'auto'} > {#each [0, 1] as s (s)} diff --git a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte index 9794dcf..89831b5 100644 --- a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte +++ b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte @@ -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)} {#snippet character({ char, index })} - {@const { proximity, isPast } = comparisonEngine.getCharState(lineIndex, index, sliderPos, container?.offsetWidth ?? 0)} - + {/snippet} {/each}