From ccf51c645ee796f502ee71b68a1a4199066e87dd Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 30 May 2026 21:50:02 +0300 Subject: [PATCH] feat(font): compute split index and 3-region slice --- .../computeLineRenderModel.test.ts | 42 ++++++++++++ .../computeLineRenderModel.ts | 68 ++++++++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.test.ts b/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.test.ts index 8b95fd9..cdd79ad 100644 --- a/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.test.ts +++ b/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.test.ts @@ -42,4 +42,46 @@ describe('computeLineRenderModel', () => { expect(model.windowChars).toEqual([]); expect(model.rightText).toBe(''); }); + + it('places entire line in rightText when slider is at 0', () => { + const line = makeLine([ + { char: 'A', widthA: 10, widthB: 10 }, + { char: 'B', widthA: 10, widthB: 10 }, + { char: 'C', widthA: 10, widthB: 10 }, + ]); + const model = computeLineRenderModel(line, 0, 500, 0); + expect(model.leftText).toBe(''); + expect(model.windowChars).toEqual([]); + expect(model.rightText).toBe('ABC'); + }); + + it('places entire line in leftText when slider is at 100', () => { + const line = makeLine([ + { char: 'A', widthA: 10, widthB: 10 }, + { char: 'B', widthA: 10, widthB: 10 }, + { char: 'C', widthA: 10, widthB: 10 }, + ]); + const model = computeLineRenderModel(line, 100, 500, 0); + expect(model.leftText).toBe('ABC'); + expect(model.windowChars).toEqual([]); + expect(model.rightText).toBe(''); + }); + + it('splits line correctly with slider mid-line (window=0)', () => { + // Equal widths → line is centered. Container=300, total=30 → xOffset=135. + // Char thresholds (per the threshold formula in the design): + // threshold[i] = xOffset + prefA[i] + widthA[i]/2 + // i=0: 135 + 0 + 5 = 140 → 140/300 = 46.67% + // i=1: 135 + 10 + 5 = 150 → 150/300 = 50.00% + // i=2: 135 + 20 + 5 = 160 → 160/300 = 53.33% + const line = makeLine([ + { char: 'A', widthA: 10, widthB: 10 }, + { char: 'B', widthA: 10, widthB: 10 }, + { 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); + expect(model.leftText).toBe('AB'); + expect(model.rightText).toBe('C'); + }); }); diff --git a/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.ts b/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.ts index aa31215..5873690 100644 --- a/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.ts +++ b/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.ts @@ -41,8 +41,72 @@ export function computeLineRenderModel( containerWidth: number, windowSize: number, ): LineRenderModel { - if (line.chars.length === 0) { + const chars = line.chars; + const n = chars.length; + if (n === 0) { return { leftText: '', windowChars: [], rightText: '' }; } - throw new Error('not implemented'); + + 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 }; +} + +/** + * Walks chars left-to-right computing the per-char threshold using prefix sums + * of widthA (past chars, fontA) and suffix sums of widthB (future chars, fontB). + * Returns the count of chars whose threshold the slider has passed. + */ +function findSplitIndex( + chars: ComparisonChar[], + sliderPos: number, + containerWidth: number, +): number { + const n = chars.length; + const sliderX = (sliderPos / 100) * containerWidth; + + const prefA = new Float64Array(n + 1); + for (let i = 0; i < n; i++) { + prefA[i + 1] = prefA[i] + chars[i].widthA; + } + const sufB = new Float64Array(n + 1); + for (let i = n - 1; i >= 0; i--) { + sufB[i] = sufB[i + 1] + chars[i].widthB; + } + + let split = 0; + for (let i = 0; i < n; i++) { + const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1]; + const xOffset = (containerWidth - totalWidth) / 2; + const threshold = xOffset + prefA[i] + chars[i].widthA / 2; + if (sliderX > threshold) { + split = i + 1; + } else { + break; + } + } + return split; +} + +function clamp(value: number, lo: number, hi: number): number { + if (value < lo) { + return lo; + } + if (value > hi) { + return hi; + } + return value; }