95 lines
2.8 KiB
TypeScript
95 lines
2.8 KiB
TypeScript
// Spritz ORP algorithm - position where the eye naturally fixates.
|
|
// Based on Optimal Viewing Position research (20-35% from left).
|
|
export function getORPIndex(wordLength: number): number {
|
|
if (wordLength <= 0) return 0;
|
|
if (wordLength === 1) return 0; // 1 char: 1st letter
|
|
if (wordLength <= 5) return 1; // 2-5 chars: 2nd letter
|
|
if (wordLength <= 9) return 2; // 6-9 chars: 3rd letter
|
|
if (wordLength <= 13) return 3; // 10-13 chars: 4th letter
|
|
return 4; // 14+ chars: 5th letter
|
|
}
|
|
|
|
function stripTrailingClosers(s: string): string {
|
|
// Handle punctuation followed by quotes/brackets like `word."` or `word.)`.
|
|
return s.replace(/[)"'\]]+$/g, "");
|
|
}
|
|
|
|
export function getTimingMultiplier(text: string, adaptiveTiming: boolean): number {
|
|
if (!adaptiveTiming) return 1;
|
|
|
|
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
|
if (tokens.length === 0) return 1;
|
|
|
|
// Length-based slowdown: use the longest token in the display chunk.
|
|
let maxLen = 0;
|
|
for (const t of tokens) maxLen = Math.max(maxLen, t.length);
|
|
let multiplier = 1 + Math.sqrt(maxLen) * 0.04;
|
|
|
|
// Punctuation-based pauses: based on the last token.
|
|
const last = stripTrailingClosers(tokens[tokens.length - 1]);
|
|
if (/[.!?]$/.test(last)) {
|
|
multiplier = Math.max(multiplier, 2.5);
|
|
} else if (/[,;:]$/.test(last)) {
|
|
multiplier = Math.max(multiplier, 1.8);
|
|
}
|
|
|
|
return multiplier;
|
|
}
|
|
|
|
export function getTimingMultiplierForWords(
|
|
words: string[],
|
|
startIndex: number,
|
|
endIndex: number,
|
|
adaptiveTiming: boolean,
|
|
): number {
|
|
if (!adaptiveTiming) return 1;
|
|
if (endIndex <= startIndex) return 1;
|
|
|
|
let maxLen = 0;
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const w = words[i];
|
|
if (w && w.length > maxLen) maxLen = w.length;
|
|
}
|
|
let multiplier = 1 + Math.sqrt(maxLen) * 0.04;
|
|
|
|
const lastToken = words[endIndex - 1] || "";
|
|
const last = stripTrailingClosers(lastToken);
|
|
if (/[.!?]$/.test(last)) {
|
|
multiplier = Math.max(multiplier, 2.5);
|
|
} else if (/[,;:]$/.test(last)) {
|
|
multiplier = Math.max(multiplier, 1.8);
|
|
}
|
|
return multiplier;
|
|
}
|
|
|
|
export function getWordDelay(
|
|
text: string,
|
|
baseDelayMs: number,
|
|
adaptiveTiming: boolean,
|
|
): number {
|
|
return baseDelayMs * getTimingMultiplier(text, adaptiveTiming);
|
|
}
|
|
|
|
export function getWordDelayForWords(
|
|
words: string[],
|
|
startIndex: number,
|
|
endIndex: number,
|
|
baseDelayMs: number,
|
|
adaptiveTiming: boolean,
|
|
): number {
|
|
return (
|
|
baseDelayMs *
|
|
getTimingMultiplierForWords(words, startIndex, endIndex, adaptiveTiming)
|
|
);
|
|
}
|
|
|
|
export function formatReadingTime(minutes: number): string {
|
|
minutes = Math.round(minutes);
|
|
if (minutes < 1) return "< 1 min";
|
|
if (minutes < 60) return `${Math.round(minutes)} min`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const mins = Math.round(minutes % 60);
|
|
if (mins === 0) return `${hours}h`;
|
|
return `${hours}h ${mins}m`;
|
|
}
|