fix(comparison): stabilize line rendering, cut per-tick re-renders
Extract findSplitIndex; computeLineRenderModel now takes the split index as a primitive. Line derives its model from `split`, so the $derived short-circuits on value equality and skips recomputation on spring ticks that don't move the split (previously every tick rebuilt the model and re-rendered the line). Lay the three regions out as inline boxes on a shared baseline. fontA and fontB now align on the typographic baseline despite differing metrics, and an always-present overflow:hidden strut pins the line-box baseline so the line no longer jumps when a bulk run mounts/unmounts or the last window char morphs to a font of different ascent.
This commit is contained in:
+55
-12
@@ -4,7 +4,11 @@ import {
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
|
||||
import { computeLineRenderModel } from './computeLineRenderModel';
|
||||
import {
|
||||
type LineRenderModel,
|
||||
computeLineRenderModel,
|
||||
findSplitIndex,
|
||||
} from './computeLineRenderModel';
|
||||
|
||||
/**
|
||||
* Build a ComparisonLine fixture with given per-char widths. xA/xB are
|
||||
@@ -34,10 +38,24 @@ function makeLine(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test helper: compute split + render model in one step, matching the
|
||||
* SliderArea call site shape.
|
||||
*/
|
||||
function compute(
|
||||
line: ComparisonLine,
|
||||
sliderPos: number,
|
||||
containerWidth: number,
|
||||
windowSize: number,
|
||||
): LineRenderModel {
|
||||
const split = findSplitIndex(line, sliderPos, containerWidth);
|
||||
return computeLineRenderModel(line, split, windowSize);
|
||||
}
|
||||
|
||||
describe('computeLineRenderModel', () => {
|
||||
it('returns empty model for an empty line', () => {
|
||||
const line = makeLine([]);
|
||||
const model = computeLineRenderModel(line, 50, 500, 5);
|
||||
const model = compute(line, 50, 500, 5);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars).toEqual([]);
|
||||
expect(model.rightText).toBe('');
|
||||
@@ -49,7 +67,7 @@ describe('computeLineRenderModel', () => {
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = computeLineRenderModel(line, 0, 500, 0);
|
||||
const model = compute(line, 0, 500, 0);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars).toEqual([]);
|
||||
expect(model.rightText).toBe('ABC');
|
||||
@@ -61,7 +79,7 @@ describe('computeLineRenderModel', () => {
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = computeLineRenderModel(line, 100, 500, 0);
|
||||
const model = compute(line, 100, 500, 0);
|
||||
expect(model.leftText).toBe('ABC');
|
||||
expect(model.windowChars).toEqual([]);
|
||||
expect(model.rightText).toBe('');
|
||||
@@ -80,7 +98,7 @@ describe('computeLineRenderModel', () => {
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
// Slider just past B's threshold (50%) but not C's (53.33%).
|
||||
const model = computeLineRenderModel(line, 51, 300, 0);
|
||||
const model = compute(line, 51, 300, 0);
|
||||
expect(model.leftText).toBe('AB');
|
||||
expect(model.rightText).toBe('C');
|
||||
});
|
||||
@@ -95,7 +113,7 @@ describe('computeLineRenderModel', () => {
|
||||
]);
|
||||
// Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%).
|
||||
// split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4
|
||||
const model = computeLineRenderModel(line, 48, 300, 3);
|
||||
const model = compute(line, 48, 300, 3);
|
||||
expect(model.leftText).toBe('A');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']);
|
||||
expect(model.rightText).toBe('E');
|
||||
@@ -109,7 +127,7 @@ describe('computeLineRenderModel', () => {
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = computeLineRenderModel(line, 0, 300, 3);
|
||||
const model = compute(line, 0, 300, 3);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']);
|
||||
expect(model.rightText).toBe('DE');
|
||||
@@ -123,7 +141,7 @@ describe('computeLineRenderModel', () => {
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = computeLineRenderModel(line, 100, 300, 3);
|
||||
const model = compute(line, 100, 300, 3);
|
||||
expect(model.leftText).toBe('AB');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']);
|
||||
expect(model.rightText).toBe('');
|
||||
@@ -134,7 +152,7 @@ describe('computeLineRenderModel', () => {
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = computeLineRenderModel(line, 50, 300, 5);
|
||||
const model = compute(line, 50, 300, 5);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']);
|
||||
expect(model.rightText).toBe('');
|
||||
@@ -148,8 +166,8 @@ describe('computeLineRenderModel', () => {
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const a = computeLineRenderModel(line, 40, 300, 3);
|
||||
const b = computeLineRenderModel(line, 60, 300, 3);
|
||||
const a = compute(line, 40, 300, 3);
|
||||
const b = compute(line, 60, 300, 3);
|
||||
// Chars that appear in both windows must carry identical keys.
|
||||
for (const charA of a.windowChars) {
|
||||
const charB = b.windowChars.find(w => w.char === charA.char);
|
||||
@@ -168,10 +186,35 @@ describe('computeLineRenderModel', () => {
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
// split = 2 → A,B past; C,D,E not
|
||||
const model = computeLineRenderModel(line, 48, 300, 5);
|
||||
const model = compute(line, 48, 300, 5);
|
||||
const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]);
|
||||
for (const wc of model.windowChars) {
|
||||
expect(wc.isPast).toBe(expected.get(wc.char));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSplitIndex', () => {
|
||||
it('returns 0 for empty line', () => {
|
||||
const line = makeLine([]);
|
||||
expect(findSplitIndex(line, 50, 500)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when slider is before all char thresholds', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
expect(findSplitIndex(line, 0, 300)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns chars.length when slider is past all char thresholds', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
expect(findSplitIndex(line, 100, 300)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
+52
-49
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
ComparisonChar,
|
||||
ComparisonLine,
|
||||
} from '../DualFontLayout/DualFontLayout';
|
||||
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
|
||||
|
||||
/**
|
||||
* Per-line render slice consumed by Line.svelte. The window is centered on the
|
||||
@@ -35,64 +32,30 @@ export interface LineRenderModel {
|
||||
rightText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slices a laid-out line into three regions around the slider's split index:
|
||||
* a fontA bulk run, an N-char crossfade window, and a fontB bulk run.
|
||||
*
|
||||
* Pure and allocation-bounded: two strings plus a `windowSize`-length array per call.
|
||||
* Safe to invoke per slider frame; `line.chars` is treated as read-only.
|
||||
*
|
||||
* @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model.
|
||||
* @param sliderPos Slider position in percent of `containerWidth`, range 0..100.
|
||||
* @param containerWidth Container width in pixels, used to translate `sliderPos` to a threshold x.
|
||||
* @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`.
|
||||
* At line edges the window is shifted (not shrunk) to keep its size.
|
||||
*/
|
||||
export function computeLineRenderModel(
|
||||
line: ComparisonLine,
|
||||
sliderPos: number,
|
||||
containerWidth: number,
|
||||
windowSize: number,
|
||||
): LineRenderModel {
|
||||
const chars = line.chars;
|
||||
const n = chars.length;
|
||||
if (n === 0) {
|
||||
return { leftText: '', windowChars: [], rightText: '' };
|
||||
}
|
||||
|
||||
const split = findSplitIndex(chars, sliderPos, containerWidth);
|
||||
|
||||
const halfWindow = Math.floor(Math.max(0, windowSize) / 2);
|
||||
let windowStart = clamp(split - halfWindow, 0, n);
|
||||
let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n);
|
||||
windowStart = Math.max(0, windowEnd - Math.max(0, windowSize));
|
||||
|
||||
const leftText = chars.slice(0, windowStart).map(c => c.char).join('');
|
||||
const rightText = chars.slice(windowEnd).map(c => c.char).join('');
|
||||
const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({
|
||||
key: `${windowStart + idx}-${c.char}`,
|
||||
char: c.char,
|
||||
isPast: (windowStart + idx) < split,
|
||||
}));
|
||||
|
||||
return { leftText, windowChars, rightText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of chars whose flip threshold the slider has crossed.
|
||||
*
|
||||
* Exposed as a separate step so consumers can pass the resulting primitive
|
||||
* `split` across component boundaries: when split is unchanged tick-to-tick,
|
||||
* downstream `$derived` reads of `computeLineRenderModel(line, split, ...)`
|
||||
* short-circuit on value equality and skip re-rendering.
|
||||
*
|
||||
* For each candidate split `i`, the line's hypothetical width at that moment is
|
||||
* `prefA[i] + widthA[i] + sufB[i+1]` (past chars in fontA, char `i` flipping, future
|
||||
* chars in fontB). The threshold is the x of char `i`'s center in the centered line.
|
||||
* Thresholds are monotonically non-decreasing in `i`, so the scan short-circuits on
|
||||
* the first miss.
|
||||
*/
|
||||
function findSplitIndex(
|
||||
chars: ComparisonChar[],
|
||||
export function findSplitIndex(
|
||||
line: ComparisonLine,
|
||||
sliderPos: number,
|
||||
containerWidth: number,
|
||||
): number {
|
||||
const chars = line.chars;
|
||||
const n = chars.length;
|
||||
if (n === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sliderX = (sliderPos / 100) * containerWidth;
|
||||
|
||||
const prefA = new Float64Array(n + 1);
|
||||
@@ -116,6 +79,46 @@ function findSplitIndex(
|
||||
return split;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slices a laid-out line into three regions around a precomputed split index:
|
||||
* a fontA bulk run, an N-char crossfade window, and a fontB bulk run.
|
||||
*
|
||||
* Pure and allocation-bounded: two strings plus a `windowSize`-length array per call.
|
||||
* Takes `split` as a primitive so callers can feed it into a `$derived` and
|
||||
* skip re-evaluation on ticks where the split index is unchanged.
|
||||
*
|
||||
* @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model.
|
||||
* @param split Count of chars the slider has passed, in `[0, line.chars.length]`.
|
||||
* @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`.
|
||||
* At line edges the window is shifted (not shrunk) to keep its size.
|
||||
*/
|
||||
export function computeLineRenderModel(
|
||||
line: ComparisonLine,
|
||||
split: number,
|
||||
windowSize: number,
|
||||
): LineRenderModel {
|
||||
const chars = line.chars;
|
||||
const n = chars.length;
|
||||
if (n === 0) {
|
||||
return { leftText: '', windowChars: [], rightText: '' };
|
||||
}
|
||||
|
||||
const halfWindow = Math.floor(Math.max(0, windowSize) / 2);
|
||||
let windowStart = clamp(split - halfWindow, 0, n);
|
||||
let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n);
|
||||
windowStart = Math.max(0, windowEnd - Math.max(0, windowSize));
|
||||
|
||||
const leftText = chars.slice(0, windowStart).map(c => c.char).join('');
|
||||
const rightText = chars.slice(windowEnd).map(c => c.char).join('');
|
||||
const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({
|
||||
key: `${windowStart + idx}-${c.char}`,
|
||||
char: c.char,
|
||||
isPast: (windowStart + idx) < split,
|
||||
}));
|
||||
|
||||
return { leftText, windowChars, rightText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps `value` into the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
|
||||
*/
|
||||
|
||||
@@ -5,5 +5,6 @@ export {
|
||||
} from './DualFontLayout/DualFontLayout';
|
||||
export {
|
||||
computeLineRenderModel,
|
||||
findSplitIndex,
|
||||
type LineRenderModel,
|
||||
} from './computeLineRenderModel/computeLineRenderModel';
|
||||
|
||||
Reference in New Issue
Block a user