feat: 英语字母描红修改
This commit is contained in:
@@ -258,8 +258,8 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
|
|||||||
sheet(
|
sheet(
|
||||||
{
|
{
|
||||||
id: 'letter-tracing-single',
|
id: 'letter-tracing-single',
|
||||||
title: '字母学习页',
|
title: '看图描红',
|
||||||
desc: '单字母配图、阅读句与四线三格描红',
|
desc: '单字母配图、例句与六行四线三格描红',
|
||||||
category: 'english',
|
category: 'english',
|
||||||
page: 'letterTracing',
|
page: 'letterTracing',
|
||||||
subpackage: 'englishPages',
|
subpackage: 'englishPages',
|
||||||
@@ -280,7 +280,7 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
|
|||||||
instructionText: '看一看,读一读,再描一描',
|
instructionText: '看一看,读一读,再描一描',
|
||||||
},
|
},
|
||||||
generatorConfig: {
|
generatorConfig: {
|
||||||
mode: 'single-letter',
|
mode: 'picture-tracing',
|
||||||
letterCase: 'upper',
|
letterCase: 'upper',
|
||||||
selectedLetter: 'A',
|
selectedLetter: 'A',
|
||||||
repetitions: 5,
|
repetitions: 5,
|
||||||
@@ -291,8 +291,8 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
|
|||||||
sheet(
|
sheet(
|
||||||
{
|
{
|
||||||
id: 'letter-tracing-full',
|
id: 'letter-tracing-full',
|
||||||
title: '26 字母描红',
|
title: '分栏对照',
|
||||||
desc: '整页四线三格逐字母描红',
|
desc: '半组字母左大写右小写,每行四个',
|
||||||
category: 'english',
|
category: 'english',
|
||||||
page: 'letterTracing',
|
page: 'letterTracing',
|
||||||
subpackage: 'englishPages',
|
subpackage: 'englishPages',
|
||||||
@@ -308,11 +308,15 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
|
|||||||
template: 'tracing-writing',
|
template: 'tracing-writing',
|
||||||
generator: 'letter-tracing',
|
generator: 'letter-tracing',
|
||||||
sortOrder: 43,
|
sortOrder: 43,
|
||||||
layoutConfig: { ...L.trace, instructionText: '沿虚线描写字母' },
|
layoutConfig: {
|
||||||
|
...L.trace,
|
||||||
|
instructionText: '左栏大写、右栏小写,逐行对应描红',
|
||||||
|
},
|
||||||
generatorConfig: {
|
generatorConfig: {
|
||||||
mode: 'full-alphabet',
|
mode: 'column-compare',
|
||||||
letterCase: 'upper',
|
letterCase: 'both',
|
||||||
repetitions: 8,
|
alphabetHalf: 'A-M',
|
||||||
|
repetitions: 4,
|
||||||
fadePattern: 'gradient',
|
fadePattern: 'gradient',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -320,8 +324,8 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
|
|||||||
sheet(
|
sheet(
|
||||||
{
|
{
|
||||||
id: 'letter-tracing-upper-lower',
|
id: 'letter-tracing-upper-lower',
|
||||||
title: '大小写字母对照',
|
title: '字母总览',
|
||||||
desc: '上半部大写、下半部小写总览',
|
desc: 'Uppercase / Lowercase 分区,9+9+8 行',
|
||||||
category: 'english',
|
category: 'english',
|
||||||
page: 'letterTracing',
|
page: 'letterTracing',
|
||||||
subpackage: 'englishPages',
|
subpackage: 'englishPages',
|
||||||
@@ -340,10 +344,10 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
|
|||||||
layoutConfig: {
|
layoutConfig: {
|
||||||
...L.trace,
|
...L.trace,
|
||||||
columns: 13,
|
columns: 13,
|
||||||
instructionText: '认识大写和小写字母',
|
instructionText: '认读全部大写与小写字母',
|
||||||
},
|
},
|
||||||
generatorConfig: {
|
generatorConfig: {
|
||||||
mode: 'upper-lower-split',
|
mode: 'overview',
|
||||||
letterCase: 'both',
|
letterCase: 'both',
|
||||||
repetitions: 1,
|
repetitions: 1,
|
||||||
fadePattern: 'first-only',
|
fadePattern: 'first-only',
|
||||||
@@ -353,8 +357,8 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
|
|||||||
sheet(
|
sheet(
|
||||||
{
|
{
|
||||||
id: 'letter-tracing-two-column',
|
id: 'letter-tracing-two-column',
|
||||||
title: '字母描红(两列)',
|
title: '大小写配对',
|
||||||
desc: 'A–M / N–Z 两列练习',
|
desc: '左 A–M、右 N–Z,每行四组大小写',
|
||||||
category: 'english',
|
category: 'english',
|
||||||
page: 'letterTracing',
|
page: 'letterTracing',
|
||||||
subpackage: 'englishPages',
|
subpackage: 'englishPages',
|
||||||
@@ -376,8 +380,8 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
|
|||||||
instructionText: '沿虚线描写字母',
|
instructionText: '沿虚线描写字母',
|
||||||
},
|
},
|
||||||
generatorConfig: {
|
generatorConfig: {
|
||||||
mode: 'two-column',
|
mode: 'case-pairing',
|
||||||
letterCase: 'upper',
|
letterCase: 'both',
|
||||||
repetitions: 4,
|
repetitions: 4,
|
||||||
fadePattern: 'gradient',
|
fadePattern: 'gradient',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import drawHeader from './drawHeader';
|
|||||||
* - 打印配置管理
|
* - 打印配置管理
|
||||||
*/
|
*/
|
||||||
const LINE_COLOR = 'rgba(50, 46, 37, 0.4)';
|
const LINE_COLOR = 'rgba(50, 46, 37, 0.4)';
|
||||||
const LINE_WIDTH = 3;
|
const LINE_WIDTH = 2;
|
||||||
|
|
||||||
export class BaseDrawService {
|
export class BaseDrawService {
|
||||||
canvas: WechatMiniprogram.Canvas;
|
canvas: WechatMiniprogram.Canvas;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const META_FONT_PX = 12;
|
|||||||
const META_UNDERLINE_GAP = 3;
|
const META_UNDERLINE_GAP = 3;
|
||||||
const TITLE_COLOR = '#322E25';
|
const TITLE_COLOR = '#322E25';
|
||||||
const LINE_COLOR = 'rgba(50, 46, 37, 0.4)';
|
const LINE_COLOR = 'rgba(50, 46, 37, 0.4)';
|
||||||
const LINE_WIDTH = 3;
|
const LINE_WIDTH = 2;
|
||||||
const META_COLOR = '#7C766A';
|
const META_COLOR = '#7C766A';
|
||||||
const META_ROW_MARGIN_X = 130;
|
const META_ROW_MARGIN_X = 130;
|
||||||
|
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import {
|
|
||||||
ALPHABET_BY_LETTER,
|
|
||||||
LETTERS_UPPER,
|
|
||||||
} from '../data/alphabet';
|
|
||||||
|
|
||||||
export type LetterTracingMode =
|
|
||||||
| 'single-letter'
|
|
||||||
| 'full-alphabet'
|
|
||||||
| 'upper-lower-split'
|
|
||||||
| 'two-column';
|
|
||||||
|
|
||||||
export type LetterCaseMode = 'upper' | 'lower' | 'both';
|
|
||||||
|
|
||||||
export interface LetterTracingGeneratorConfig {
|
|
||||||
mode: LetterTracingMode;
|
|
||||||
letterCase: LetterCaseMode;
|
|
||||||
selectedLetter?: string;
|
|
||||||
repetitions: number;
|
|
||||||
fadePattern: 'gradient' | 'first-only';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TracingCell {
|
|
||||||
char: string;
|
|
||||||
opacity: number;
|
|
||||||
isGuide: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TracingRow {
|
|
||||||
displayChar: string;
|
|
||||||
cells: TracingCell[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeachingBlock {
|
|
||||||
upper: string;
|
|
||||||
lower: string;
|
|
||||||
word: string;
|
|
||||||
sentence: string;
|
|
||||||
emoji: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LetterTracingData =
|
|
||||||
| {
|
|
||||||
mode: 'single-letter';
|
|
||||||
teaching: TeachingBlock;
|
|
||||||
rows: TracingRow[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
mode: 'full-alphabet';
|
|
||||||
rows: TracingRow[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
mode: 'upper-lower-split';
|
|
||||||
upperGlyphs: string[];
|
|
||||||
lowerGlyphs: string[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
mode: 'two-column';
|
|
||||||
leftRows: TracingRow[];
|
|
||||||
rightRows: TracingRow[];
|
|
||||||
};
|
|
||||||
|
|
||||||
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',
|
|
||||||
): 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 });
|
|
||||||
}
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowForChar(
|
|
||||||
ch: string,
|
|
||||||
repetitions: number,
|
|
||||||
fadePattern: 'gradient' | 'first-only',
|
|
||||||
): TracingRow {
|
|
||||||
return {
|
|
||||||
displayChar: ch,
|
|
||||||
cells: buildCells(ch, repetitions, fadePattern),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateLetterTracing(
|
|
||||||
config: LetterTracingGeneratorConfig,
|
|
||||||
): LetterTracingData {
|
|
||||||
const { mode, letterCase, repetitions, fadePattern } = config;
|
|
||||||
|
|
||||||
if (mode === 'single-letter') {
|
|
||||||
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(lower, repetitions, fadePattern),
|
|
||||||
];
|
|
||||||
return { mode: 'single-letter', teaching, rows };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'full-alphabet') {
|
|
||||||
const rows = LETTERS_UPPER.map((L) => {
|
|
||||||
const ch = letterCase === 'lower' ? L.toLowerCase() : L;
|
|
||||||
return rowForChar(ch, repetitions, fadePattern);
|
|
||||||
});
|
|
||||||
return { mode: 'full-alphabet', rows };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'upper-lower-split') {
|
|
||||||
return {
|
|
||||||
mode: 'upper-lower-split',
|
|
||||||
upperGlyphs: [...LETTERS_UPPER],
|
|
||||||
lowerGlyphs: LETTERS_UPPER.map((L) => L.toLowerCase()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// two-column
|
|
||||||
const left = LETTERS_UPPER.slice(0, 13);
|
|
||||||
const right = LETTERS_UPPER.slice(13);
|
|
||||||
const mapChar = (L: string) =>
|
|
||||||
letterCase === 'lower' ? L.toLowerCase() : L;
|
|
||||||
const leftRows = left.map((L) =>
|
|
||||||
rowForChar(mapChar(L), repetitions, fadePattern),
|
|
||||||
);
|
|
||||||
const rightRows = right.map((L) =>
|
|
||||||
rowForChar(mapChar(L), repetitions, fadePattern),
|
|
||||||
);
|
|
||||||
return { mode: 'two-column', leftRows, rightRows };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 与 WorksheetDefinition.generatorConfig 合并后的运行时配置 */
|
|
||||||
export function mergeLetterTracingConfig(
|
|
||||||
base: Partial<LetterTracingGeneratorConfig>,
|
|
||||||
overrides: Partial<LetterTracingGeneratorConfig>,
|
|
||||||
): LetterTracingGeneratorConfig {
|
|
||||||
return {
|
|
||||||
mode: (overrides.mode ?? base.mode ?? 'single-letter') 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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -5,39 +5,43 @@ import { getWorksheetById } from '../../core/data/worksheets';
|
|||||||
import {
|
import {
|
||||||
generateLetterTracing,
|
generateLetterTracing,
|
||||||
mergeLetterTracingConfig,
|
mergeLetterTracingConfig,
|
||||||
|
normalizeLetter,
|
||||||
type LetterTracingGeneratorConfig,
|
type LetterTracingGeneratorConfig,
|
||||||
type LetterTracingMode,
|
type LetterTracingMode,
|
||||||
} from '../../core/generators/letter-tracing-generator';
|
} from '../shared/generators/letter-tracing-generator';
|
||||||
import { LETTERS_UPPER } from '../../core/data/alphabet';
|
import { LETTERS_UPPER } from '../../core/data/alphabet';
|
||||||
|
|
||||||
|
/** 各 worksheetId 对应的页面标题和描述 */
|
||||||
const PAGE_META: Record<string, { title: string; desc: string }> = {
|
const PAGE_META: Record<string, { title: string; desc: string }> = {
|
||||||
'letter-tracing-single': {
|
'letter-tracing-single': {
|
||||||
title: '字母学习页',
|
title: '看图描红',
|
||||||
desc: '单字母配图与描红',
|
desc: '配图、例句与六行描红',
|
||||||
},
|
},
|
||||||
'letter-tracing-full': {
|
'letter-tracing-full': {
|
||||||
title: '26 字母描红',
|
title: '分栏对照',
|
||||||
desc: '整页字母书写练习',
|
desc: '半组字母左大写右小写',
|
||||||
},
|
},
|
||||||
'letter-tracing-upper-lower': {
|
'letter-tracing-upper-lower': {
|
||||||
title: '大小写字母对照',
|
title: '字母总览',
|
||||||
desc: '一页认识 A-Z 与 a-z',
|
desc: 'Uppercase / Lowercase 总览',
|
||||||
},
|
},
|
||||||
'letter-tracing-two-column': {
|
'letter-tracing-two-column': {
|
||||||
title: '字母描红(两列)',
|
title: '大小写配对',
|
||||||
desc: 'A–M / N–Z 两列练习',
|
desc: '左 A–M、右 N–Z 配对描红',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 根据 worksheetId 查找页面元信息 */
|
||||||
function pageInfoLookup(id: string) {
|
function pageInfoLookup(id: string) {
|
||||||
return PAGE_META[id];
|
return PAGE_META[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 模式选择器的选项列表 */
|
||||||
const MODE_OPTIONS = [
|
const MODE_OPTIONS = [
|
||||||
{ id: 'letter-tracing-single', label: '单字母学习页' },
|
{ id: 'letter-tracing-single', label: '看图描红' },
|
||||||
{ id: 'letter-tracing-full', label: '整页描红' },
|
{ id: 'letter-tracing-full', label: '分栏对照' },
|
||||||
{ id: 'letter-tracing-upper-lower', label: '大小写对照' },
|
{ id: 'letter-tracing-upper-lower', label: '字母总览' },
|
||||||
{ id: 'letter-tracing-two-column', label: '两列描红' },
|
{ id: 'letter-tracing-two-column', label: '大小写配对' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type PageData = CanvasDataState & {
|
type PageData = CanvasDataState & {
|
||||||
@@ -66,7 +70,7 @@ createPage(
|
|||||||
hasContent: false,
|
hasContent: false,
|
||||||
showShareDialog: false,
|
showShareDialog: false,
|
||||||
worksheetId: 'letter-tracing-single',
|
worksheetId: 'letter-tracing-single',
|
||||||
traceMode: 'single-letter',
|
traceMode: 'picture-tracing',
|
||||||
selectedLetter: 'A',
|
selectedLetter: 'A',
|
||||||
letterCaseLower: false,
|
letterCaseLower: false,
|
||||||
showLetterPicker: true,
|
showLetterPicker: true,
|
||||||
@@ -76,6 +80,7 @@ createPage(
|
|||||||
modeOptions: [...MODE_OPTIONS],
|
modeOptions: [...MODE_OPTIONS],
|
||||||
} as unknown as PageData,
|
} as unknown as PageData,
|
||||||
|
|
||||||
|
/** 页面加载:从路由参数获取 worksheetId 并应用 */
|
||||||
onLoad(options: { id?: string }) {
|
onLoad(options: { id?: string }) {
|
||||||
const worksheetId =
|
const worksheetId =
|
||||||
options.id && PAGE_META[options.id]
|
options.id && PAGE_META[options.id]
|
||||||
@@ -84,6 +89,7 @@ createPage(
|
|||||||
this.applyWorksheet(worksheetId);
|
this.applyWorksheet(worksheetId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 用户点击模式选择器时切换 worksheet */
|
||||||
onSelectMode(e: WechatMiniprogram.TouchEvent) {
|
onSelectMode(e: WechatMiniprogram.TouchEvent) {
|
||||||
const id = e.currentTarget.dataset.id as string | undefined;
|
const id = e.currentTarget.dataset.id as string | undefined;
|
||||||
if (!id || id === this.data.worksheetId) return;
|
if (!id || id === this.data.worksheetId) return;
|
||||||
@@ -93,6 +99,7 @@ createPage(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 页面就绪:初始化 Canvas 和绘制服务,完成后触发首次绘制 */
|
||||||
onReady() {
|
onReady() {
|
||||||
this.initCanvas({
|
this.initCanvas({
|
||||||
createDrawService: (
|
createDrawService: (
|
||||||
@@ -102,7 +109,6 @@ createPage(
|
|||||||
) => new LetterTracingDraw(canvas, ctx, options),
|
) => new LetterTracingDraw(canvas, ctx, options),
|
||||||
drawServiceOptions: {
|
drawServiceOptions: {
|
||||||
title: this.data.pageTitle,
|
title: this.data.pageTitle,
|
||||||
subTitle: this.data.subTitle || '',
|
|
||||||
},
|
},
|
||||||
onCanvasReady: () => {
|
onCanvasReady: () => {
|
||||||
this.drawCanvas();
|
this.drawCanvas();
|
||||||
@@ -110,12 +116,13 @@ createPage(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 构建运行时生成器配置:合并 worksheet 预设 + 当前页面状态 */
|
||||||
buildRuntimeConfig(): LetterTracingGeneratorConfig {
|
buildRuntimeConfig(): LetterTracingGeneratorConfig {
|
||||||
const def = getWorksheetById(this.data.worksheetId);
|
const def = getWorksheetById(this.data.worksheetId);
|
||||||
const base = (def?.generatorConfig ??
|
const base = (def?.generatorConfig ??
|
||||||
{}) as Partial<LetterTracingGeneratorConfig>;
|
{}) as Partial<LetterTracingGeneratorConfig>;
|
||||||
const letterCase =
|
const letterCase =
|
||||||
this.data.traceMode === 'single-letter'
|
this.data.traceMode === 'picture-tracing'
|
||||||
? (base.letterCase ?? 'upper')
|
? (base.letterCase ?? 'upper')
|
||||||
: this.data.letterCaseLower
|
: this.data.letterCaseLower
|
||||||
? 'lower'
|
? 'lower'
|
||||||
@@ -123,14 +130,23 @@ createPage(
|
|||||||
return mergeLetterTracingConfig(base, {
|
return mergeLetterTracingConfig(base, {
|
||||||
selectedLetter: this.data.selectedLetter,
|
selectedLetter: this.data.selectedLetter,
|
||||||
letterCase,
|
letterCase,
|
||||||
|
...(this.data.traceMode === 'column-compare'
|
||||||
|
? {
|
||||||
|
alphabetHalf: this.data.letterCaseLower
|
||||||
|
? 'N-Z'
|
||||||
|
: 'A-M',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 执行 Canvas 绘制:生成数据 → 调用 drawService 渲染 */
|
||||||
async drawCanvas() {
|
async drawCanvas() {
|
||||||
if (!this.ctx || !this.drawService) return;
|
if (!this.ctx || !this.drawService) return;
|
||||||
try {
|
try {
|
||||||
const config = this.buildRuntimeConfig();
|
const config = this.buildRuntimeConfig();
|
||||||
const data = generateLetterTracing(config);
|
const data = generateLetterTracing(config);
|
||||||
|
console.log('drawCanvas data', data);
|
||||||
await (this.drawService as LetterTracingDraw).draw(data);
|
await (this.drawService as LetterTracingDraw).draw(data);
|
||||||
this.setData({ hasContent: true });
|
this.setData({ hasContent: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -139,6 +155,7 @@ createPage(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 用户在字母网格中选择了一个字母 */
|
||||||
onSelectLetter(e: WechatMiniprogram.TouchEvent) {
|
onSelectLetter(e: WechatMiniprogram.TouchEvent) {
|
||||||
const letter = e.currentTarget.dataset.letter as string;
|
const letter = e.currentTarget.dataset.letter as string;
|
||||||
if (!letter) return;
|
if (!letter) return;
|
||||||
@@ -146,16 +163,19 @@ createPage(
|
|||||||
this.drawCanvas();
|
this.drawCanvas();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 切换为大写模式并重绘 */
|
||||||
onSelectCaseUpper() {
|
onSelectCaseUpper() {
|
||||||
this.setData({ letterCaseLower: false });
|
this.setData({ letterCaseLower: false });
|
||||||
this.drawCanvas();
|
this.drawCanvas();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 切换为小写模式并重绘 */
|
||||||
onSelectCaseLower() {
|
onSelectCaseLower() {
|
||||||
this.setData({ letterCaseLower: true });
|
this.setData({ letterCaseLower: true });
|
||||||
this.drawCanvas();
|
this.drawCanvas();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 跳到下一个字母(循环 A→Z→A)并重绘 */
|
||||||
onNextLetter() {
|
onNextLetter() {
|
||||||
const list = LETTERS_UPPER;
|
const list = LETTERS_UPPER;
|
||||||
const i = list.indexOf(this.data.selectedLetter);
|
const i = list.indexOf(this.data.selectedLetter);
|
||||||
@@ -164,6 +184,13 @@ createPage(
|
|||||||
this.drawCanvas();
|
this.drawCanvas();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用指定的 worksheet 配置到页面状态
|
||||||
|
* 根据 worksheet 的 generatorConfig 决定 UI 控件的显隐(字母选择器、大小写切换等)
|
||||||
|
* @param worksheetId - 要应用的 worksheet ID
|
||||||
|
* @param options.preserveLetter - 是否保留当前选中的字母(模式切换时使用)
|
||||||
|
* @param options.redraw - 是否立即重绘 Canvas
|
||||||
|
*/
|
||||||
applyWorksheet(
|
applyWorksheet(
|
||||||
worksheetId: string,
|
worksheetId: string,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -172,22 +199,24 @@ createPage(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const def = getWorksheetById(worksheetId);
|
const def = getWorksheetById(worksheetId);
|
||||||
|
console.log('def', def);
|
||||||
const base = (def?.generatorConfig ??
|
const base = (def?.generatorConfig ??
|
||||||
{}) as Partial<LetterTracingGeneratorConfig>;
|
{}) as Partial<LetterTracingGeneratorConfig>;
|
||||||
const merged = mergeLetterTracingConfig(base, {});
|
const merged = mergeLetterTracingConfig(base, {});
|
||||||
|
|
||||||
const traceMode = merged.mode;
|
const traceMode = merged.mode;
|
||||||
const showLetterPicker = traceMode === 'single-letter';
|
const showLetterPicker = traceMode === 'picture-tracing';
|
||||||
const showCaseToggle =
|
const showCaseToggle = traceMode === 'column-compare';
|
||||||
traceMode === 'full-alphabet' || traceMode === 'two-column';
|
const showNextLetter = traceMode === 'picture-tracing';
|
||||||
const showNextLetter = traceMode === 'single-letter';
|
|
||||||
const selectedLetter = options?.preserveLetter
|
const selectedLetter = options?.preserveLetter
|
||||||
? this.data.selectedLetter
|
? (this.data.selectedLetter ?? 'A')
|
||||||
: normalizePick(merged.selectedLetter);
|
: normalizeLetter(merged.selectedLetter ?? '');
|
||||||
const letterCaseLower =
|
const letterCaseLower =
|
||||||
traceMode === 'single-letter'
|
traceMode === 'column-compare'
|
||||||
? merged.letterCase === 'lower'
|
? merged.alphabetHalf === 'N-Z'
|
||||||
: this.data.letterCaseLower;
|
: traceMode === 'picture-tracing'
|
||||||
|
? merged.letterCase === 'lower'
|
||||||
|
: false;
|
||||||
|
|
||||||
this.setData(
|
this.setData(
|
||||||
{
|
{
|
||||||
@@ -208,8 +237,6 @@ createPage(
|
|||||||
|
|
||||||
if (this.drawService) {
|
if (this.drawService) {
|
||||||
this.drawService.options.title = this.data.pageTitle;
|
this.drawService.options.title = this.data.pageTitle;
|
||||||
this.drawService.options.subTitle =
|
|
||||||
this.data.subTitle || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.redraw) {
|
if (options?.redraw) {
|
||||||
@@ -224,8 +251,3 @@ createPage(
|
|||||||
pageInfoLookup,
|
pageInfoLookup,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function normalizePick(c?: string): string {
|
|
||||||
if (!c || c.length === 0) return 'A';
|
|
||||||
return c.trim().toUpperCase().charAt(0);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:if="{{showCaseToggle}}" class="param-block">
|
<view wx:if="{{showCaseToggle}}" class="param-block">
|
||||||
<text class="lt-field-label">大小写</text>
|
<text class="lt-field-label">半组字母</text>
|
||||||
<view class="lt-chip-row">
|
<view class="lt-chip-row">
|
||||||
<view
|
<view
|
||||||
class="lt-chip {{!letterCaseLower ? 'lt-chip--active' : ''}}"
|
class="lt-chip {{!letterCaseLower ? 'lt-chip--active' : ''}}"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
hover-start-time="0"
|
hover-start-time="0"
|
||||||
hover-stay-time="70"
|
hover-stay-time="70"
|
||||||
bind:tap="onSelectCaseUpper">
|
bind:tap="onSelectCaseUpper">
|
||||||
大写 A–Z
|
A–M
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
class="lt-chip {{letterCaseLower ? 'lt-chip--active' : ''}}"
|
class="lt-chip {{letterCaseLower ? 'lt-chip--active' : ''}}"
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
hover-start-time="0"
|
hover-start-time="0"
|
||||||
hover-stay-time="70"
|
hover-stay-time="70"
|
||||||
bind:tap="onSelectCaseLower">
|
bind:tap="onSelectCaseLower">
|
||||||
小写 a–z
|
N–Z
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## 练习类型
|
||||||
|
|
||||||
|
1. 大小写配对 — 26 字母双栏,每组大写+小写并排
|
||||||
|
2. 分栏对照 — 半组字母,左栏大写右栏小写
|
||||||
|
3. 字母总览 — 上下分区展示全部大小写
|
||||||
|
4. 看图描红 — 单字母教学配图 + 描红练习
|
||||||
|
|
||||||
|
### 类型描述
|
||||||
|
|
||||||
|
1. 一页展示 26 个字母,每个包含大小写,分左右两栏,左栏A-M,右栏N-Z,每栏的一行展示 4 组字母,每一组都是一个大写和小写字母的组合。同一行是相同的字母
|
||||||
|
2. 一页展示 A-M 或N-Z的字母,分左右两栏,左栏每行展示 4 个大小字母,右栏展示 4 个对应的小写字母。同一行是相同的字母
|
||||||
|
3. 一页分上下两块区域展示,上面展示大写字母,下面展示小写字母,上面是Uppercase Letters 标题加三行,前面两行展示 9 个字母,最后一行展示剩余的 8 个字母。下面区域title是 lowercase Letters,行的展示逻辑和上面一致
|
||||||
|
4. 一页分为上下两部分,上部分有分左右两部分,左边展示超大字体的大小写字母,下面展示一段方面记忆的短句,例如:“L is for Lion”,上部分右边展示对应的Lion 的图片。下部分展示 6 行字母描红,前三行大写,后三行小写。
|
||||||
|
|
||||||
|
## 生成要求
|
||||||
|
|
||||||
|
字母行统一使用 四线三格,颜色为偏黑色,其中最上边和最下边的线条为实线,中间的两个线条为虚线。
|
||||||
|
|
||||||
|
## 字母书写顺序资料
|
||||||
|
|
||||||
|
https://zhuanlan.zhihu.com/p/107632161
|
||||||
|
|
||||||
|
https://www.ld4.net/26zimu.html
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -2,14 +2,15 @@ import { BaseDrawService } from '../../../core/draw/baseDraw';
|
|||||||
import type {
|
import type {
|
||||||
LetterTracingData,
|
LetterTracingData,
|
||||||
TracingRow,
|
TracingRow,
|
||||||
} from '../../../core/generators/letter-tracing-generator';
|
} from '../generators/letter-tracing-generator';
|
||||||
|
|
||||||
const M = 24;
|
const M = 24;
|
||||||
const BLUE = '#4A90E2';
|
/** 四线三格线色:偏黑,实线与虚线统一色相,仅以线型区分 */
|
||||||
const ORANGE = '#FF8C42';
|
const LINE_INK = '#2a2a2a';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 字母描红绘制(tracing-writing + letter-tracing 的客户端实现)
|
* 字母描红绘制
|
||||||
|
* 四线三格:顶线、上中线、下中线为格内三线间距;最上最下为实线,中间两虚线(产品文档)
|
||||||
*/
|
*/
|
||||||
export default class LetterTracingDraw extends BaseDrawService {
|
export default class LetterTracingDraw extends BaseDrawService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -25,53 +26,65 @@ export default class LetterTracingDraw extends BaseDrawService {
|
|||||||
await this.drawHeaderAndDivider();
|
await this.drawHeaderAndDivider();
|
||||||
|
|
||||||
switch (data.mode) {
|
switch (data.mode) {
|
||||||
case 'single-letter':
|
case 'picture-tracing':
|
||||||
this.drawSingleLetter(data);
|
this.drawPictureTracing(data);
|
||||||
|
this.drawPrintFooter();
|
||||||
break;
|
break;
|
||||||
case 'full-alphabet':
|
case 'case-pairing':
|
||||||
this.drawFullAlphabet(data.rows);
|
this.drawCasePairing(data.leftVisualRows, data.rightVisualRows);
|
||||||
|
this.drawPrintFooter();
|
||||||
break;
|
break;
|
||||||
case 'upper-lower-split':
|
case 'column-compare':
|
||||||
this.drawUpperLowerSplit(data.upperGlyphs, data.lowerGlyphs);
|
this.drawColumnCompare(data.leftRows, data.rightRows);
|
||||||
|
this.drawPrintFooter();
|
||||||
break;
|
break;
|
||||||
case 'two-column':
|
case 'overview':
|
||||||
this.drawTwoColumn(data.leftRows, data.rightRows);
|
this.drawOverview(data.upperRows, data.lowerRows);
|
||||||
|
this.drawPrintFooter();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 四线三格:顶线、中虚线、基线、底线 */
|
/**
|
||||||
|
* 四线三格:y 顶 → y+h 底,中间两条虚线
|
||||||
|
*/
|
||||||
private drawFourLines(x: number, y: number, w: number, h: number) {
|
private drawFourLines(x: number, y: number, w: number, h: number) {
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
const yTop = y;
|
const yTop = y;
|
||||||
const yMid = y + h * 0.28;
|
const yMid1 = y + h * 0.28;
|
||||||
const yBase = y + h * 0.62;
|
const yMid2 = y + h * 0.58;
|
||||||
const yBot = y + h;
|
const yBot = y + h;
|
||||||
|
|
||||||
ctx.strokeStyle = BLUE;
|
const strokeSolid = () => {
|
||||||
ctx.lineWidth = 1;
|
ctx.strokeStyle = LINE_INK;
|
||||||
ctx.setLineDash([]);
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
};
|
||||||
|
const strokeDash = () => {
|
||||||
|
ctx.strokeStyle = LINE_INK;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([5, 5]);
|
||||||
|
};
|
||||||
|
|
||||||
|
strokeSolid();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, yTop);
|
ctx.moveTo(x, yTop);
|
||||||
ctx.lineTo(x + w, yTop);
|
ctx.lineTo(x + w, yTop);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.strokeStyle = ORANGE;
|
strokeDash();
|
||||||
ctx.setLineDash([6, 6]);
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, yMid);
|
ctx.moveTo(x, yMid1);
|
||||||
ctx.lineTo(x + w, yMid);
|
ctx.lineTo(x + w, yMid1);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.strokeStyle = BLUE;
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, yMid2);
|
||||||
|
ctx.lineTo(x + w, yMid2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
strokeSolid();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
ctx.lineWidth = 1.2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, yBase);
|
|
||||||
ctx.lineTo(x + w, yBase);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, yBot);
|
ctx.moveTo(x, yBot);
|
||||||
ctx.lineTo(x + w, yBot);
|
ctx.lineTo(x + w, yBot);
|
||||||
@@ -86,18 +99,30 @@ export default class LetterTracingDraw extends BaseDrawService {
|
|||||||
h: number,
|
h: number,
|
||||||
opacity: number,
|
opacity: number,
|
||||||
bold: boolean,
|
bold: boolean,
|
||||||
|
pairChar?: string,
|
||||||
) {
|
) {
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
if (opacity <= 0) return;
|
if (opacity <= 0) return;
|
||||||
const fontSize = Math.min(h * 0.72, w * 0.85);
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = opacity;
|
ctx.globalAlpha = opacity;
|
||||||
ctx.fillStyle = bold ? '#111' : '#666';
|
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
|
||||||
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px sans-serif`;
|
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
const baselineY = y + h * 0.62;
|
const baselineY = y + h * 0.72;
|
||||||
ctx.fillText(ch, x + w / 2, baselineY);
|
|
||||||
|
if (pairChar) {
|
||||||
|
const fs = Math.min(h * 0.5, w * 0.22);
|
||||||
|
ctx.font = `${bold ? 'bold ' : ''}${fs}px sans-serif`;
|
||||||
|
const gap = fs * 0.2;
|
||||||
|
const totalW = fs * 2 + gap;
|
||||||
|
const x0 = x + w / 2 - totalW / 2;
|
||||||
|
ctx.fillText(ch, x0 + fs * 0.5, baselineY);
|
||||||
|
ctx.fillText(pairChar, x0 + fs * 1.5 + gap, baselineY);
|
||||||
|
} else {
|
||||||
|
const fontSize = Math.min(h * 0.72, w * 0.85);
|
||||||
|
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px sans-serif`;
|
||||||
|
ctx.fillText(ch, x + w / 2, baselineY);
|
||||||
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +135,7 @@ export default class LetterTracingDraw extends BaseDrawService {
|
|||||||
) {
|
) {
|
||||||
const gap = 4;
|
const gap = 4;
|
||||||
const n = row.cells.length;
|
const n = row.cells.length;
|
||||||
const cellW = (totalWidth - gap * (n - 1)) / n;
|
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth;
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const cx = startX + i * (cellW + gap);
|
const cx = startX + i * (cellW + gap);
|
||||||
@@ -124,140 +149,182 @@ export default class LetterTracingDraw extends BaseDrawService {
|
|||||||
rowH,
|
rowH,
|
||||||
cell.opacity,
|
cell.opacity,
|
||||||
cell.isGuide,
|
cell.isGuide,
|
||||||
|
cell.pairChar,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawSingleLetter(
|
/**
|
||||||
data: Extract<LetterTracingData, { mode: 'single-letter' }>,
|
* 看图描红:上左超大字母+例句,上右「配图」大 emoji;下 6 行四线三格
|
||||||
|
*/
|
||||||
|
private drawPictureTracing(
|
||||||
|
data: Extract<LetterTracingData, { mode: 'picture-tracing' }>,
|
||||||
) {
|
) {
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
const { teaching, rows } = data;
|
const { teaching, rows } = data;
|
||||||
const cw = this.canvasWidth;
|
const cw = this.canvasWidth;
|
||||||
let y = this.currentY + 8;
|
let y = this.currentY + 8;
|
||||||
const teachH = Math.min(220, (this.canvasHeight - y) * 0.42);
|
const teachH = Math.min(240, (this.canvasHeight - y) * 0.38);
|
||||||
|
const innerW = cw - M * 2;
|
||||||
|
const splitX = M + innerW * 0.52;
|
||||||
|
|
||||||
// 教学区背景
|
|
||||||
ctx.fillStyle = '#f8fafc';
|
ctx.fillStyle = '#f8fafc';
|
||||||
ctx.fillRect(M, y, cw - M * 2, teachH);
|
ctx.fillRect(M, y, innerW, teachH);
|
||||||
|
|
||||||
// 左侧 emoji
|
// 左侧:大小写 + 例句(短句在字母下方)
|
||||||
ctx.font = `${Math.min(88, teachH * 0.45)}px sans-serif`;
|
const leftPad = M + 20;
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(teaching.emoji, M + (cw - M * 2) * 0.22, y + teachH / 2);
|
|
||||||
|
|
||||||
// 右侧文字
|
|
||||||
const tx = M + (cw - M * 2) * 0.42;
|
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
ctx.fillStyle = '#111';
|
ctx.fillStyle = '#111';
|
||||||
ctx.font = 'bold 44px sans-serif';
|
const bigFs = Math.min(56, teachH * 0.26);
|
||||||
ctx.fillText(`${teaching.upper} ${teaching.lower}`, tx, y + 16);
|
ctx.font = `bold ${bigFs}px sans-serif`;
|
||||||
ctx.font = '22px sans-serif';
|
ctx.fillText(
|
||||||
|
`${teaching.upper} ${teaching.lower}`,
|
||||||
|
leftPad,
|
||||||
|
y + teachH * 0.12,
|
||||||
|
);
|
||||||
|
ctx.font = `${Math.min(22, teachH * 0.1)}px sans-serif`;
|
||||||
ctx.fillStyle = '#333';
|
ctx.fillStyle = '#333';
|
||||||
ctx.fillText(teaching.sentence, tx, y + 72);
|
ctx.fillText(teaching.sentence, leftPad, y + teachH * 0.48);
|
||||||
ctx.fillStyle = '#666';
|
|
||||||
ctx.font = '18px sans-serif';
|
// 右侧:配图区(以 emoji 作为插图占位)
|
||||||
ctx.fillText(teaching.word, tx, y + 104);
|
ctx.font = `${Math.min(120, teachH * 0.55)}px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(
|
||||||
|
teaching.emoji,
|
||||||
|
splitX + (cw - M - splitX) / 2,
|
||||||
|
y + teachH / 2,
|
||||||
|
);
|
||||||
|
|
||||||
y += teachH + 12;
|
y += teachH + 12;
|
||||||
this.drawDashedDivider(y, M, '#ccc');
|
this.drawDashedDivider(y, M, '#ccc');
|
||||||
y += 14;
|
y += 14;
|
||||||
|
|
||||||
const rest = this.canvasHeight - y - M;
|
const rest = this.canvasHeight - y - M;
|
||||||
const rowH = Math.max(36, (rest - 8) / rows.length);
|
const rowH = Math.max(34, (rest - 10) / rows.length);
|
||||||
const rowWidth = cw - M * 2;
|
const rowWidth = innerW;
|
||||||
for (let r = 0; r < rows.length; r++) {
|
for (let r = 0; r < rows.length; r++) {
|
||||||
this.drawTracingRow(
|
this.drawTracingRow(
|
||||||
rows[r],
|
rows[r],
|
||||||
M,
|
M,
|
||||||
y + r * (rowH + 10),
|
y + r * (rowH + 8),
|
||||||
rowH,
|
rowH,
|
||||||
rowWidth,
|
rowWidth,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawFullAlphabet(rows: TracingRow[]) {
|
/**
|
||||||
|
* 大小写配对:左右两栏,每行最多 4 组子行并排
|
||||||
|
*/
|
||||||
|
private drawCasePairing(
|
||||||
|
leftVisualRows: TracingRow[][],
|
||||||
|
rightVisualRows: TracingRow[][],
|
||||||
|
) {
|
||||||
const y0 = this.currentY + 6;
|
const y0 = this.currentY + 6;
|
||||||
const bottom = this.canvasHeight - M;
|
const bottom = this.canvasHeight - M;
|
||||||
const totalH = bottom - y0;
|
const gapCol = 18;
|
||||||
const rowH = Math.max(14, totalH / rows.length - 2);
|
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
|
||||||
const rowWidth = this.canvasWidth - M * 2;
|
const maxVisual = Math.max(
|
||||||
|
leftVisualRows.length,
|
||||||
|
rightVisualRows.length,
|
||||||
|
);
|
||||||
|
const rowSlotH = Math.max(40, (bottom - y0) / maxVisual - 6);
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
const drawSide = (
|
||||||
const y = y0 + i * (rowH + 2);
|
blocks: TracingRow[][],
|
||||||
this.drawTracingRow(rows[i], M, y, rowH, rowWidth);
|
startX: number,
|
||||||
}
|
width: number,
|
||||||
}
|
) => {
|
||||||
|
let y = y0;
|
||||||
private drawUpperLowerSplit(upper: string[], lower: string[]) {
|
for (const visualRow of blocks) {
|
||||||
const ctx = this.ctx;
|
const nSeg = visualRow.length;
|
||||||
const cw = this.canvasWidth;
|
const segGap = 6;
|
||||||
let y = this.currentY + 6;
|
const segW = (width - (nSeg - 1) * segGap) / nSeg;
|
||||||
const innerW = cw - M * 2;
|
for (let s = 0; s < nSeg; s++) {
|
||||||
|
this.drawTracingRow(
|
||||||
const drawBlockTitle = (t: string) => {
|
visualRow[s],
|
||||||
ctx.fillStyle = '#111';
|
startX + s * (segW + segGap),
|
||||||
ctx.font = 'bold 18px sans-serif';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
ctx.fillText(t, M, y);
|
|
||||||
y += 26;
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawGlyphGrid = (chars: string[], cols: number) => {
|
|
||||||
const rows = Math.ceil(chars.length / cols);
|
|
||||||
const cellW = (innerW - (cols - 1) * 4) / cols;
|
|
||||||
const cellH = 32;
|
|
||||||
|
|
||||||
for (let r = 0; r < rows; r++) {
|
|
||||||
for (let c = 0; c < cols; c++) {
|
|
||||||
const idx = r * cols + c;
|
|
||||||
if (idx >= chars.length) break;
|
|
||||||
const cx = M + c * (cellW + 4);
|
|
||||||
this.drawFourLines(cx, y, cellW, cellH);
|
|
||||||
this.drawCharInCell(
|
|
||||||
chars[idx],
|
|
||||||
cx,
|
|
||||||
y,
|
y,
|
||||||
cellW,
|
rowSlotH,
|
||||||
cellH,
|
segW,
|
||||||
0.85,
|
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
y += cellH + 6;
|
y += rowSlotH + 8;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
drawBlockTitle('大写字母 Uppercase');
|
drawSide(leftVisualRows, M, colW);
|
||||||
drawGlyphGrid(upper, 13);
|
drawSide(rightVisualRows, M + colW + gapCol, colW);
|
||||||
y += 8;
|
|
||||||
this.drawDashedDivider(y, M, '#bbb');
|
|
||||||
y += 14;
|
|
||||||
drawBlockTitle('小写字母 Lowercase');
|
|
||||||
drawGlyphGrid(lower, 13);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawTwoColumn(leftRows: TracingRow[], rightRows: TracingRow[]) {
|
/** 分栏对照:左大写、右小写,逐行对齐 */
|
||||||
|
private drawColumnCompare(leftRows: TracingRow[], rightRows: TracingRow[]) {
|
||||||
const y0 = this.currentY + 6;
|
const y0 = this.currentY + 6;
|
||||||
const bottom = this.canvasHeight - M;
|
const bottom = this.canvasHeight - M;
|
||||||
const totalH = bottom - y0;
|
const maxR = Math.max(leftRows.length, rightRows.length);
|
||||||
const maxRows = Math.max(leftRows.length, rightRows.length);
|
const rowH = Math.max(36, (bottom - y0) / maxR - 6);
|
||||||
const rowH = Math.max(16, totalH / maxRows - 3);
|
|
||||||
const gapCol = 16;
|
const gapCol = 16;
|
||||||
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
|
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
|
||||||
|
|
||||||
for (let i = 0; i < leftRows.length; i++) {
|
for (let i = 0; i < leftRows.length; i++) {
|
||||||
const y = y0 + i * (rowH + 3);
|
const y = y0 + i * (rowH + 6);
|
||||||
this.drawTracingRow(leftRows[i], M, y, rowH, colW);
|
this.drawTracingRow(leftRows[i], M, y, rowH, colW);
|
||||||
}
|
}
|
||||||
const xR = M + colW + gapCol;
|
const xR = M + colW + gapCol;
|
||||||
for (let i = 0; i < rightRows.length; i++) {
|
for (let i = 0; i < rightRows.length; i++) {
|
||||||
const y = y0 + i * (rowH + 3);
|
const y = y0 + i * (rowH + 6);
|
||||||
this.drawTracingRow(rightRows[i], xR, y, rowH, colW);
|
this.drawTracingRow(rightRows[i], xR, y, rowH, colW);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字母总览:Uppercase Letters / lowercase Letters,各行 9+9+8,四线三格
|
||||||
|
*/
|
||||||
|
private drawOverview(upperRows: string[][], lowerRows: string[][]) {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
let y = this.currentY + 6;
|
||||||
|
const innerW = this.canvasWidth - M * 2;
|
||||||
|
|
||||||
|
const title = (t: string) => {
|
||||||
|
ctx.fillStyle = '#111';
|
||||||
|
ctx.font = 'bold 17px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(t, M, y);
|
||||||
|
y += 24;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawBand = (rows: string[][]) => {
|
||||||
|
for (const line of rows) {
|
||||||
|
const n = line.length;
|
||||||
|
const gap = 4;
|
||||||
|
const cellW = (innerW - (n - 1) * gap) / n;
|
||||||
|
const cellH = 30;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const cx = M + i * (cellW + gap);
|
||||||
|
this.drawFourLines(cx, y, cellW, cellH);
|
||||||
|
this.drawCharInCell(
|
||||||
|
line[i],
|
||||||
|
cx,
|
||||||
|
y,
|
||||||
|
cellW,
|
||||||
|
cellH,
|
||||||
|
0.9,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
y += cellH + 8;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
title('Uppercase Letters');
|
||||||
|
drawBand(upperRows);
|
||||||
|
y += 6;
|
||||||
|
this.drawDashedDivider(y, M, '#bbb');
|
||||||
|
y += 12;
|
||||||
|
title('lowercase Letters');
|
||||||
|
drawBand(lowerRows);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user