feat: 完成四种字母描红开发

This commit is contained in:
R524809
2026-04-17 17:03:36 +08:00
parent 15ad5cce75
commit 9febf7da4f
16 changed files with 479 additions and 480 deletions
+89 -100
View File
@@ -12,7 +12,7 @@
覆盖四种练习形态: 覆盖四种练习形态:
1. **单字母学习页**:选定一个字母,含书写示意、阅读句(如 *L is for Lion*)、配图,下方四线三格描红行。 1. **单字母学习页**:选定一个字母,含书写示意、阅读句(如 _L is for Lion_)、配图,下方四线三格描红行。
2. **整页 26 字母**:26 个字母各占一行(或等价满版排列),四线三格内描红。 2. **整页 26 字母**:26 个字母各占一行(或等价满版排列),四线三格内描红。
3. **上大小写下小写**:上半区展示大写 A–Z,下半区展示小写 a–z。 3. **上大小写下小写**:上半区展示大写 A–Z,下半区展示小写 a–z。
4. **两列 26 字母**:一页内左列 A–M、右列 N–Z(或同构小写),每行含示范与描红格。 4. **两列 26 字母**:一页内左列 A–M、右列 N–Z(或同构小写),每行含示范与描红格。
@@ -25,14 +25,12 @@
从产品视角是四种题型;从引擎视角可收敛为**同一排版模板 + 同一数据生成器**,通过 `generatorConfig.mode` 区分。 从产品视角是四种题型;从引擎视角可收敛为**同一排版模板 + 同一数据生成器**,通过 `generatorConfig.mode` 区分。
| 用户可见类型 | 排版本质 | 模板 `template` | 生成器 `generator` |
| 用户可见类型 | 排版本质 | 模板 `template` | 生成器 `generator` | | --------------- | --------------------------------- | ----------------- | ------------------ |
| --------- | ------------------ | ----------------- | ---------------- | | 单字母学习页 | 教学区 + 描红区(混合型纵向分区) | `tracing-writing` | `letter-tracing` |
| 单字母学习页 | 教学区 + 描红区(混合型纵向分区) | `tracing-writing` | `letter-tracing` | | 26 字母整页描红 | 纯描红满版(多行) | `tracing-writing` | `letter-tracing` |
| 26 字母整页描红 | 纯描红满版(多行) | `tracing-writing` | `letter-tracing` | | 上大写 / 下小写 | 纯描红双区块(展示 + 轻描红) | `tracing-writing` | `letter-tracing` |
| 上大写 / 下小写 | 纯描红双区块(展示 + 轻描红) | `tracing-writing` | `letter-tracing` | | 两列 26 字母 | 纯描红双栏 | `tracing-writing` | `letter-tracing` |
| 两列 26 字母 | 纯描红双栏 | `tracing-writing` | `letter-tracing` |
**结论**:一个 `TracingWritingRenderer`(扩展四线三格能力)+ 一个 `LetterTracingGenerator`,用 `mode` 参数驱动四种版面,无需四种独立页面类型(除非产品坚持拆入口卡片)。 **结论**:一个 `TracingWritingRenderer`(扩展四线三格能力)+ 一个 `LetterTracingGenerator`,用 `mode` 参数驱动四种版面,无需四种独立页面类型(除非产品坚持拆入口卡片)。
@@ -42,31 +40,31 @@
### 3.1 `single-letter`(单字母学习页) ### 3.1 `single-letter`(单字母学习页)
- **上部(约 40%~45% 内容高)**:教学区 - **上部(约 40%~45% 内容高)**:教学区
- 配图(与字母绑定的词汇插图,内置 26 组映射 + 资源 key / CDN URL - 配图(与字母绑定的词汇插图,内置 26 组映射 + 资源 key / CDN URL
- 大写 + 小写展示 - 大写 + 小写展示
- 阅读句:`*X* is for *Word`* - 阅读句:`*X* is for *Word`\*
- 书写顺序示意(可用简化箭头/步骤编号,首版可弱化) - 书写顺序示意(可用简化箭头/步骤编号,首版可弱化)
- **下部(约 50%**:描红区 - **下部(约 50%**:描红区
- 至少两行四线三格:一行大写、一行小写(或按配置多行) - 至少两行四线三格:一行大写、一行小写(或按配置多行)
- 每行:示范格(实线/深色)+ 若干渐淡虚线格 + 可选空白格 - 每行:示范格(实线/深色)+ 若干渐淡虚线格 + 可选空白格
### 3.2 `full-alphabet`26 字母整页) ### 3.2 `full-alphabet`26 字母整页)
- 26 行(大写或二选一为小写),每行对应一个字母。 - 26 行(大写或二选一为小写),每行对应一个字母。
- 每行:四线三格横排多个单元格——首格示范,后续为渐淡描红或空白。 - 每行:四线三格横排多个单元格——首格示范,后续为渐淡描红或空白。
- 行高由可用高度 / 26 自动换算,保证打印可读(需与 `fontSize`、DPI 导出策略一致)。 - 行高由可用高度 / 26 自动换算,保证打印可读(需与 `fontSize`、DPI 导出策略一致)。
### 3.3 `upper-lower-split`(上大写、下小写) ### 3.3 `upper-lower-split`(上大写、下小写)
- 上区块:标题如「大写字母」+ 两行(或紧凑网格)排完 A–Z,每字母一格四线三格,偏**展示**(描红可弱:首格实线 + 空白)。 - 上区块:标题如「大写字母」+ 两行(或紧凑网格)排完 A–Z,每字母一格四线三格,偏**展示**(描红可弱:首格实线 + 空白)。
- 分隔线。 - 分隔线。
- 下区块:标题如「小写字母」+ 同理排完 a–z。 - 下区块:标题如「小写字母」+ 同理排完 a–z。
### 3.4 `two-column`(两列) ### 3.4 `two-column`(两列)
- 左列 A–M(或 a–m),右列 N–Z(或 n–z)。 - 左列 A–M(或 a–m),右列 N–Z(或 n–z)。
- 每行结构与 `full-alphabet` 类似:示范 + 多格描红。 - 每行结构与 `full-alphabet` 类似:示范 + 多格描红。
- 中间留白或细分隔线,避免幼儿视觉混淆。 - 中间留白或细分隔线,避免幼儿视觉混淆。
--- ---
@@ -75,9 +73,9 @@
用于英文字母书写指导(区别于汉字田字格): 用于英文字母书写指导(区别于汉字田字格):
- **顶线**:大写字母上沿参考。 - **顶线**:大写字母上沿参考。
- **中虚线**:小写无升部字母的上沿(x-height 概念)。 - **中虚线**:小写无升部字母的上沿(x-height 概念)。
- **基线**:字母落笔主参考线。 - **基线**:字母落笔主参考线。
- **底线**:降部(如 g、p、y)下沿参考。 - **底线**:降部(如 g、p、y)下沿参考。
大写主体落在顶线—基线之间;小写主体多在中虚线—基线之间,降部延伸至底线。渲染器需实现 `drawFourLineGrid(cellRect)`,并与 `fontSize`、基线对齐算法统一。 大写主体落在顶线—基线之间;小写主体多在中虚线—基线之间,降部延伸至底线。渲染器需实现 `drawFourLineGrid(cellRect)`,并与 `fontSize`、基线对齐算法统一。
@@ -96,99 +94,97 @@
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
← 返回 英语字母描红 ❤️ │ ← 返回 英语字母描红 ❤️
├─────────────────────────────────────────┤ ├─────────────────────────────────────────┤
│ │
┌─────────────────────────────────┐ │ ┌─────────────────────────────────┐ │
│ │
A4 打印效果预览区 A4 打印效果预览区
(Canvas 实时渲染) (Canvas 实时渲染)
│ │
└─────────────────────────────────┘ │ └─────────────────────────────────┘ │
│ │
⚙️ 参数设置 │ ⚙️ 参数设置 │
┌─────────────────────────────────┐ │ ┌─────────────────────────────────┐ │
│ │
练习类型: 练习类型:
┌──────┐┌──────┐┌──────┐ ┌──────┐┌──────┐┌──────┐
│ 单字母 ││ 整页 ││大小写│ │ ← 核心切换 │ 单字母 ││ 整页 ││大小写│ │ ← 核心切换
│ 学习页 ││ 描红 ││对照 │ 学习页 ││ 描红 ││对照 │
└──────┘└──────┘└──────┘ └──────┘└──────┘└──────┘
┌──────┐ ┌──────┐
│ 两列 │ 两列 │
│ 描红 │ 描红 │
└──────┘ └──────┘
│ │
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│ │
大小写:[大写 ▼] │ ← mode 2/4 时显示 大小写:[大写 ▼] │ ← mode 2/4 时显示
│ │
选择字母:A [选中] B [选中]... ← mode 1 时显示 选择字母:A [选中] B [选中]... │ ← mode 1 时显示
│ │
每行描红格数:[5 ▼] │ ← mode 1/2/4 时显示 每行描红格数:[5 ▼] │ ← mode 1/2/4 时显示
│ │
└─────────────────────────────────┘ │ └─────────────────────────────────┘ │
│ │
┌──────────┐ ┌──────────────┐ │ ┌──────────┐ ┌──────────────┐ │
🔄 换一批 │ │ 📥 保存到相册 │ 🔄 换一批 │ │ 📥 保存到相册 │ │
└──────────┘ └──────────────┘ │ └──────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
### 5.2 参数项与 Mode 的显隐 ### 5.2 参数项与 Mode 的显隐
| 配置项 | `single-letter` | `full-alphabet` | `upper-lower-split` | `two-column` |
| 配置项 | `single-letter` | `full-alphabet` | `upper-lower-split` | `two-column` | | ---------------- | ---------------- | --------------- | ------------------- | ------------ |
| ---------- | --------------- | --------------- | ------------------- | ------------ | | 练习类型(mode) | 显示 | 显示 | 显示 | 显示 |
| 练习类型(mode) | 显示 | 显示 | 显示 | 显示 | | 选择字母 | 显示(单选) | 隐藏 | 隐藏 | 隐藏 |
| 选择字母 | 显示(单选) | 隐藏 | 隐藏 | 隐藏 | | 大小写 | 隐藏(默认双写) | 显示 | 隐藏(固定双区) | 显示 |
| 大小写 | 隐藏(默认双写) | 显示 | 隐藏固定双区) | 显示 | | 每行描红格数 | 显示 | 显示 | 隐藏固定为 1 | 显示 |
| 每行描红格数 | 显示 | 显示 | 隐藏或固定为 1 | 显示 | | 换一批 | 可切换下一字母 | 可不展示或禁用 | 可不展示 | 可不展示 |
| 换一批 | 可切换下一字母 | 可不展示或禁用 | 可不展示 | 可不展示 |
参数面板由 `WorksheetConfig.userConfigurable` 描述;前端根据当前 `mode` **过滤**不适用字段(或在 JSON 层拆成 4 条配置,每条只含本 mode 的 `userConfigurable`)。 参数面板由 `WorksheetConfig.userConfigurable` 描述;前端根据当前 `mode` **过滤**不适用字段(或在 JSON 层拆成 4 条配置,每条只含本 mode 的 `userConfigurable`)。
### 5.3 入口策略 ### 5.3 入口策略
- **推荐**:发现页 / 英语分类下 **4 张卡片**,对应 4 个 `worksheet id`,均进入**同一通用 worksheet 页面**,仅默认 `generatorConfig` 不同。 - **推荐**:发现页 / 英语分类下 **4 张卡片**,对应 4 个 `worksheet id`,均进入**同一通用 worksheet 页面**,仅默认 `generatorConfig` 不同。
- **可选**:单页内提供「练习类型」Segment,切换 `mode` 并合并默认参数后重绘(与多卡片二选一或并存)。 - **可选**:单页内提供「练习类型」Segment,切换 `mode` 并合并默认参数后重绘(与多卡片二选一或并存)。
--- ---
@@ -221,15 +217,15 @@ interface LetterTracingGeneratorConfig {
生成器产出**与平台无关**的数据,供 `TracingWritingRenderer` 消费: 生成器产出**与平台无关**的数据,供 `TracingWritingRenderer` 消费:
- `single-letter``{ teaching: { upper, lower, word, sentence, imageRef }, rows: TracingRow[] }` - `single-letter``{ teaching: { upper, lower, word, sentence, imageRef }, rows: TracingRow[] }`
- `full-alphabet` / `two-column``{ columns?: 1 | 2, rows: TracingRow[] }` - `full-alphabet` / `two-column``{ columns?: 1 | 2, rows: TracingRow[] }`
- `upper-lower-split``{ upperBlock: TracingRow[] | GlyphCell[], lowerBlock: ... }` - `upper-lower-split``{ upperBlock: TracingRow[] | GlyphCell[], lowerBlock: ... }`
`TracingRow` 建议包含:`displayChar``cells: { char, opacity, isGuide }[]``gridType: 'four-line'` `TracingRow` 建议包含:`displayChar``cells: { char, opacity, isGuide }[]``gridType: 'four-line'`
### 6.3 词汇与配图数据 ### 6.3 词汇与配图数据
- 维护 `Record<'A'..'Z', { word: string; imageKey: string }>`(或云端下发)。 - 维护 `Record<'A'..'Z', { word: string; imageKey: string }>`(或云端下发)。
- 图片走内置分包资源或云存储 URL,由 [产品设计文档](../产品设计文档.md) §7.3 中「云素材」规则约束:**插图是设计资产,可 CDN;整页描红仍客户端拼版**。 - 图片走内置分包资源或云存储 URL,由 [产品设计文档](../产品设计文档.md) §7.3 中「云素材」规则约束:**插图是设计资产,可 CDN;整页描红仍客户端拼版**。
--- ---
@@ -238,23 +234,21 @@ interface LetterTracingGeneratorConfig {
### 7.1 职责 ### 7.1 职责
- 沿用现有 `BaseTemplateRenderer` 流程:背景、页眉、说明文案、内容区、页脚。 - 沿用现有 `BaseTemplateRenderer` 流程:背景、页眉、说明文案、内容区、页脚。
- 根据 `data.mode` 分支调用 `drawSingleLetterPage` / `drawFullAlphabetPage` / `drawUpperLowerSplitPage` / `drawTwoColumnPage` - 根据 `data.mode` 分支调用 `drawSingleLetterPage` / `drawFullAlphabetPage` / `drawUpperLowerSplitPage` / `drawTwoColumnPage`
- 统一使用 **A4 逻辑坐标**(与技术文档 §7.1 Canvas 管线一致)。 - 统一使用 **A4 逻辑坐标**(与技术文档 §7.1 Canvas 管线一致)。
### 7.2 与汉字描红的差异 ### 7.2 与汉字描红的差异
| 维度 | 汉字练字(`character-tracing` | 字母描红(`letter-tracing` |
| 维度 | 汉字练字(`character-tracing` | 字母描红(`letter-tracing` | | ---- | ------------------------------- | ---------------------------- |
| --- | ------------------------- | ---------------------- | | 格子 | 田字格 | 四线三格 |
| 格子 | 田字格 | 四线三格 | | 字形 | SVG 笔画路径 | 优先 `fillText` + 字体轮廓 |
| 字形 | SVG 笔画路径 | 优先 `fillText` + 字体轮廓 | | 版面 | 多字多格排布 | 四种 mode 分区逻辑 |
| 版面 | 多字多格排布 | 四种 mode 分区逻辑 |
### 7.3 参考现有实现 ### 7.3 参考现有实现
- `service/wordDrawService.ts`:布局分区、渐淡格思路可对齐。 - `service/wordDrawService.ts`:布局分区、渐淡格思路可对齐。
- `mathPages/shared/service/numberWriteDraw.ts`:四线三格线型可参考。 - `mathPages/shared/service/numberWriteDraw.ts`:四线三格线型可参考。
--- ---
@@ -330,7 +324,7 @@ interface LetterTracingGeneratorConfig {
```json ```json
{ {
"id": "letter-tracing-two-column", "id": "letter-tracing-case-pairing",
"title": "字母描红(两列)", "title": "字母描红(两列)",
"category": "english", "category": "english",
"subcategory": "letter-tracing", "subcategory": "letter-tracing",
@@ -354,24 +348,19 @@ interface LetterTracingGeneratorConfig {
## 九、实现落地清单(工程侧) ## 九、实现落地清单(工程侧)
| 项 | 说明 |
| 项 | 说明 | | ------------------------ | ---------------------------------------------------------------------------------------- |
| ------------------------ | -------------------------------------------------------------------------- | | `LetterTracingGenerator` | `core/generators/` 或英语分包 `shared/generators/`,注册到 `generatorRegistry` |
| `LetterTracingGenerator` | `core/generators/` 或英语分包 `shared/generators/`,注册到 `generatorRegistry` | | `TracingWritingRenderer` | 扩展四线三格 + 四种 `draw*Page` |
| `TracingWritingRenderer` | 扩展四线三格 + 四种 `draw*Page` | | 字母词汇与配图映射 | `core/data/alphabet.ts` 或云端配置 |
| 字母词汇与配图映射 | `core/data/alphabet.ts` 或云端配置 | | 通用 worksheet 页 | `pages/worksheet/worksheet`(或过渡期 `englishPages` 专用页再迁) |
| 通用 worksheet 页 | `pages/worksheet/worksheet`(或过渡期 `englishPages` 专用页再迁) |
| `ALL_WORKSHEETS` | 增加 4 条 `WorksheetDefinition``category` 对齐英语,`legacyPage` 可指向 worksheet 路由 | | `ALL_WORKSHEETS` | 增加 4 条 `WorksheetDefinition``category` 对齐英语,`legacyPage` 可指向 worksheet 路由 |
| 分包 | 按需新增 `englishPages`,避免主包膨胀 | | 分包 | 按需新增 `englishPages`,避免主包膨胀 |
--- ---
## 十、修订记录 ## 十、修订记录
| 日期 | 版本 | 说明 |
| 日期 | 版本 | 说明 | | ---------- | ---- | -------- |
| ---------- | ---- | ---- |
| 2026-04-03 | v1.0 | 初稿入库 | | 2026-04-03 | v1.0 | 初稿入库 |
+18 -18
View File
@@ -280,7 +280,7 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
instructionText: '看一看,读一读,再描一描', instructionText: '看一看,读一读,再描一描',
}, },
generatorConfig: { generatorConfig: {
mode: 'picture-tracing', mode: 'letter-tracing-single',
letterCase: 'upper', letterCase: 'upper',
selectedLetter: 'A', selectedLetter: 'A',
repetitions: 5, repetitions: 5,
@@ -290,9 +290,9 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
), ),
sheet( sheet(
{ {
id: 'letter-tracing-full', id: 'letter-tracing-two-column',
title: '分栏对照', title: '两列练习',
desc: '半组字母左大写右小写,每行四个', desc: '左 AM、右 NZ,每组包含大小写',
category: 'english', category: 'english',
page: 'letterTracing', page: 'letterTracing',
subpackage: 'englishPages', subpackage: 'englishPages',
@@ -301,7 +301,7 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
ageRange: [4, 7], ageRange: [4, 7],
difficulty: 'beginner', difficulty: 'beginner',
previewBg: '#e8f4ff', previewBg: '#e8f4ff',
previewText: 'A→Z', previewText: 'A|N',
}, },
{ {
subcategory: 'letter-tracing', subcategory: 'letter-tracing',
@@ -310,12 +310,12 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
sortOrder: 43, sortOrder: 43,
layoutConfig: { layoutConfig: {
...L.trace, ...L.trace,
instructionText: '左栏大写、右栏小写,逐行对应描红', columns: 2,
instructionText: '左 A–M、右 NZ,大小写配对描红',
}, },
generatorConfig: { generatorConfig: {
mode: 'column-compare', mode: 'letter-tracing-two-column',
letterCase: 'both', letterCase: 'both',
alphabetHalf: 'A-M',
repetitions: 4, repetitions: 4,
fadePattern: 'gradient', fadePattern: 'gradient',
}, },
@@ -347,7 +347,7 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
instructionText: '认读全部大写与小写字母', instructionText: '认读全部大写与小写字母',
}, },
generatorConfig: { generatorConfig: {
mode: 'overview', mode: 'letter-tracing-upper-lower',
letterCase: 'both', letterCase: 'both',
repetitions: 1, repetitions: 1,
fadePattern: 'first-only', fadePattern: 'first-only',
@@ -356,9 +356,9 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
), ),
sheet( sheet(
{ {
id: 'letter-tracing-two-column', id: 'letter-tracing-case-pairing',
title: '大小写配对', title: '大小写练习',
desc: '左 AM、右 NZ,每行四组大小写', desc: '半组字母左大写右小写,每行四个',
category: 'english', category: 'english',
page: 'letterTracing', page: 'letterTracing',
subpackage: 'englishPages', subpackage: 'englishPages',
@@ -367,7 +367,7 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
ageRange: [4, 7], ageRange: [4, 7],
difficulty: 'beginner', difficulty: 'beginner',
previewBg: '#e8f4ff', previewBg: '#e8f4ff',
previewText: 'A|N', previewText: 'A→Z',
}, },
{ {
subcategory: 'letter-tracing', subcategory: 'letter-tracing',
@@ -376,12 +376,12 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
sortOrder: 45, sortOrder: 45,
layoutConfig: { layoutConfig: {
...L.trace, ...L.trace,
columns: 2, instructionText: '左栏大写、右栏小写,逐行对应描红',
instructionText: '沿虚线描写字母',
}, },
generatorConfig: { generatorConfig: {
mode: 'case-pairing', mode: 'letter-tracing-case-pairing',
letterCase: 'both', letterCase: 'both',
alphabetHalf: 'A-M',
repetitions: 4, repetitions: 4,
fadePattern: 'gradient', fadePattern: 'gradient',
}, },
@@ -412,7 +412,7 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
instructionText: '每页 3 个字母,逐字精练', instructionText: '每页 3 个字母,逐字精练',
}, },
generatorConfig: { generatorConfig: {
mode: 'triple-focus', mode: 'letter-tracing-triple',
letterCase: 'both', letterCase: 'both',
repetitions: 5, repetitions: 5,
fadePattern: 'gradient', fadePattern: 'gradient',
@@ -445,7 +445,7 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
instructionText: '每行一个字母,逐字反复练习', instructionText: '每行一个字母,逐字反复练习',
}, },
generatorConfig: { generatorConfig: {
mode: 'single-line', mode: 'letter-tracing-single-line',
letterCase: 'both', letterCase: 'both',
alphabetHalf: 'A-M', alphabetHalf: 'A-M',
repetitions: 5, repetitions: 5,
+1 -1
View File
@@ -13,7 +13,7 @@ import drawHeader from './drawHeader';
* - 分割线绘制 * - 分割线绘制
* - 打印配置管理 * - 打印配置管理
*/ */
const LINE_COLOR = 'rgba(50, 46, 37, 0.4)'; const LINE_COLOR = '#BCBAB2'; // 打印友好的深灰线色,避免渐变色
const LINE_WIDTH = 3; const LINE_WIDTH = 3;
export class BaseDrawService { export class BaseDrawService {
+1 -1
View File
@@ -23,7 +23,7 @@ const META_FONT = '14px "Microsoft Yahei"';
const META_FONT_PX = 12; 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 = '#BCBAB2';
const LINE_WIDTH = 3; const LINE_WIDTH = 3;
const META_COLOR = '#7C766A'; const META_COLOR = '#7C766A';
const META_ROW_MARGIN_X = 130; const META_ROW_MARGIN_X = 130;
@@ -30,14 +30,14 @@ const MODES = [
desc: 'Uppercase / Lowercase 总览', desc: 'Uppercase / Lowercase 总览',
}, },
{ {
id: 'letter-tracing-two-column', id: 'letter-tracing-case-pairing',
label: '大小写配对', label: '大小写练习',
desc: '左 AM、右 NZ 配对描红', desc: '半组字母左大写右小写',
}, },
{ {
id: 'letter-tracing-full', id: 'letter-tracing-two-column',
label: '分栏对照', label: '两列练习',
desc: '半组字母左大写右小写', desc: '左 AM、右 NZ 配对描红',
}, },
{ {
id: 'letter-tracing-triple', id: 'letter-tracing-triple',
@@ -89,7 +89,7 @@ createPage(
hasContent: false, hasContent: false,
showShareDialog: false, showShareDialog: false,
worksheetId: 'letter-tracing-single', worksheetId: 'letter-tracing-single',
traceMode: 'picture-tracing', traceMode: 'letter-tracing-single',
selectedLetter: 'A', selectedLetter: 'A',
letterCaseLower: false, letterCaseLower: false,
showLetterPicker: true, showLetterPicker: true,
@@ -166,14 +166,14 @@ createPage(
const base = (def?.generatorConfig ?? const base = (def?.generatorConfig ??
{}) as Partial<LetterTracingGeneratorConfig>; {}) as Partial<LetterTracingGeneratorConfig>;
const letterCase = const letterCase =
this.data.traceMode === 'picture-tracing' this.data.traceMode === 'letter-tracing-single'
? (base.letterCase ?? 'upper') ? (base.letterCase ?? 'upper')
: this.data.letterCaseLower : this.data.letterCaseLower
? 'lower' ? 'lower'
: 'upper'; : 'upper';
const needsHalf = const needsHalf =
this.data.traceMode === 'column-compare' || this.data.traceMode === 'letter-tracing-case-pairing' ||
this.data.traceMode === 'single-line'; this.data.traceMode === 'letter-tracing-single-line';
return mergeLetterTracingConfig(base, { return mergeLetterTracingConfig(base, {
selectedLetter: this.data.selectedLetter, selectedLetter: this.data.selectedLetter,
letterCase, letterCase,
@@ -254,17 +254,20 @@ createPage(
const traceMode = merged.mode; const traceMode = merged.mode;
const showLetterPicker = const showLetterPicker =
traceMode === 'picture-tracing' || traceMode === 'triple-focus'; traceMode === 'letter-tracing-single' ||
traceMode === 'letter-tracing-triple';
const showCaseToggle = const showCaseToggle =
traceMode === 'column-compare' || traceMode === 'single-line'; traceMode === 'letter-tracing-case-pairing' ||
const showNextLetter = traceMode === 'picture-tracing'; traceMode === 'letter-tracing-single-line';
const showNextLetter = traceMode === 'letter-tracing-single';
const selectedLetter = options?.preserveLetter const selectedLetter = options?.preserveLetter
? (this.data.selectedLetter ?? 'A') ? (this.data.selectedLetter ?? 'A')
: normalizeLetter(merged.selectedLetter ?? ''); : normalizeLetter(merged.selectedLetter ?? '');
const letterCaseLower = const letterCaseLower =
traceMode === 'column-compare' || traceMode === 'single-line' traceMode === 'letter-tracing-case-pairing' ||
traceMode === 'letter-tracing-single-line'
? merged.alphabetHalf === 'N-Z' ? merged.alphabetHalf === 'N-Z'
: traceMode === 'picture-tracing' : traceMode === 'letter-tracing-single'
? merged.letterCase === 'lower' ? merged.letterCase === 'lower'
: false; : false;
@@ -24,18 +24,18 @@
所有字母以浅红色描红字体呈现,可打印后用铅笔覆写练习。 所有字母以浅红色描红字体呈现,可打印后用铅笔覆写练习。
### 3. 大小写配对case-pairing)—— 左右分栏 · 大小写成对练习 ### 3. 分栏对照case-pairing)—— 半表字母 · 左大写右小写对照
> 以 Aa 为一组进行配对描红,强化大小写对应关系。
页面分左右两栏(左栏 A–M,右栏 N–Z),每行展示 4 组大小写配对字母。同一行的字母互相对应,每组内包含示范字母(实色)和渐淡描红格,引导从临摹到独立书写。
### 4. 分栏对照(column-compare)—— 半表字母 · 左大写右小写对照
> 半组字母(A–M 或 N–Z)逐行展开,左大写右小写一一对照。 > 半组字母(A–M 或 N–Z)逐行展开,左大写右小写一一对照。
四线三格横跨整页宽度,每行分为左右两半:左半栏写大写字母,右半栏写对应的小写字母。第一个字母为黑色示范,后续为浅红色描红。可切换 A–M / N–Z 两个半表。 四线三格横跨整页宽度,每行分为左右两半:左半栏写大写字母,右半栏写对应的小写字母。第一个字母为黑色示范,后续为浅红色描红。可切换 A–M / N–Z 两个半表。
### 4. 两列练习(two-column)—— 左右分栏 · 大小写成对练习
> 以 Aa 为一组进行配对描红,强化大小写对应关系。
页面分左右两栏(左栏 A–M,右栏 N–Z),每行展示 4 组大小写配对字母。同一行的字母互相对应,每组内包含示范字母(实色)和渐淡描红格,引导从临摹到独立书写。每行第一组字母是黑色,后续字母是浅红色,每个颜色渐淡,两栏之间间距 18
### 5. 三字母精练(triple-focus)—— 每页 3 个字母 · 深度书写 ### 5. 三字母精练(triple-focus)—— 每页 3 个字母 · 深度书写
> 每页聚焦 3 个字母,兼顾专注度和练习量,适合进阶阶段。 > 每页聚焦 3 个字母,兼顾专注度和练习量,适合进阶阶段。
@@ -3,121 +3,128 @@ import type {
LetterTracingData, LetterTracingData,
TracingRow, TracingRow,
} from '../generators/letter-tracing-generator'; } from '../generators/letter-tracing-generator';
import { drawFourLineGrid } from './drawTools'; import { fontFamilyOf } from '../data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
type LetterFontSizes,
} from './drawTools';
const COLOR_BLACK = '#1a1a1a';
const COLOR_LIGHT_RED = '#FF8E8E';
const LETTER_FACE = 'Helvetica, Arial, "Segoe UI", Roboto, sans-serif'; /** 左右大写/小写两栏之间的间距(相对行宽 + 下限,避免过窄屏过小) */
const COLUMN_GUTTER_RATIO = 0.02;
const COLUMN_GUTTER_MIN = 14;
type CasePairingData = Extract<LetterTracingData, { mode: 'case-pairing' }>; /**
* 单栏内字母排版区域占栏宽比例(<1 则同栏字母更紧凑、字间距更小)
*/
const INTRA_COLUMN_LETTER_BAND_RATIO = 1;
type CasePairingData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-case-pairing' }
>;
/**
* 分栏对照绘制:
* 四线三格横跨整页宽度,字母分左右两侧书写——
* 左栏大写、右栏小写(各 repetitions 个),中间留间距;同栏内字母略收紧。
* 每侧第一个字母黑色,后续浅红色。
*/
export default class CasePairingDraw extends BaseDrawService { export default class CasePairingDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
async draw(data: CasePairingData) { async draw(data: CasePairingData) {
this.prepareDraw(); this.prepareDraw();
await this.drawHeaderAndDivider(); await this.drawHeaderAndDivider();
this.drawContent(data.leftVisualRows, data.rightVisualRows); this.drawContent(data.leftRows, data.rightRows);
this.drawPrintFooter(); this.drawPrintFooter();
} }
private drawContent( private drawContent(leftRows: TracingRow[], rightRows: TracingRow[]) {
leftVisualRows: TracingRow[][],
rightVisualRows: TracingRow[][],
) {
const M = 24; const M = 24;
const y0 = this.currentY + 6; const lineW = this.canvasWidth - M * 2;
const bottom = this.canvasHeight - M; const maxR = Math.max(leftRows.length, rightRows.length);
const gapCol = 18;
const colW = (this.canvasWidth - M * 2 - gapCol) / 2; const footerReserve = 56;
const maxVisual = Math.max( const availableH =
leftVisualRows.length, this.canvasHeight - this.currentY - footerReserve - 12;
rightVisualRows.length, const rowGap = Math.max(
4,
(availableH - maxR * FOUR_LINE_GRID_H) / (maxR + 1),
); );
const rowSlotH = Math.max(40, (bottom - y0) / maxVisual - 6);
const drawSide = ( const y0 = this.currentY + rowGap;
blocks: TracingRow[][],
startX: number, const columnGutter = Math.max(
width: number, COLUMN_GUTTER_MIN,
) => { Math.round(lineW * COLUMN_GUTTER_RATIO),
let y = y0; );
for (const visualRow of blocks) { const halfContentW = (lineW - columnGutter) / 2;
const nSeg = visualRow.length; const leftX = M;
const segGap = 6; const rightX = M + halfContentW + columnGutter;
const segW = (width - (nSeg - 1) * segGap) / nSeg;
for (let s = 0; s < nSeg; s++) { for (let i = 0; i < maxR; i++) {
this.drawTracingRow( const y = y0 + i * (FOUR_LINE_GRID_H + rowGap);
visualRow[s],
startX + s * (segW + segGap), drawFourLineGrid(this.ctx, M, y, lineW, FOUR_LINE_GRID_H);
y,
rowSlotH, if (i < leftRows.length) {
segW, this.drawLettersInHalf(
); leftRows[i],
} leftX,
y += rowSlotH + 8; y,
halfContentW,
INTRA_COLUMN_LETTER_BAND_RATIO,
);
} }
}; if (i < rightRows.length) {
this.drawLettersInHalf(
drawSide(leftVisualRows, M, colW); rightRows[i],
drawSide(rightVisualRows, M + colW + gapCol, colW); rightX,
y,
halfContentW,
INTRA_COLUMN_LETTER_BAND_RATIO,
);
}
}
} }
private drawTracingRow( /**
* 在四线三格的半侧区域内绘制字母。
* 第一个字母黑色,后续字母浅红色。
*/
private drawLettersInHalf(
row: TracingRow, row: TracingRow,
startX: number, x: number,
y: number, y: number,
rowH: number, columnW: number,
totalWidth: number, letterBandRatio: number,
) { ) {
const gap = 4;
const n = row.cells.length; const n = row.cells.length;
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth; if (n === 0) return;
const bandW = columnW * letterBandRatio;
const inset = (columnW - bandW) / 2;
const slotW = bandW / n;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const cx = startX + i * (cellW + gap);
drawFourLineGrid(this.ctx, cx, y, cellW, rowH);
const cell = row.cells[i]; const cell = row.cells[i];
this.drawCharInCell( const color = i === 0 ? COLOR_BLACK : COLOR_LIGHT_RED;
const cx = x + inset + slotW * i + slotW / 2;
drawLetterInFourLineGrid(
this.ctx,
cell.char, cell.char,
cx, cx,
y, y,
cellW, FOUR_LINE_GRID_H,
rowH, this.letterSizes,
cell.opacity, { fontFamily: fontFamilyOf(this.letterSizes._profile), color },
cell.isGuide,
cell.pairChar,
); );
} }
} }
private drawCharInCell(
ch: string,
x: number,
y: number,
w: number,
h: number,
opacity: number,
bold: boolean,
pairChar?: string,
) {
const ctx = this.ctx;
if (opacity <= 0) return;
ctx.save();
ctx.globalAlpha = opacity;
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const baselineY = y + h * 0.72;
if (pairChar) {
const fs = Math.min(h * 0.5, w * 0.22);
ctx.font = `${bold ? 'bold ' : ''}${fs}px ${LETTER_FACE}`;
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 ${LETTER_FACE}`;
ctx.fillText(ch, x + w / 2, baselineY);
}
ctx.restore();
}
} }
@@ -1,127 +0,0 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
import { fontFamilyOf } from '../data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
type LetterFontSizes,
} from './drawTools';
const COLOR_BLACK = '#1a1a1a';
const COLOR_LIGHT_RED = 'rgba(252, 46, 0, 0.3)';
/** 左右大写/小写两栏之间的间距(相对行宽 + 下限,避免过窄屏过小) */
const COLUMN_GUTTER_RATIO = 0.02;
const COLUMN_GUTTER_MIN = 14;
/**
* 单栏内字母排版区域占栏宽比例(<1 则同栏字母更紧凑、字间距更小)
*/
const INTRA_COLUMN_LETTER_BAND_RATIO = 1;
type ColumnCompareData = Extract<LetterTracingData, { mode: 'column-compare' }>;
/**
* 分栏对照绘制:
* 四线三格横跨整页宽度,字母分左右两侧书写——
* 左栏大写、右栏小写(各 repetitions 个),中间留间距;同栏内字母略收紧。
* 每侧第一个字母黑色,后续浅红色。
*/
export default class ColumnCompareDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
async draw(data: ColumnCompareData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawContent(data.leftRows, data.rightRows);
this.drawPrintFooter();
}
private drawContent(leftRows: TracingRow[], rightRows: TracingRow[]) {
const M = 24;
const lineW = this.canvasWidth - M * 2;
const maxR = Math.max(leftRows.length, rightRows.length);
const footerReserve = 56;
const availableH =
this.canvasHeight - this.currentY - footerReserve - 12;
const rowGap = Math.max(
4,
(availableH - maxR * FOUR_LINE_GRID_H) / (maxR + 1),
);
const y0 = this.currentY + rowGap;
const columnGutter = Math.max(
COLUMN_GUTTER_MIN,
Math.round(lineW * COLUMN_GUTTER_RATIO),
);
const halfContentW = (lineW - columnGutter) / 2;
const leftX = M;
const rightX = M + halfContentW + columnGutter;
for (let i = 0; i < maxR; i++) {
const y = y0 + i * (FOUR_LINE_GRID_H + rowGap);
drawFourLineGrid(this.ctx, M, y, lineW, FOUR_LINE_GRID_H);
if (i < leftRows.length) {
this.drawLettersInHalf(
leftRows[i],
leftX,
y,
halfContentW,
INTRA_COLUMN_LETTER_BAND_RATIO,
);
}
if (i < rightRows.length) {
this.drawLettersInHalf(
rightRows[i],
rightX,
y,
halfContentW,
INTRA_COLUMN_LETTER_BAND_RATIO,
);
}
}
}
/**
* 在四线三格的半侧区域内绘制字母。
* 第一个字母黑色,后续字母浅红色。
*/
private drawLettersInHalf(
row: TracingRow,
x: number,
y: number,
columnW: number,
letterBandRatio: number,
) {
const n = row.cells.length;
if (n === 0) return;
const bandW = columnW * letterBandRatio;
const inset = (columnW - bandW) / 2;
const slotW = bandW / n;
for (let i = 0; i < n; i++) {
const cell = row.cells[i];
const color = i === 0 ? COLOR_BLACK : COLOR_LIGHT_RED;
const cx = x + inset + slotW * i + slotW / 2;
drawLetterInFourLineGrid(
this.ctx,
cell.char,
cx,
y,
FOUR_LINE_GRID_H,
this.letterSizes,
{ fontFamily: fontFamilyOf(this.letterSizes._profile), color },
);
}
}
}
@@ -97,8 +97,7 @@ export function drawLetterInFourLineGrid(
bold?: boolean; bold?: boolean;
}, },
) { ) {
const fontFamily = const fontFamily = options?.fontFamily ?? fontFamilyOf(sizes._profile);
options?.fontFamily ?? fontFamilyOf(sizes._profile);
const color = options?.color ?? '#1a1a1a'; const color = options?.color ?? '#1a1a1a';
const bold = options?.bold ?? false; const bold = options?.bold ?? false;
@@ -1,9 +1,9 @@
import { BaseDrawService } from '../../../core/draw/baseDraw'; import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { LetterTracingData } from '../generators/letter-tracing-generator'; import type { LetterTracingData } from '../generators/letter-tracing-generator';
import PictureTracingDraw from './pictureTracingDraw'; import singleLetterDraw from './singleLetterDraw';
import CasePairingDraw from './casePairingDraw'; import CasePairingDraw from './casePairingDraw';
import ColumnCompareDraw from './columnCompareDraw'; import TwoColumnDraw from './twoColumnDraw';
import OverviewDraw from './overviewDraw'; import UpperLowerDraw from './upperLowerDraw';
/** /**
* 字母描红绘制分发层。 * 字母描红绘制分发层。
@@ -12,10 +12,10 @@ import OverviewDraw from './overviewDraw';
export default class LetterTracingDraw extends BaseDrawService { export default class LetterTracingDraw extends BaseDrawService {
async draw(data: LetterTracingData) { async draw(data: LetterTracingData) {
const DrawClass = { const DrawClass = {
'picture-tracing': PictureTracingDraw, 'letter-tracing-single': singleLetterDraw,
'case-pairing': CasePairingDraw, 'letter-tracing-upper-lower': UpperLowerDraw,
'column-compare': ColumnCompareDraw, 'letter-tracing-case-pairing': CasePairingDraw,
overview: OverviewDraw, 'letter-tracing-two-column': TwoColumnDraw,
}[data.mode]; }[data.mode];
const delegate = new DrawClass(this.canvas, this.ctx, this.options); const delegate = new DrawClass(this.canvas, this.ctx, this.options);
@@ -19,23 +19,26 @@ const ACCENT_RED = 'rgba(252, 46, 0, 1)';
const TITLE_TEXT_FONT = '14px sans-serif'; const TITLE_TEXT_FONT = '14px sans-serif';
const TITLE_ICON_GAP = 4; const TITLE_ICON_GAP = 4;
type PictureData = Extract<LetterTracingData, { mode: 'picture-tracing' }>; type SingleLetterData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-single' }
>;
export default class PictureTracingDraw extends BaseDrawService { export default class SingleLetterDraw extends BaseDrawService {
private letterSizes: LetterFontSizes = private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H); calcLetterFontSizes(FOUR_LINE_GRID_H);
private get letterFace(): string { private get letterFace(): string {
return fontFamilyOf(this.letterSizes._profile); return fontFamilyOf(this.letterSizes._profile);
} }
async draw(data: PictureData) { async draw(data: SingleLetterData) {
this.prepareDraw(); this.prepareDraw();
await this.drawHeaderAndDivider(); await this.drawHeaderAndDivider();
await this.drawContent(data); await this.drawContent(data);
this.drawPrintFooter(); this.drawPrintFooter();
} }
private async drawContent(data: PictureData) { private async drawContent(data: SingleLetterData) {
const { teaching, highlightGrid, traceImgPath } = data; const { teaching, highlightGrid, traceImgPath } = data;
const ctx = this.ctx; const ctx = this.ctx;
@@ -79,7 +82,7 @@ export default class PictureTracingDraw extends BaseDrawService {
); );
} }
this.drawSentence( this.drawSingleLetterSentence(
teaching.sentence, teaching.sentence,
teaching.upper, teaching.upper,
TOP_X, TOP_X,
@@ -228,7 +231,7 @@ export default class PictureTracingDraw extends BaseDrawService {
ctx.restore(); ctx.restore();
} }
private drawSentence( private drawSingleLetterSentence(
sentence: string, sentence: string,
letter: string, letter: string,
x: number, x: number,
@@ -0,0 +1,124 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../generators/letter-tracing-generator';
import { fontFamilyOf } from '../data/fontProfiles';
import {
calcLetterFontSizes,
drawFourLineGrid,
drawLetterInFourLineGrid,
FOUR_LINE_GRID_H,
type LetterFontSizes,
} from './drawTools';
const COLOR_BLACK = '#1a1a1a';
const COLOR_LIGHT_RED = '#FF8E8E';
const COLUMN_GUTTER = 18;
type TwoColumnData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-two-column' }
>;
/**
* 两列练习绘制:
* 左栏 A–M(Aa Bb … Mm)、右栏 NZNn Oo … Zz),每栏 13 行。
* 每行一个完整四线三格,格内绘制大小写配对字母。
* 第一行黑色示范,后续行浅红色渐淡。两栏间距 18px。
*/
export default class TwoColumnDraw extends BaseDrawService {
private letterSizes: LetterFontSizes =
calcLetterFontSizes(FOUR_LINE_GRID_H);
async draw(data: TwoColumnData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawContent(data.leftRows, data.rightRows);
this.drawPrintFooter();
}
private drawContent(leftRows: TracingRow[], rightRows: TracingRow[]) {
const M = 24;
const lineW = this.canvasWidth - M * 2;
const colW = (lineW - COLUMN_GUTTER) / 2;
const maxR = Math.max(leftRows.length, rightRows.length);
const footerReserve = 56;
const availableH =
this.canvasHeight - this.currentY - footerReserve - 12;
const rowGap = Math.max(
4,
(availableH - maxR * FOUR_LINE_GRID_H) / (maxR + 1),
);
const y0 = this.currentY + rowGap;
const leftX = M;
const rightX = M + colW + COLUMN_GUTTER;
for (let i = 0; i < maxR; i++) {
const y = y0 + i * (FOUR_LINE_GRID_H + rowGap);
if (i < leftRows.length) {
drawFourLineGrid(this.ctx, leftX, y, colW, FOUR_LINE_GRID_H);
this.drawPairRow(leftRows[i], leftX, y, colW);
}
if (i < rightRows.length) {
drawFourLineGrid(this.ctx, rightX, y, colW, FOUR_LINE_GRID_H);
this.drawPairRow(rightRows[i], rightX, y, colW);
}
}
}
/**
* 在一个四线三格内绘制大小写配对(如 A a)。
* rowIndex=0 黑色示范,>0 浅红色。
* cells 里每个 cell 的 char 是大写、pairChar 是小写,
* 按 repetitions 排列(第一个实色 → 后续渐淡)。
*/
private drawPairRow(row: TracingRow, x: number, y: number, colW: number) {
const fontFamily = fontFamilyOf(this.letterSizes._profile);
const cells = row.cells;
const n = cells.length;
if (n === 0) return;
const pairCount = n;
const slotW = colW / pairCount;
const pairGap = 25;
for (let i = 0; i < pairCount; i++) {
const cell = cells[i];
const color = i === 0 ? COLOR_BLACK : COLOR_LIGHT_RED;
const alpha = i >= pairCount - 2 ? 0.5 : 1;
const pairCx = x + i * slotW + slotW / 2;
this.ctx.save();
this.ctx.globalAlpha = alpha;
drawLetterInFourLineGrid(
this.ctx,
cell.char,
pairCx - pairGap / 2,
y,
FOUR_LINE_GRID_H,
this.letterSizes,
{ fontFamily, color },
);
if (cell.pairChar) {
drawLetterInFourLineGrid(
this.ctx,
cell.pairChar,
pairCx + pairGap / 2,
y,
FOUR_LINE_GRID_H,
this.letterSizes,
{ fontFamily, color },
);
}
this.ctx.restore();
}
}
}
@@ -14,7 +14,10 @@ import {
type LetterFontSizes, type LetterFontSizes,
} from './drawTools'; } from './drawTools';
type OverviewData = Extract<LetterTracingData, { mode: 'overview' }>; type UpperLowerData = Extract<
LetterTracingData,
{ mode: 'letter-tracing-upper-lower' }
>;
/** 页面左右边距(margin,px),用于排版时留白 */ /** 页面左右边距(margin,px),用于排版时留白 */
const M = 24; const M = 24;
@@ -40,24 +43,24 @@ const TITLE_FACE = fontFamilyOf(TITLE_PROFILE);
* + 4 线7+7+6+6 * + 4 线7+7+6+6
* 7 6 6 * 7 6 6
*/ */
export default class OverviewDraw extends BaseDrawService { export default class UpperLowerDraw extends BaseDrawService {
private letterSizes: LetterFontSizes = calcLetterFontSizes( private letterSizes: LetterFontSizes = calcLetterFontSizes(
FOUR_LINE_GRID_H, FOUR_LINE_GRID_H,
DASHED_PROFILE, DASHED_PROFILE,
); );
async draw(data: OverviewData) { async draw(data: UpperLowerData) {
this.prepareDraw(); this.prepareDraw();
await Promise.allSettled([ await Promise.allSettled([
loadLetterFont(DASHED_PROFILE), loadLetterFont(DASHED_PROFILE),
loadLetterFont(TITLE_PROFILE), loadLetterFont(TITLE_PROFILE),
]); ]);
await this.drawHeaderAndDivider(); await this.drawHeaderAndDivider();
this.drawOverviewBody(data.upperRows, data.lowerRows); this.drawUpperLowerBody(data.upperRows, data.lowerRows);
await this.drawPrintFooter(); await this.drawPrintFooter();
} }
private drawOverviewBody( private drawUpperLowerBody(
upperRows: string[][], upperRows: string[][],
lowerRows: string[][], lowerRows: string[][],
): void { ): void {
@@ -3,17 +3,17 @@ import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../data/alphabet';
/** 字母描红的布局模式(与产品文档六类练习一致) */ /** 字母描红的布局模式(与产品文档六类练习一致) */
export type LetterTracingMode = export type LetterTracingMode =
/** 1 看图识字:教学区 + 描红行(前三行大写、后三行小写) */ /** 1 看图识字:教学区 + 描红行(前三行大写、后三行小写) */
| 'picture-tracing' | 'letter-tracing-single'
/** 2 字母总览:Uppercase / Lowercase 分区,每区四行 7+7+6+6 */ /** 2 字母总览:Uppercase / Lowercase 分区,每区四行 7+7+6+6 */
| 'overview' | 'letter-tracing-upper-lower'
/** 3 大小写配对:左栏 A–M、右栏 N–Z,每行最多 4 组 Aa */ /** 3 大小写配对:左栏 A–M、右栏 N–Z,每行最多 4 组 Aa */
| 'case-pairing' | 'letter-tracing-case-pairing'
/** 4 分栏对照:半组字母,左栏大写、右栏小写,每行 4 个 */ /** 4 分栏对照:半组字母,左栏大写、右栏小写,每行 4 个 */
| 'column-compare' | 'letter-tracing-two-column'
/** 5 三字母精练:每页聚焦 3 个字母,各含大写行 + 小写行 */ /** 5 三字母精练:每页聚焦 3 个字母,各含大写行 + 小写行 */
| 'triple-focus' | 'letter-tracing-triple'
/** 6 单字母逐行:每行一个字母,13 字母半表(A–M 或 N–Z) */ /** 6 单字母逐行:每行一个字母,13 字母半表(A–M 或 N–Z) */
| 'single-line'; | 'letter-tracing-single-line';
/** 字母大小写模式(部分模式不使用) */ /** 字母大小写模式(部分模式不使用) */
export type LetterCaseMode = 'upper' | 'lower' | 'both'; export type LetterCaseMode = 'upper' | 'lower' | 'both';
@@ -70,7 +70,7 @@ export interface TeachingBlock {
export type LetterTracingData = export type LetterTracingData =
| { | {
mode: 'picture-tracing'; mode: 'letter-tracing-single';
teaching: TeachingBlock; teaching: TeachingBlock;
highlightGrid: HighlightGrid; highlightGrid: HighlightGrid;
/** Trace It 区域使用的字母笔顺图路径(同 teaching.letterImgPath */ /** Trace It 区域使用的字母笔顺图路径(同 teaching.letterImgPath */
@@ -79,17 +79,17 @@ export type LetterTracingData =
tracingLineCount: number; tracingLineCount: number;
} }
| { | {
mode: 'case-pairing'; mode: 'letter-tracing-case-pairing';
leftVisualRows: TracingRow[][];
rightVisualRows: TracingRow[][];
}
| {
mode: 'column-compare';
leftRows: TracingRow[]; leftRows: TracingRow[];
rightRows: TracingRow[]; rightRows: TracingRow[];
} }
| { | {
mode: 'overview'; mode: 'letter-tracing-two-column';
leftRows: TracingRow[];
rightRows: TracingRow[];
}
| {
mode: 'letter-tracing-upper-lower';
upperRows: string[][]; upperRows: string[][];
lowerRows: string[][]; lowerRows: string[][];
}; };
@@ -212,7 +212,7 @@ export function generateLetterTracing(
): LetterTracingData { ): LetterTracingData {
const { mode, repetitions, fadePattern } = config; const { mode, repetitions, fadePattern } = config;
if (mode === 'picture-tracing') { if (mode === 'letter-tracing-single') {
const key = normalizeLetter(config.selectedLetter ?? 'A'); const key = normalizeLetter(config.selectedLetter ?? 'A');
const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A; const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A;
const upper = key; const upper = key;
@@ -237,7 +237,7 @@ export function generateLetterTracing(
const highlightGrid = buildHighlightGrid(upper, 3, 6); const highlightGrid = buildHighlightGrid(upper, 3, 6);
return { return {
mode: 'picture-tracing', mode: 'letter-tracing-single',
teaching, teaching,
highlightGrid, highlightGrid,
traceImgPath: letterImgPath, traceImgPath: letterImgPath,
@@ -245,23 +245,7 @@ export function generateLetterTracing(
}; };
} }
if (mode === 'case-pairing') { if (mode === 'letter-tracing-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 = const half =
config.alphabetHalf === 'N-Z' config.alphabetHalf === 'N-Z'
? LETTERS_UPPER.slice(13) ? LETTERS_UPPER.slice(13)
@@ -272,12 +256,26 @@ export function generateLetterTracing(
const rightRows = half.map((L) => const rightRows = half.map((L) =>
rowForChar(L.toLowerCase(), repetitions, fadePattern), rowForChar(L.toLowerCase(), repetitions, fadePattern),
); );
return { mode: 'column-compare', leftRows, rightRows }; return { mode: 'letter-tracing-case-pairing', leftRows, rightRows };
} }
// overview 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),
};
}
// letter-tracing-upper-lower
return { return {
mode: 'overview', mode: 'letter-tracing-upper-lower',
upperRows: overviewRowsFrom([...LETTERS_UPPER]), upperRows: overviewRowsFrom([...LETTERS_UPPER]),
lowerRows: overviewRowsFrom(LETTERS_UPPER.map((L) => L.toLowerCase())), lowerRows: overviewRowsFrom(LETTERS_UPPER.map((L) => L.toLowerCase())),
}; };
@@ -291,7 +289,7 @@ export function mergeLetterTracingConfig(
return { return {
mode: (overrides.mode ?? mode: (overrides.mode ??
base.mode ?? base.mode ??
'picture-tracing') as LetterTracingMode, 'letter-tracing-single') as LetterTracingMode,
letterCase: (overrides.letterCase ?? letterCase: (overrides.letterCase ??
base.letterCase ?? base.letterCase ??
'upper') as LetterCaseMode, 'upper') as LetterCaseMode,
+4 -4
View File
@@ -743,9 +743,9 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
ageBand: '4-7岁', ageBand: '4-7岁',
difficulty: '入门', difficulty: '入门',
icon: '🔤', icon: '🔤',
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-full', path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column',
available: true, available: true,
source: 'letter-tracing-full', source: 'letter-tracing-two-column',
}, },
{ {
id: 'current-english-3', id: 'current-english-3',
@@ -767,9 +767,9 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
ageBand: '4-7岁', ageBand: '4-7岁',
difficulty: '入门', difficulty: '入门',
icon: '✏️', icon: '✏️',
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column', path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-case-pairing',
available: true, available: true,
source: 'letter-tracing-two-column', source: 'letter-tracing-case-pairing',
}, },
], ],
}, },
+61 -61
View File
@@ -1,64 +1,64 @@
{ {
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html", "description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "doodle-mini", "projectname": "doodle-mini",
"setting": { "setting": {
"compileHotReLoad": true, "compileHotReLoad": true,
"urlCheck": true, "urlCheck": true,
"coverView": false, "coverView": false,
"lazyloadPlaceholderEnable": false, "lazyloadPlaceholderEnable": false,
"skylineRenderEnable": true, "skylineRenderEnable": true,
"preloadBackgroundData": false, "preloadBackgroundData": false,
"autoAudits": false, "autoAudits": false,
"useApiHook": true, "useApiHook": true,
"useApiHostProcess": true, "useApiHostProcess": true,
"showShadowRootInWxmlPanel": false, "showShadowRootInWxmlPanel": false,
"useStaticServer": false, "useStaticServer": false,
"useLanDebug": false, "useLanDebug": false,
"showES6CompileOption": false, "showES6CompileOption": false,
"checkInvalidKey": true, "checkInvalidKey": true,
"ignoreDevUnusedFiles": true, "ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false "bigPackageSizeSupport": false
}, },
"libVersion": "3.7.12", "libVersion": "3.7.12",
"condition": { "condition": {
"miniprogram": { "miniprogram": {
"list": [ "list": [
{ {
"name": "englishPages/letterTracing/letterTracing", "name": "englishPages/letterTracing/letterTracing",
"pathName": "englishPages/letterTracing/letterTracing", "pathName": "englishPages/letterTracing/letterTracing",
"query": "id=letter-tracing-upper-lower", "query": "id=letter-tracing-upper-lower",
"scene": null, "scene": null,
"launchMode": "default" "launchMode": "default"
}, },
{ {
"name": "englishPages/letterTracing/letterTracing", "name": "englishPages/letterTracing/letterTracing",
"pathName": "englishPages/letterTracing/letterTracing", "pathName": "englishPages/letterTracing/letterTracing",
"query": "id=letter-tracing-full", "query": "id=letter-tracing-two-column",
"launchMode": "default", "launchMode": "default",
"scene": null "scene": null
}, },
{ {
"name": "englishPages/letterTracing/letterTracing", "name": "englishPages/letterTracing/letterTracing",
"pathName": "englishPages/letterTracing/letterTracing", "pathName": "englishPages/letterTracing/letterTracing",
"query": "id=letter-tracing-single", "query": "id=letter-tracing-single",
"launchMode": "default", "launchMode": "default",
"scene": null "scene": null
}, },
{ {
"name": "pages/index/index", "name": "pages/index/index",
"pathName": "pages/index/index", "pathName": "pages/index/index",
"query": "", "query": "",
"launchMode": "default", "launchMode": "default",
"scene": null "scene": null
}, },
{ {
"name": "englishPages/letterTracing/letterTracing", "name": "englishPages/letterTracing/letterTracing",
"pathName": "englishPages/letterTracing/letterTracing", "pathName": "englishPages/letterTracing/letterTracing",
"query": "id=letter-tracing-single", "query": "id=letter-tracing-single",
"launchMode": "default", "launchMode": "default",
"scene": null "scene": null
}
]
} }
]
} }
} }
}