261 lines
7.8 KiB
TypeScript
261 lines
7.8 KiB
TypeScript
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../../../core/data/alphabet';
|
||
|
||
/** 字母描红的布局模式(与产品文档四类练习一致) */
|
||
export type LetterTracingMode =
|
||
/** 4 看图描红:教学区 + 6 行描红(前三行大写、后三行小写) */
|
||
| 'picture-tracing'
|
||
/** 1 大小写配对:左栏 A–M、右栏 N–Z,每行最多 4 组 Aa */
|
||
| 'case-pairing'
|
||
/** 2 分栏对照:半组字母,左栏大写、右栏小写,每行 4 个 */
|
||
| 'column-compare'
|
||
/** 3 字母总览:Uppercase / Lowercase 分区,每区 9+9+8 */
|
||
| 'overview';
|
||
|
||
/** 字母大小写模式(部分模式不使用) */
|
||
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;
|
||
}
|
||
|
||
/** 单个描红格子的数据 */
|
||
export interface TracingCell {
|
||
char: string;
|
||
opacity: number;
|
||
isGuide: boolean;
|
||
/** 与 char 组成大小写一对(如 A + a),用于大小写配对格内展示 */
|
||
pairChar?: string;
|
||
}
|
||
|
||
/** 一行描红数据,包含展示字符和若干格子 */
|
||
export interface TracingRow {
|
||
displayChar: string;
|
||
cells: TracingCell[];
|
||
}
|
||
|
||
/** 看图描红教学区 */
|
||
export interface TeachingBlock {
|
||
upper: string;
|
||
lower: string;
|
||
word: string;
|
||
sentence: string;
|
||
emoji: string;
|
||
}
|
||
|
||
export type LetterTracingData =
|
||
| {
|
||
mode: 'picture-tracing';
|
||
teaching: TeachingBlock;
|
||
rows: TracingRow[];
|
||
}
|
||
| {
|
||
mode: 'case-pairing';
|
||
leftVisualRows: TracingRow[][];
|
||
rightVisualRows: TracingRow[][];
|
||
}
|
||
| {
|
||
mode: 'column-compare';
|
||
leftRows: TracingRow[];
|
||
rightRows: TracingRow[];
|
||
}
|
||
| {
|
||
mode: 'overview';
|
||
upperRows: string[][];
|
||
lowerRows: string[][];
|
||
};
|
||
|
||
/** 将用户输入的字母标准化为大写单字符,默认 'A' */
|
||
export function normalizeLetter(c: string): string {
|
||
const u = c.trim().toUpperCase();
|
||
return u.length ? u[0] : 'A';
|
||
}
|
||
|
||
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),
|
||
};
|
||
}
|
||
|
||
/** 一行内多个字母,各自占一段描红格(用于分栏对照等) */
|
||
function rowForManyChars(
|
||
chars: string[],
|
||
repetitions: number,
|
||
fadePattern: 'gradient' | 'first-only',
|
||
): TracingRow {
|
||
const cells: TracingCell[] = [];
|
||
for (const ch of chars) {
|
||
cells.push(...buildCells(ch, repetitions, fadePattern));
|
||
}
|
||
return {
|
||
displayChar: chars.join(''),
|
||
cells,
|
||
};
|
||
}
|
||
|
||
function chunk<T>(arr: T[], size: number): T[][] {
|
||
const out: T[][] = [];
|
||
for (let i = 0; i < arr.length; i += size) {
|
||
out.push(arr.slice(i, i + size));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 总览区 26 字母拆成 9 + 9 + 8 三行 */
|
||
function overviewRowsFrom(letters: string[]): string[][] {
|
||
return [letters.slice(0, 9), letters.slice(9, 18), letters.slice(18, 26)];
|
||
}
|
||
|
||
/**
|
||
* 根据配置生成字母描红数据
|
||
* - picture-tracing:教学区 + 6 行描红
|
||
* - case-pairing:左 A–M / 右 N–Z,每行最多 4 组大小写配对
|
||
* - column-compare:半表,左大写右小写,每行 4 个
|
||
* - overview:Uppercase / Lowercase Letters,9+9+8
|
||
*/
|
||
export function generateLetterTracing(
|
||
config: LetterTracingGeneratorConfig,
|
||
): LetterTracingData {
|
||
const { mode, repetitions, fadePattern } = config;
|
||
|
||
if (mode === 'picture-tracing') {
|
||
const key = normalizeLetter(config.selectedLetter ?? 'A');
|
||
const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A;
|
||
const upper = key;
|
||
const lower = key.toLowerCase();
|
||
const teaching: TeachingBlock = {
|
||
upper,
|
||
lower,
|
||
word: meta.word,
|
||
sentence: `${upper} is for ${meta.word}`,
|
||
emoji: meta.emoji,
|
||
};
|
||
const rows: TracingRow[] = [
|
||
rowForChar(upper, repetitions, fadePattern),
|
||
rowForChar(upper, repetitions, fadePattern),
|
||
rowForChar(upper, repetitions, fadePattern),
|
||
rowForChar(lower, repetitions, fadePattern),
|
||
rowForChar(lower, repetitions, fadePattern),
|
||
rowForChar(lower, repetitions, fadePattern),
|
||
];
|
||
return { mode: 'picture-tracing', teaching, rows };
|
||
}
|
||
|
||
if (mode === 'case-pairing') {
|
||
const leftLetters = LETTERS_UPPER.slice(0, 13);
|
||
const rightLetters = LETTERS_UPPER.slice(13);
|
||
const pack = (letters: string[]) =>
|
||
chunk(letters, 4).map((group) =>
|
||
group.map((L) =>
|
||
rowForChar(
|
||
L,
|
||
repetitions,
|
||
fadePattern,
|
||
L.toLowerCase(),
|
||
),
|
||
),
|
||
);
|
||
return {
|
||
mode: 'case-pairing',
|
||
leftVisualRows: pack(leftLetters),
|
||
rightVisualRows: pack(rightLetters),
|
||
};
|
||
}
|
||
|
||
if (mode === 'column-compare') {
|
||
const half =
|
||
config.alphabetHalf === 'N-Z'
|
||
? LETTERS_UPPER.slice(13)
|
||
: LETTERS_UPPER.slice(0, 13);
|
||
const rowsOf4 = chunk(half, 4);
|
||
const leftRows = rowsOf4.map((g) =>
|
||
rowForManyChars(g, repetitions, fadePattern),
|
||
);
|
||
const rightRows = rowsOf4.map((g) =>
|
||
rowForManyChars(
|
||
g.map((L) => L.toLowerCase()),
|
||
repetitions,
|
||
fadePattern,
|
||
),
|
||
);
|
||
return { mode: 'column-compare', leftRows, rightRows };
|
||
}
|
||
|
||
// overview
|
||
return {
|
||
mode: 'overview',
|
||
upperRows: overviewRowsFrom([...LETTERS_UPPER]),
|
||
lowerRows: overviewRowsFrom(
|
||
LETTERS_UPPER.map((L) => L.toLowerCase()),
|
||
),
|
||
};
|
||
}
|
||
|
||
/** 与 WorksheetDefinition.generatorConfig 合并后的运行时配置 */
|
||
export function mergeLetterTracingConfig(
|
||
base: Partial<LetterTracingGeneratorConfig>,
|
||
overrides: Partial<LetterTracingGeneratorConfig>,
|
||
): LetterTracingGeneratorConfig {
|
||
return {
|
||
mode: (overrides.mode ??
|
||
base.mode ??
|
||
'picture-tracing') 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,
|
||
};
|
||
}
|