461 lines
15 KiB
TypeScript
461 lines
15 KiB
TypeScript
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../../shared/data/alphabet';
|
||
|
||
/** 字母描红的布局模式(与产品文档六类练习一致) */
|
||
export type LetterTracingMode =
|
||
/** 1 看图识字:教学区 + 描红行(前三行大写、后三行小写) */
|
||
| 'letter-tracing-single'
|
||
/** 2 字母总览:Uppercase / Lowercase 分区,每区四行 7+7+6+6 */
|
||
| 'letter-tracing-upper-lower'
|
||
/** 3 大小写配对:左栏 A–M、右栏 N–Z,每行最多 4 组 Aa */
|
||
| 'letter-tracing-case-pairing'
|
||
/** 4 分栏对照:半组字母,左栏大写、右栏小写,每行 4 个 */
|
||
| 'letter-tracing-two-column'
|
||
/** 5 三字母精练:每页聚焦 3 个字母,每字母 4 行(2 行示范 + 2 行简练) */
|
||
| 'letter-tracing-three'
|
||
/** 6 单字母逐行:每行一个字母,13 字母半表(A–M 或 N–Z) */
|
||
| 'letter-tracing-half'
|
||
/** 7 每日打卡:整页四宫格,每宫一个字母,7 行 5 列闭合四线三格 */
|
||
| 'letter-tracing-daily-checkin';
|
||
|
||
/** 字母大小写模式(部分模式不使用) */
|
||
export type LetterCaseMode = 'upper' | 'lower' | 'both';
|
||
|
||
/** A–M / N–Z 半表(分栏对照) */
|
||
export type AlphabetHalf = 'A-M' | 'N-Z';
|
||
|
||
/** 字母描红生成器的配置项 */
|
||
export interface LetterTracingGeneratorConfig {
|
||
mode: LetterTracingMode;
|
||
letterCase: LetterCaseMode;
|
||
selectedLetter?: string;
|
||
repetitions: number;
|
||
fadePattern: 'gradient' | 'first-only';
|
||
/** 分栏对照 / 单字母逐行:练习 A–M 或 N–Z */
|
||
alphabetHalf?: AlphabetHalf;
|
||
/** 三字母精练:当前页聚焦的 3 个字母(如 ['A','B','C']) */
|
||
tripleLetters?: string[];
|
||
/** 每日打卡:当前页聚焦的 4 个字母(如 ['A','B','C','D']) */
|
||
dailyLetters?: string[];
|
||
}
|
||
|
||
/** 单个描红格子的数据 */
|
||
export interface TracingCell {
|
||
char: string;
|
||
opacity: number;
|
||
isGuide: boolean;
|
||
/** 与 char 组成大小写一对(如 A + a),用于大小写配对格内展示 */
|
||
pairChar?: string;
|
||
}
|
||
|
||
/** 一行描红数据,包含展示字符和若干格子 */
|
||
export interface TracingRow {
|
||
displayChar: string;
|
||
cells: TracingCell[];
|
||
}
|
||
|
||
/** 单字母逐行:一行中一个大小写配对的展示格子 */
|
||
export interface SingleLineCell {
|
||
upper: string;
|
||
lower: string;
|
||
/** 'solid' 正常渲染 | 'dashed' 使用虚线字体 */
|
||
style: 'solid' | 'dashed';
|
||
color: string;
|
||
alpha: number;
|
||
}
|
||
|
||
/** 单字母逐行:一行数据(6 个位置,前 4 个有字母,后 2 个空) */
|
||
export interface SingleLineRow {
|
||
letter: string;
|
||
cells: (SingleLineCell | null)[];
|
||
}
|
||
|
||
/** 三字母精练:一个字母区域(4 行),前两行完整示范,后两行简练(黑+浅红) */
|
||
export interface TripleSection {
|
||
letter: string;
|
||
rows: SingleLineRow[];
|
||
}
|
||
|
||
/** 每日打卡:单行(5 列),支持实线/虚线/留空三种样式 */
|
||
export interface DailyCheckinRow {
|
||
style: 'solid' | 'dashed' | 'blank';
|
||
cells: (SingleLineCell | null)[];
|
||
}
|
||
|
||
/** 每日打卡:单个字母对应一个宫格 */
|
||
export interface DailyCheckinSection {
|
||
letter: string;
|
||
pageNumber: number;
|
||
rows: DailyCheckinRow[];
|
||
}
|
||
|
||
/** Highlight It 格子数据(3×6 网格,混入目标字母) */
|
||
export interface HighlightGrid {
|
||
rows: string[][];
|
||
targetLetter: string;
|
||
}
|
||
|
||
/** 看图描红教学区 */
|
||
export interface TeachingBlock {
|
||
upper: string;
|
||
lower: string;
|
||
word: string;
|
||
sentence: string;
|
||
emoji: string;
|
||
/** 字母笔顺图路径(/englishPages/shared/assets/letter/X.png) */
|
||
letterImgPath: string;
|
||
/** 配图路径(/englishPages/shared/assets/letterImgs/Word.png) */
|
||
wordImgPath: string;
|
||
}
|
||
|
||
export type LetterTracingData =
|
||
| {
|
||
mode: 'letter-tracing-single';
|
||
teaching: TeachingBlock;
|
||
highlightGrid: HighlightGrid;
|
||
/** Trace It 区域使用的字母笔顺图路径(同 teaching.letterImgPath) */
|
||
traceImgPath: string;
|
||
/** 下部 4 行四线三格(无字符填充,纯空行) */
|
||
tracingLineCount: number;
|
||
}
|
||
| {
|
||
mode: 'letter-tracing-case-pairing';
|
||
leftRows: TracingRow[];
|
||
rightRows: TracingRow[];
|
||
}
|
||
| {
|
||
mode: 'letter-tracing-two-column';
|
||
leftRows: TracingRow[];
|
||
rightRows: TracingRow[];
|
||
}
|
||
| {
|
||
mode: 'letter-tracing-half';
|
||
rows: SingleLineRow[];
|
||
}
|
||
| {
|
||
mode: 'letter-tracing-three';
|
||
sections: TripleSection[];
|
||
}
|
||
| {
|
||
mode: 'letter-tracing-daily-checkin';
|
||
sections: DailyCheckinSection[];
|
||
}
|
||
| {
|
||
mode: 'letter-tracing-upper-lower';
|
||
upperRows: string[][];
|
||
lowerRows: string[][];
|
||
};
|
||
|
||
/** 将用户输入的字母标准化为大写单字符,默认 'A' */
|
||
export function normalizeLetter(c: string): string {
|
||
const u = c.trim().toUpperCase();
|
||
return u.length ? u[0] : 'A';
|
||
}
|
||
|
||
/** 构建 Highlight It 的 3×6 随机字母网格,保证混入 2~5 个目标字母 */
|
||
function buildHighlightGrid(
|
||
target: string,
|
||
gridRows: number,
|
||
gridCols: number,
|
||
): HighlightGrid {
|
||
const total = gridRows * gridCols;
|
||
const targetCount = 2 + Math.floor(Math.random() * 4); // 2~5
|
||
const others = LETTERS_UPPER.filter((l) => l !== target);
|
||
|
||
const chars: string[] = [];
|
||
for (let i = 0; i < targetCount; i++) chars.push(target);
|
||
for (let i = targetCount; i < total; i++) {
|
||
chars.push(others[Math.floor(Math.random() * others.length)]);
|
||
}
|
||
for (let i = chars.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||
}
|
||
|
||
const rows: string[][] = [];
|
||
for (let r = 0; r < gridRows; r++) {
|
||
rows.push(chars.slice(r * gridCols, (r + 1) * gridCols));
|
||
}
|
||
return { rows, targetLetter: target };
|
||
}
|
||
|
||
function buildCells(
|
||
char: string,
|
||
count: number,
|
||
fadePattern: 'gradient' | 'first-only',
|
||
pairChar?: string,
|
||
): TracingCell[] {
|
||
const n = Math.max(1, Math.min(12, count));
|
||
const cells: TracingCell[] = [];
|
||
for (let i = 0; i < n; i++) {
|
||
let opacity: number;
|
||
if (fadePattern === 'first-only') {
|
||
opacity = i === 0 ? 1 : 0;
|
||
} else if (n <= 1) {
|
||
opacity = 1;
|
||
} else {
|
||
opacity = Math.max(0.12, 1 - i / (n - 1));
|
||
}
|
||
cells.push({
|
||
char,
|
||
opacity,
|
||
isGuide: i === 0,
|
||
...(pairChar !== undefined ? { pairChar } : {}),
|
||
});
|
||
}
|
||
return cells;
|
||
}
|
||
|
||
function rowForChar(
|
||
ch: string,
|
||
repetitions: number,
|
||
fadePattern: 'gradient' | 'first-only',
|
||
pairChar?: string,
|
||
): TracingRow {
|
||
return {
|
||
displayChar: ch,
|
||
cells: buildCells(ch, repetitions, fadePattern, pairChar),
|
||
};
|
||
}
|
||
|
||
/** 总览区 26 字母拆成 7 + 7 + 6 + 6 四行(前两行 7 个、后两行 6 个,与四线三格排版一致) */
|
||
function overviewRowsFrom(letters: string[]): string[][] {
|
||
return [
|
||
letters.slice(0, 7),
|
||
letters.slice(7, 14),
|
||
letters.slice(14, 20),
|
||
letters.slice(20, 26),
|
||
];
|
||
}
|
||
|
||
const _COLOR_BLACK = '#1a1a1a';
|
||
const _COLOR_LIGHT_RED = '#FF8E8E';
|
||
|
||
/** 构建 6 slot 的单行字母数据:前 4 格有字母(颜色递进),后 2 格空白 */
|
||
function buildSingleLineCells(L: string): (SingleLineCell | null)[] {
|
||
const lower = L.toLowerCase();
|
||
return [
|
||
{ upper: L, lower, style: 'solid', color: _COLOR_BLACK, alpha: 1 },
|
||
{ upper: L, lower, style: 'solid', color: _COLOR_LIGHT_RED, alpha: 1 },
|
||
{
|
||
upper: L,
|
||
lower,
|
||
style: 'solid',
|
||
color: _COLOR_LIGHT_RED,
|
||
alpha: 0.5,
|
||
},
|
||
{
|
||
upper: L,
|
||
lower,
|
||
style: 'dashed',
|
||
color: _COLOR_LIGHT_RED,
|
||
alpha: 0.5,
|
||
},
|
||
null,
|
||
null,
|
||
];
|
||
}
|
||
|
||
/** 构建 6 slot 的简练行数据:前 2 格有字母(黑色+浅红色),后 4 格空白 */
|
||
function buildSimpleCells(L: string): (SingleLineCell | null)[] {
|
||
const lower = L.toLowerCase();
|
||
return [
|
||
{ upper: L, lower, style: 'solid', color: _COLOR_BLACK, alpha: 1 },
|
||
{ upper: L, lower, style: 'solid', color: _COLOR_LIGHT_RED, alpha: 1 },
|
||
null,
|
||
null,
|
||
null,
|
||
null,
|
||
];
|
||
}
|
||
|
||
function buildDailyFilledCells(
|
||
L: string,
|
||
style: 'solid' | 'dashed',
|
||
firstBlack: boolean = false,
|
||
): (SingleLineCell | null)[] {
|
||
const lower = L.toLowerCase();
|
||
return Array.from({ length: 5 }, (_, idx) => ({
|
||
upper: L,
|
||
lower,
|
||
style,
|
||
color: idx === 0 && firstBlack ? _COLOR_BLACK : _COLOR_LIGHT_RED,
|
||
alpha: 1,
|
||
}));
|
||
}
|
||
|
||
function buildDailyBlankCells(): (SingleLineCell | null)[] {
|
||
return Array.from({ length: 5 }, () => null);
|
||
}
|
||
|
||
function buildDailyCheckinRows(L: string): DailyCheckinRow[] {
|
||
return [
|
||
{ style: 'solid', cells: buildDailyFilledCells(L, 'solid', true) },
|
||
{ style: 'solid', cells: buildDailyFilledCells(L, 'solid') },
|
||
{ style: 'blank', cells: buildDailyBlankCells() },
|
||
{ style: 'dashed', cells: buildDailyFilledCells(L, 'dashed') },
|
||
{ style: 'blank', cells: buildDailyBlankCells() },
|
||
{ style: 'dashed', cells: buildDailyFilledCells(L, 'dashed') },
|
||
{ style: 'blank', cells: buildDailyBlankCells() },
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 根据配置生成字母描红数据
|
||
* - picture-tracing:教学区 + 6 行描红
|
||
* - case-pairing:左 A–M / 右 N–Z,每行最多 4 组大小写配对
|
||
* - column-compare:半表,左大写右小写,每行 4 个
|
||
* - overview:Uppercase / Lowercase Letters,每区四行 7+7+6+6
|
||
*/
|
||
export function generateLetterTracing(
|
||
config: LetterTracingGeneratorConfig,
|
||
): LetterTracingData {
|
||
const { mode, repetitions, fadePattern } = config;
|
||
|
||
if (mode === 'letter-tracing-single') {
|
||
const key = normalizeLetter(config.selectedLetter ?? 'A');
|
||
const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A;
|
||
const upper = key;
|
||
const lower = key.toLowerCase();
|
||
|
||
const hasLetterAsset = key === 'A' || key === 'B';
|
||
const letterFile = hasLetterAsset ? key : 'A';
|
||
const letterImgPath = `/englishPages/shared/assets/letter/${letterFile}.png`;
|
||
|
||
const wordImgPath = `/englishPages/shared/assets/letterImgs/Apple.png`;
|
||
|
||
const teaching: TeachingBlock = {
|
||
upper,
|
||
lower,
|
||
word: meta.word,
|
||
sentence: `${upper} is for ${meta.word}`,
|
||
emoji: meta.emoji,
|
||
letterImgPath,
|
||
wordImgPath,
|
||
};
|
||
|
||
const highlightGrid = buildHighlightGrid(upper, 3, 6);
|
||
|
||
return {
|
||
mode: 'letter-tracing-single',
|
||
teaching,
|
||
highlightGrid,
|
||
traceImgPath: letterImgPath,
|
||
tracingLineCount: 8,
|
||
};
|
||
}
|
||
|
||
if (mode === 'letter-tracing-case-pairing') {
|
||
const half =
|
||
config.alphabetHalf === 'N-Z'
|
||
? LETTERS_UPPER.slice(13)
|
||
: LETTERS_UPPER.slice(0, 13);
|
||
const leftRows = half.map((L) =>
|
||
rowForChar(L, repetitions, fadePattern),
|
||
);
|
||
const rightRows = half.map((L) =>
|
||
rowForChar(L.toLowerCase(), repetitions, fadePattern),
|
||
);
|
||
return { mode: 'letter-tracing-case-pairing', leftRows, rightRows };
|
||
}
|
||
|
||
if (mode === 'letter-tracing-two-column') {
|
||
const leftLetters = LETTERS_UPPER.slice(0, 13);
|
||
const rightLetters = LETTERS_UPPER.slice(13);
|
||
const toRows = (letters: string[]) =>
|
||
letters.map((L) =>
|
||
rowForChar(L, repetitions, fadePattern, L.toLowerCase()),
|
||
);
|
||
return {
|
||
mode: 'letter-tracing-two-column',
|
||
leftRows: toRows(leftLetters),
|
||
rightRows: toRows(rightLetters),
|
||
};
|
||
}
|
||
|
||
if (mode === 'letter-tracing-three') {
|
||
const letters = config.tripleLetters ?? ['A', 'B', 'C'];
|
||
const sections: TripleSection[] = letters.map((L) => {
|
||
const filledRow: SingleLineRow = {
|
||
letter: L,
|
||
cells: buildSingleLineCells(L),
|
||
};
|
||
const simpleRow: SingleLineRow = {
|
||
letter: L,
|
||
cells: buildSimpleCells(L),
|
||
};
|
||
return {
|
||
letter: L,
|
||
rows: [filledRow, filledRow, simpleRow, simpleRow],
|
||
};
|
||
});
|
||
return { mode: 'letter-tracing-three', sections };
|
||
}
|
||
|
||
if (mode === 'letter-tracing-half') {
|
||
const half =
|
||
config.alphabetHalf === 'N-Z'
|
||
? LETTERS_UPPER.slice(13)
|
||
: LETTERS_UPPER.slice(0, 13);
|
||
|
||
const rows: SingleLineRow[] = half.map((L) => ({
|
||
letter: L,
|
||
cells: buildSingleLineCells(L),
|
||
}));
|
||
|
||
return { mode: 'letter-tracing-half', rows };
|
||
}
|
||
|
||
if (mode === 'letter-tracing-daily-checkin') {
|
||
const letters = (config.dailyLetters ?? ['A', 'B', 'C', 'D']).map(
|
||
normalizeLetter,
|
||
);
|
||
const sections: DailyCheckinSection[] = letters.map((L) => ({
|
||
letter: L,
|
||
pageNumber: LETTERS_UPPER.indexOf(L) + 1,
|
||
rows: buildDailyCheckinRows(L),
|
||
}));
|
||
return { mode: 'letter-tracing-daily-checkin', sections };
|
||
}
|
||
|
||
// letter-tracing-upper-lower
|
||
return {
|
||
mode: 'letter-tracing-upper-lower',
|
||
upperRows: overviewRowsFrom([...LETTERS_UPPER]),
|
||
lowerRows: overviewRowsFrom(LETTERS_UPPER.map((L) => L.toLowerCase())),
|
||
};
|
||
}
|
||
|
||
/** 与页面内模式配置合并后的运行时配置 */
|
||
export function mergeLetterTracingConfig(
|
||
base: Partial<LetterTracingGeneratorConfig>,
|
||
overrides: Partial<LetterTracingGeneratorConfig>,
|
||
): LetterTracingGeneratorConfig {
|
||
return {
|
||
mode: (overrides.mode ??
|
||
base.mode ??
|
||
'letter-tracing-single') as LetterTracingMode,
|
||
letterCase: (overrides.letterCase ??
|
||
base.letterCase ??
|
||
'upper') as LetterCaseMode,
|
||
selectedLetter: overrides.selectedLetter ?? base.selectedLetter,
|
||
repetitions: Math.max(
|
||
1,
|
||
(overrides.repetitions ?? base.repetitions ?? 5) as number,
|
||
),
|
||
fadePattern: (overrides.fadePattern ??
|
||
base.fadePattern ??
|
||
'gradient') as 'gradient' | 'first-only',
|
||
alphabetHalf: (overrides.alphabetHalf ??
|
||
base.alphabetHalf ??
|
||
'A-M') as AlphabetHalf,
|
||
tripleLetters: overrides.tripleLetters ??
|
||
base.tripleLetters ?? ['A', 'B', 'C'],
|
||
dailyLetters: overrides.dailyLetters ?? base.dailyLetters ?? [
|
||
'A',
|
||
'B',
|
||
'C',
|
||
'D',
|
||
],
|
||
};
|
||
}
|