Files
frontend-svelte/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts

198 lines
6.4 KiB
TypeScript
Raw Normal View History

/**
* Interface representing a line of text with its measured width.
*/
export interface LineData {
text: string;
width: number;
}
/**
* Creates a helper for splitting text into lines and calculating character proximity.
* This is used by the ComparisonSlider (TestTen) to render morphing text.
*
* @param text - The text to split and measure
* @param fontA - The first font definition
* @param fontB - The second font definition
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
*/
export function createCharacterComparison(
text: () => string,
fontA: () => { name: string; id: string },
fontB: () => { name: string; id: string },
weight: () => number,
size: () => number,
) {
let lines = $state<LineData[]>([]);
let containerWidth = $state(0);
/**
* Measures text width using a canvas context.
* @param ctx - Canvas rendering context
* @param text - Text string to measure
* @param fontFamily - Font family name
* @param fontSize - Font size in pixels
* @param fontWeight - Font weight
*/
function measureText(
ctx: CanvasRenderingContext2D,
text: string,
fontFamily: string,
fontSize: number,
fontWeight: number,
): number {
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
return ctx.measureText(text).width;
}
/**
* Determines the appropriate font size based on window width.
* Matches the Tailwind breakpoints used in the component.
*/
function getFontSize() {
if (typeof window === 'undefined') {
return 64;
}
return window.innerWidth >= 1024
? 112
: window.innerWidth >= 768
? 96
: window.innerWidth >= 640
? 80
: 64;
}
/**
* Breaks the text into lines based on the container width and measure canvas.
* Populates the `lines` state.
*
* @param container - The container element to measure width from
* @param measureCanvas - The canvas element used for text measurement
*/
function breakIntoLines(
container: HTMLElement | undefined,
measureCanvas: HTMLCanvasElement | undefined,
) {
if (!container || !measureCanvas) return;
const rect = container.getBoundingClientRect();
containerWidth = rect.width;
// Padding considerations - matches the container padding
const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = rect.width - padding;
const ctx = measureCanvas.getContext('2d');
if (!ctx) return;
const controlledFontSize = size();
const fontSize = getFontSize();
const currentWeight = weight(); // Get current weight
const words = text().split(' ');
const newLines: LineData[] = [];
let currentLineWords: string[] = [];
function pushLine(words: string[]) {
if (words.length === 0) return;
const lineText = words.join(' ');
// Measure both fonts at the CURRENT weight
const widthA = measureText(
ctx!,
lineText,
fontA().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
);
const widthB = measureText(
ctx!,
lineText,
fontB().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
);
const maxWidth = Math.max(widthA, widthB);
newLines.push({ text: lineText, width: maxWidth });
}
for (const word of words) {
const testLine = currentLineWords.length > 0
? currentLineWords.join(' ') + ' ' + word
: word;
// Measure with both fonts and use the wider one to prevent layout shifts
const widthA = measureText(
ctx,
testLine,
fontA().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
);
const widthB = measureText(
ctx,
testLine,
fontB().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
);
const maxWidth = Math.max(widthA, widthB);
if (maxWidth > availableWidth && currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [word];
} else {
currentLineWords.push(word);
}
}
if (currentLineWords.length > 0) pushLine(currentLineWords);
lines = newLines;
}
/**
* precise calculation of character state based on global slider position.
*
* @param lineIndex - Index of the line
* @param charIndex - Index of the character in the line
* @param lineData - The line data object
* @param sliderPos - Current slider position (0-100)
* @returns Object containing proximity (0-1) and isPast (boolean)
*/
function getCharState(
lineIndex: number,
charIndex: number,
lineData: LineData,
sliderPos: number,
) {
if (!containerWidth) return { proximity: 0, isPast: false };
// Calculate the pixel position of the character relative to the CONTAINER
// 1. Find the left edge of the centered line
const lineStartOffset = (containerWidth - lineData.width) / 2;
// 2. Find the character's center relative to the line
const charRelativePercent = (charIndex + 0.5) / lineData.text.length;
const charPixelPos = lineStartOffset + (charRelativePercent * lineData.width);
// 3. Convert back to global percentage (0-100)
const charGlobalPercent = (charPixelPos / containerWidth) * 100;
const distance = Math.abs(sliderPos - charGlobalPercent);
// Proximity range: +/- 15% around the slider
const range = 15;
const proximity = Math.max(0, 1 - distance / range);
const isPast = sliderPos > charGlobalPercent;
return { proximity, isPast };
}
return {
get lines() {
return lines;
},
get containerWidth() {
return containerWidth;
},
breakIntoLines,
getCharState,
};
}