diff --git a/miniprogram/core/data/worksheets.ts b/miniprogram/core/data/worksheets.ts index d267823..ca014b4 100644 --- a/miniprogram/core/data/worksheets.ts +++ b/miniprogram/core/data/worksheets.ts @@ -258,8 +258,8 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [ sheet( { id: 'letter-tracing-single', - title: '字母学习页', - desc: '单字母配图、阅读句与四线三格描红', + title: '看图描红', + desc: '单字母配图、例句与六行四线三格描红', category: 'english', page: 'letterTracing', subpackage: 'englishPages', @@ -280,7 +280,7 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [ instructionText: '看一看,读一读,再描一描', }, generatorConfig: { - mode: 'single-letter', + mode: 'picture-tracing', letterCase: 'upper', selectedLetter: 'A', repetitions: 5, @@ -291,8 +291,8 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [ sheet( { id: 'letter-tracing-full', - title: '26 字母描红', - desc: '整页四线三格逐字母描红', + title: '分栏对照', + desc: '半组字母左大写右小写,每行四个', category: 'english', page: 'letterTracing', subpackage: 'englishPages', @@ -308,11 +308,15 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [ template: 'tracing-writing', generator: 'letter-tracing', sortOrder: 43, - layoutConfig: { ...L.trace, instructionText: '沿虚线描写字母' }, + layoutConfig: { + ...L.trace, + instructionText: '左栏大写、右栏小写,逐行对应描红', + }, generatorConfig: { - mode: 'full-alphabet', - letterCase: 'upper', - repetitions: 8, + mode: 'column-compare', + letterCase: 'both', + alphabetHalf: 'A-M', + repetitions: 4, fadePattern: 'gradient', }, }, @@ -320,8 +324,8 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [ sheet( { id: 'letter-tracing-upper-lower', - title: '大小写字母对照', - desc: '上半部大写、下半部小写总览', + title: '字母总览', + desc: 'Uppercase / Lowercase 分区,9+9+8 行', category: 'english', page: 'letterTracing', subpackage: 'englishPages', @@ -340,10 +344,10 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [ layoutConfig: { ...L.trace, columns: 13, - instructionText: '认识大写和小写字母', + instructionText: '认读全部大写与小写字母', }, generatorConfig: { - mode: 'upper-lower-split', + mode: 'overview', letterCase: 'both', repetitions: 1, fadePattern: 'first-only', @@ -353,8 +357,8 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [ sheet( { id: 'letter-tracing-two-column', - title: '字母描红(两列)', - desc: 'A–M / N–Z 两列练习', + title: '大小写配对', + desc: '左 A–M、右 N–Z,每行四组大小写', category: 'english', page: 'letterTracing', subpackage: 'englishPages', @@ -376,8 +380,8 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [ instructionText: '沿虚线描写字母', }, generatorConfig: { - mode: 'two-column', - letterCase: 'upper', + mode: 'case-pairing', + letterCase: 'both', repetitions: 4, fadePattern: 'gradient', }, diff --git a/miniprogram/core/draw/baseDraw.ts b/miniprogram/core/draw/baseDraw.ts index 7fbff1e..296725b 100644 --- a/miniprogram/core/draw/baseDraw.ts +++ b/miniprogram/core/draw/baseDraw.ts @@ -14,7 +14,7 @@ import drawHeader from './drawHeader'; * - 打印配置管理 */ const LINE_COLOR = 'rgba(50, 46, 37, 0.4)'; -const LINE_WIDTH = 3; +const LINE_WIDTH = 2; export class BaseDrawService { canvas: WechatMiniprogram.Canvas; diff --git a/miniprogram/core/draw/drawHeader.ts b/miniprogram/core/draw/drawHeader.ts index 0d6f01e..00a92b7 100644 --- a/miniprogram/core/draw/drawHeader.ts +++ b/miniprogram/core/draw/drawHeader.ts @@ -24,7 +24,7 @@ const META_FONT_PX = 12; const META_UNDERLINE_GAP = 3; const TITLE_COLOR = '#322E25'; const LINE_COLOR = 'rgba(50, 46, 37, 0.4)'; -const LINE_WIDTH = 3; +const LINE_WIDTH = 2; const META_COLOR = '#7C766A'; const META_ROW_MARGIN_X = 130; diff --git a/miniprogram/core/generators/letter-tracing-generator.ts b/miniprogram/core/generators/letter-tracing-generator.ts deleted file mode 100644 index 4e73039..0000000 --- a/miniprogram/core/generators/letter-tracing-generator.ts +++ /dev/null @@ -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, - overrides: Partial, -): 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', - }; -} diff --git a/miniprogram/englishPages/letterTracing/letterTracing.ts b/miniprogram/englishPages/letterTracing/letterTracing.ts index 2cafadc..dbf286d 100644 --- a/miniprogram/englishPages/letterTracing/letterTracing.ts +++ b/miniprogram/englishPages/letterTracing/letterTracing.ts @@ -5,39 +5,43 @@ import { getWorksheetById } from '../../core/data/worksheets'; import { generateLetterTracing, mergeLetterTracingConfig, + normalizeLetter, type LetterTracingGeneratorConfig, type LetterTracingMode, -} from '../../core/generators/letter-tracing-generator'; +} from '../shared/generators/letter-tracing-generator'; import { LETTERS_UPPER } from '../../core/data/alphabet'; +/** 各 worksheetId 对应的页面标题和描述 */ const PAGE_META: Record = { 'letter-tracing-single': { - title: '字母学习页', - desc: '单字母配图与描红', + title: '看图描红', + desc: '配图、例句与六行描红', }, 'letter-tracing-full': { - title: '26 字母描红', - desc: '整页字母书写练习', + title: '分栏对照', + desc: '半组字母左大写右小写', }, 'letter-tracing-upper-lower': { - title: '大小写字母对照', - desc: '一页认识 A-Z 与 a-z', + title: '字母总览', + desc: 'Uppercase / Lowercase 总览', }, 'letter-tracing-two-column': { - title: '字母描红(两列)', - desc: 'A–M / N–Z 两列练习', + title: '大小写配对', + desc: '左 A–M、右 N–Z 配对描红', }, }; +/** 根据 worksheetId 查找页面元信息 */ function pageInfoLookup(id: string) { return PAGE_META[id]; } +/** 模式选择器的选项列表 */ const MODE_OPTIONS = [ - { id: 'letter-tracing-single', label: '单字母学习页' }, - { id: 'letter-tracing-full', label: '整页描红' }, - { id: 'letter-tracing-upper-lower', label: '大小写对照' }, - { id: 'letter-tracing-two-column', label: '两列描红' }, + { id: 'letter-tracing-single', label: '看图描红' }, + { id: 'letter-tracing-full', label: '分栏对照' }, + { id: 'letter-tracing-upper-lower', label: '字母总览' }, + { id: 'letter-tracing-two-column', label: '大小写配对' }, ] as const; type PageData = CanvasDataState & { @@ -66,7 +70,7 @@ createPage( hasContent: false, showShareDialog: false, worksheetId: 'letter-tracing-single', - traceMode: 'single-letter', + traceMode: 'picture-tracing', selectedLetter: 'A', letterCaseLower: false, showLetterPicker: true, @@ -76,6 +80,7 @@ createPage( modeOptions: [...MODE_OPTIONS], } as unknown as PageData, + /** 页面加载:从路由参数获取 worksheetId 并应用 */ onLoad(options: { id?: string }) { const worksheetId = options.id && PAGE_META[options.id] @@ -84,6 +89,7 @@ createPage( this.applyWorksheet(worksheetId); }, + /** 用户点击模式选择器时切换 worksheet */ onSelectMode(e: WechatMiniprogram.TouchEvent) { const id = e.currentTarget.dataset.id as string | undefined; if (!id || id === this.data.worksheetId) return; @@ -93,6 +99,7 @@ createPage( }); }, + /** 页面就绪:初始化 Canvas 和绘制服务,完成后触发首次绘制 */ onReady() { this.initCanvas({ createDrawService: ( @@ -102,7 +109,6 @@ createPage( ) => new LetterTracingDraw(canvas, ctx, options), drawServiceOptions: { title: this.data.pageTitle, - subTitle: this.data.subTitle || '', }, onCanvasReady: () => { this.drawCanvas(); @@ -110,12 +116,13 @@ createPage( }); }, + /** 构建运行时生成器配置:合并 worksheet 预设 + 当前页面状态 */ buildRuntimeConfig(): LetterTracingGeneratorConfig { const def = getWorksheetById(this.data.worksheetId); const base = (def?.generatorConfig ?? {}) as Partial; const letterCase = - this.data.traceMode === 'single-letter' + this.data.traceMode === 'picture-tracing' ? (base.letterCase ?? 'upper') : this.data.letterCaseLower ? 'lower' @@ -123,14 +130,23 @@ createPage( return mergeLetterTracingConfig(base, { selectedLetter: this.data.selectedLetter, letterCase, + ...(this.data.traceMode === 'column-compare' + ? { + alphabetHalf: this.data.letterCaseLower + ? 'N-Z' + : 'A-M', + } + : {}), }); }, + /** 执行 Canvas 绘制:生成数据 → 调用 drawService 渲染 */ async drawCanvas() { if (!this.ctx || !this.drawService) return; try { const config = this.buildRuntimeConfig(); const data = generateLetterTracing(config); + console.log('drawCanvas data', data); await (this.drawService as LetterTracingDraw).draw(data); this.setData({ hasContent: true }); } catch (e) { @@ -139,6 +155,7 @@ createPage( } }, + /** 用户在字母网格中选择了一个字母 */ onSelectLetter(e: WechatMiniprogram.TouchEvent) { const letter = e.currentTarget.dataset.letter as string; if (!letter) return; @@ -146,16 +163,19 @@ createPage( this.drawCanvas(); }, + /** 切换为大写模式并重绘 */ onSelectCaseUpper() { this.setData({ letterCaseLower: false }); this.drawCanvas(); }, + /** 切换为小写模式并重绘 */ onSelectCaseLower() { this.setData({ letterCaseLower: true }); this.drawCanvas(); }, + /** 跳到下一个字母(循环 A→Z→A)并重绘 */ onNextLetter() { const list = LETTERS_UPPER; const i = list.indexOf(this.data.selectedLetter); @@ -164,6 +184,13 @@ createPage( this.drawCanvas(); }, + /** + * 应用指定的 worksheet 配置到页面状态 + * 根据 worksheet 的 generatorConfig 决定 UI 控件的显隐(字母选择器、大小写切换等) + * @param worksheetId - 要应用的 worksheet ID + * @param options.preserveLetter - 是否保留当前选中的字母(模式切换时使用) + * @param options.redraw - 是否立即重绘 Canvas + */ applyWorksheet( worksheetId: string, options?: { @@ -172,22 +199,24 @@ createPage( }, ) { const def = getWorksheetById(worksheetId); + console.log('def', def); const base = (def?.generatorConfig ?? {}) as Partial; const merged = mergeLetterTracingConfig(base, {}); const traceMode = merged.mode; - const showLetterPicker = traceMode === 'single-letter'; - const showCaseToggle = - traceMode === 'full-alphabet' || traceMode === 'two-column'; - const showNextLetter = traceMode === 'single-letter'; + const showLetterPicker = traceMode === 'picture-tracing'; + const showCaseToggle = traceMode === 'column-compare'; + const showNextLetter = traceMode === 'picture-tracing'; const selectedLetter = options?.preserveLetter - ? this.data.selectedLetter - : normalizePick(merged.selectedLetter); + ? (this.data.selectedLetter ?? 'A') + : normalizeLetter(merged.selectedLetter ?? ''); const letterCaseLower = - traceMode === 'single-letter' - ? merged.letterCase === 'lower' - : this.data.letterCaseLower; + traceMode === 'column-compare' + ? merged.alphabetHalf === 'N-Z' + : traceMode === 'picture-tracing' + ? merged.letterCase === 'lower' + : false; this.setData( { @@ -208,8 +237,6 @@ createPage( if (this.drawService) { this.drawService.options.title = this.data.pageTitle; - this.drawService.options.subTitle = - this.data.subTitle || ''; } if (options?.redraw) { @@ -224,8 +251,3 @@ createPage( pageInfoLookup, }, ); - -function normalizePick(c?: string): string { - if (!c || c.length === 0) return 'A'; - return c.trim().toUpperCase().charAt(0); -} diff --git a/miniprogram/englishPages/letterTracing/letterTracing.wxml b/miniprogram/englishPages/letterTracing/letterTracing.wxml index e2454ab..6c98f38 100644 --- a/miniprogram/englishPages/letterTracing/letterTracing.wxml +++ b/miniprogram/englishPages/letterTracing/letterTracing.wxml @@ -53,7 +53,7 @@ - 大小写 + 半组字母 - 大写 A–Z + A–M - 小写 a–z + N–Z diff --git a/miniprogram/englishPages/letterTracing/字母描红产品文档.md b/miniprogram/englishPages/letterTracing/字母描红产品文档.md new file mode 100644 index 0000000..8756557 --- /dev/null +++ b/miniprogram/englishPages/letterTracing/字母描红产品文档.md @@ -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 diff --git a/miniprogram/englishPages/shared/assets/letter/A.png b/miniprogram/englishPages/shared/assets/letter/A.png new file mode 100644 index 0000000..c58d840 Binary files /dev/null and b/miniprogram/englishPages/shared/assets/letter/A.png differ diff --git a/miniprogram/englishPages/shared/assets/letter/B.png b/miniprogram/englishPages/shared/assets/letter/B.png new file mode 100644 index 0000000..3cdcadf Binary files /dev/null and b/miniprogram/englishPages/shared/assets/letter/B.png differ diff --git a/miniprogram/englishPages/shared/draw/letterTracingDraw.ts b/miniprogram/englishPages/shared/draw/letterTracingDraw.ts index e5353e0..2da623a 100644 --- a/miniprogram/englishPages/shared/draw/letterTracingDraw.ts +++ b/miniprogram/englishPages/shared/draw/letterTracingDraw.ts @@ -2,14 +2,15 @@ import { BaseDrawService } from '../../../core/draw/baseDraw'; import type { LetterTracingData, TracingRow, -} from '../../../core/generators/letter-tracing-generator'; +} from '../generators/letter-tracing-generator'; const M = 24; -const BLUE = '#4A90E2'; -const ORANGE = '#FF8C42'; +/** 四线三格线色:偏黑,实线与虚线统一色相,仅以线型区分 */ +const LINE_INK = '#2a2a2a'; /** - * 字母描红绘制(tracing-writing + letter-tracing 的客户端实现) + * 字母描红绘制 + * 四线三格:顶线、上中线、下中线为格内三线间距;最上最下为实线,中间两虚线(产品文档) */ export default class LetterTracingDraw extends BaseDrawService { constructor( @@ -25,53 +26,65 @@ export default class LetterTracingDraw extends BaseDrawService { await this.drawHeaderAndDivider(); switch (data.mode) { - case 'single-letter': - this.drawSingleLetter(data); + case 'picture-tracing': + this.drawPictureTracing(data); + this.drawPrintFooter(); break; - case 'full-alphabet': - this.drawFullAlphabet(data.rows); + case 'case-pairing': + this.drawCasePairing(data.leftVisualRows, data.rightVisualRows); + this.drawPrintFooter(); break; - case 'upper-lower-split': - this.drawUpperLowerSplit(data.upperGlyphs, data.lowerGlyphs); + case 'column-compare': + this.drawColumnCompare(data.leftRows, data.rightRows); + this.drawPrintFooter(); break; - case 'two-column': - this.drawTwoColumn(data.leftRows, data.rightRows); + case 'overview': + this.drawOverview(data.upperRows, data.lowerRows); + this.drawPrintFooter(); break; } } - /** 四线三格:顶线、中虚线、基线、底线 */ + /** + * 四线三格:y 顶 → y+h 底,中间两条虚线 + */ private drawFourLines(x: number, y: number, w: number, h: number) { const ctx = this.ctx; const yTop = y; - const yMid = y + h * 0.28; - const yBase = y + h * 0.62; + const yMid1 = y + h * 0.28; + const yMid2 = y + h * 0.58; const yBot = y + h; - ctx.strokeStyle = BLUE; - ctx.lineWidth = 1; - ctx.setLineDash([]); + const strokeSolid = () => { + ctx.strokeStyle = LINE_INK; + ctx.lineWidth = 1; + ctx.setLineDash([]); + }; + const strokeDash = () => { + ctx.strokeStyle = LINE_INK; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + }; + + strokeSolid(); ctx.beginPath(); ctx.moveTo(x, yTop); ctx.lineTo(x + w, yTop); ctx.stroke(); - ctx.strokeStyle = ORANGE; - ctx.setLineDash([6, 6]); + strokeDash(); ctx.beginPath(); - ctx.moveTo(x, yMid); - ctx.lineTo(x + w, yMid); + ctx.moveTo(x, yMid1); + ctx.lineTo(x + w, yMid1); ctx.stroke(); - ctx.strokeStyle = BLUE; + ctx.beginPath(); + ctx.moveTo(x, yMid2); + ctx.lineTo(x + w, yMid2); + ctx.stroke(); + + strokeSolid(); 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.moveTo(x, yBot); ctx.lineTo(x + w, yBot); @@ -86,18 +99,30 @@ export default class LetterTracingDraw extends BaseDrawService { h: number, opacity: number, bold: boolean, + pairChar?: string, ) { const ctx = this.ctx; if (opacity <= 0) return; - const fontSize = Math.min(h * 0.72, w * 0.85); ctx.save(); ctx.globalAlpha = opacity; - ctx.fillStyle = bold ? '#111' : '#666'; - ctx.font = `${bold ? 'bold ' : ''}${fontSize}px sans-serif`; + ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; - const baselineY = y + h * 0.62; - ctx.fillText(ch, x + w / 2, baselineY); + const baselineY = y + h * 0.72; + + 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(); } @@ -110,7 +135,7 @@ export default class LetterTracingDraw extends BaseDrawService { ) { const gap = 4; 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++) { const cx = startX + i * (cellW + gap); @@ -124,140 +149,182 @@ export default class LetterTracingDraw extends BaseDrawService { rowH, cell.opacity, cell.isGuide, + cell.pairChar, ); } } - private drawSingleLetter( - data: Extract, + /** + * 看图描红:上左超大字母+例句,上右「配图」大 emoji;下 6 行四线三格 + */ + private drawPictureTracing( + data: Extract, ) { const ctx = this.ctx; const { teaching, rows } = data; const cw = this.canvasWidth; 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.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`; - 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; + // 左侧:大小写 + 例句(短句在字母下方) + const leftPad = M + 20; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#111'; - ctx.font = 'bold 44px sans-serif'; - ctx.fillText(`${teaching.upper} ${teaching.lower}`, tx, y + 16); - ctx.font = '22px sans-serif'; + const bigFs = Math.min(56, teachH * 0.26); + ctx.font = `bold ${bigFs}px 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.fillText(teaching.sentence, tx, y + 72); - ctx.fillStyle = '#666'; - ctx.font = '18px sans-serif'; - ctx.fillText(teaching.word, tx, y + 104); + ctx.fillText(teaching.sentence, leftPad, y + teachH * 0.48); + + // 右侧:配图区(以 emoji 作为插图占位) + 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; this.drawDashedDivider(y, M, '#ccc'); y += 14; const rest = this.canvasHeight - y - M; - const rowH = Math.max(36, (rest - 8) / rows.length); - const rowWidth = cw - M * 2; + const rowH = Math.max(34, (rest - 10) / rows.length); + const rowWidth = innerW; for (let r = 0; r < rows.length; r++) { this.drawTracingRow( rows[r], M, - y + r * (rowH + 10), + y + r * (rowH + 8), rowH, rowWidth, ); } } - private drawFullAlphabet(rows: TracingRow[]) { + /** + * 大小写配对:左右两栏,每行最多 4 组子行并排 + */ + private drawCasePairing( + leftVisualRows: TracingRow[][], + rightVisualRows: TracingRow[][], + ) { const y0 = this.currentY + 6; const bottom = this.canvasHeight - M; - const totalH = bottom - y0; - const rowH = Math.max(14, totalH / rows.length - 2); - const rowWidth = this.canvasWidth - M * 2; + const gapCol = 18; + const colW = (this.canvasWidth - M * 2 - gapCol) / 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 y = y0 + i * (rowH + 2); - this.drawTracingRow(rows[i], M, y, rowH, rowWidth); - } - } - - private drawUpperLowerSplit(upper: string[], lower: string[]) { - const ctx = this.ctx; - const cw = this.canvasWidth; - let y = this.currentY + 6; - const innerW = cw - M * 2; - - const drawBlockTitle = (t: string) => { - ctx.fillStyle = '#111'; - 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, + const drawSide = ( + blocks: TracingRow[][], + startX: number, + width: number, + ) => { + let y = y0; + for (const visualRow of blocks) { + const nSeg = visualRow.length; + const segGap = 6; + const segW = (width - (nSeg - 1) * segGap) / nSeg; + for (let s = 0; s < nSeg; s++) { + this.drawTracingRow( + visualRow[s], + startX + s * (segW + segGap), y, - cellW, - cellH, - 0.85, - true, + rowSlotH, + segW, ); } - y += cellH + 6; + y += rowSlotH + 8; } }; - drawBlockTitle('大写字母 Uppercase'); - drawGlyphGrid(upper, 13); - y += 8; - this.drawDashedDivider(y, M, '#bbb'); - y += 14; - drawBlockTitle('小写字母 Lowercase'); - drawGlyphGrid(lower, 13); + drawSide(leftVisualRows, M, colW); + drawSide(rightVisualRows, M + colW + gapCol, colW); } - private drawTwoColumn(leftRows: TracingRow[], rightRows: TracingRow[]) { + /** 分栏对照:左大写、右小写,逐行对齐 */ + private drawColumnCompare(leftRows: TracingRow[], rightRows: TracingRow[]) { const y0 = this.currentY + 6; const bottom = this.canvasHeight - M; - const totalH = bottom - y0; - const maxRows = Math.max(leftRows.length, rightRows.length); - const rowH = Math.max(16, totalH / maxRows - 3); + const maxR = Math.max(leftRows.length, rightRows.length); + const rowH = Math.max(36, (bottom - y0) / maxR - 6); const gapCol = 16; const colW = (this.canvasWidth - M * 2 - gapCol) / 2; 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); } const xR = M + colW + gapCol; 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); } } + + /** + * 字母总览: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); + } } diff --git a/miniprogram/englishPages/shared/generators/letter-tracing-generator.ts b/miniprogram/englishPages/shared/generators/letter-tracing-generator.ts new file mode 100644 index 0000000..53d80ee --- /dev/null +++ b/miniprogram/englishPages/shared/generators/letter-tracing-generator.ts @@ -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(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, + overrides: Partial, +): 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, + }; +}