feat: 完成四种字母描红开发
This commit is contained in:
+8
-19
@@ -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,15 +25,13 @@
|
|||||||
|
|
||||||
从产品视角是四种题型;从引擎视角可收敛为**同一排版模板 + 同一数据生成器**,通过 `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` 参数驱动四种版面,无需四种独立页面类型(除非产品坚持拆入口卡片)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -45,7 +43,7 @@
|
|||||||
- **上部(约 40%~45% 内容高)**:教学区
|
- **上部(约 40%~45% 内容高)**:教学区
|
||||||
- 配图(与字母绑定的词汇插图,内置 26 组映射 + 资源 key / CDN URL)
|
- 配图(与字母绑定的词汇插图,内置 26 组映射 + 资源 key / CDN URL)
|
||||||
- 大写 + 小写展示
|
- 大写 + 小写展示
|
||||||
- 阅读句:`*X* is for *Word`*
|
- 阅读句:`*X* is for *Word`\*
|
||||||
- 书写顺序示意(可用简化箭头/步骤编号,首版可弱化)
|
- 书写顺序示意(可用简化箭头/步骤编号,首版可弱化)
|
||||||
- **下部(约 50%)**:描红区
|
- **下部(约 50%)**:描红区
|
||||||
- 至少两行四线三格:一行大写、一行小写(或按配置多行)
|
- 至少两行四线三格:一行大写、一行小写(或按配置多行)
|
||||||
@@ -174,16 +172,14 @@
|
|||||||
|
|
||||||
### 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 入口策略
|
||||||
@@ -244,14 +240,12 @@ interface LetterTracingGeneratorConfig {
|
|||||||
|
|
||||||
### 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`:布局分区、渐淡格思路可对齐。
|
||||||
@@ -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,9 +348,8 @@ 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` 或云端配置 |
|
||||||
@@ -364,14 +357,10 @@ interface LetterTracingGeneratorConfig {
|
|||||||
| `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 | 初稿入库 |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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: '左 A–M、右 N–Z,每组包含大小写',
|
||||||
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、右 N–Z,大小写配对描红',
|
||||||
},
|
},
|
||||||
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: '左 A–M、右 N–Z,每行四组大小写',
|
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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: '左 A–M、右 N–Z 配对描红',
|
desc: '半组字母左大写右小写',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'letter-tracing-full',
|
id: 'letter-tracing-two-column',
|
||||||
label: '分栏对照',
|
label: '两列练习',
|
||||||
desc: '半组字母左大写右小写',
|
desc: '左 A–M、右 N–Z 配对描红',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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 maxVisual = Math.max(
|
|
||||||
leftVisualRows.length,
|
|
||||||
rightVisualRows.length,
|
|
||||||
);
|
|
||||||
const rowSlotH = Math.max(40, (bottom - y0) / maxVisual - 6);
|
|
||||||
|
|
||||||
const drawSide = (
|
const footerReserve = 56;
|
||||||
blocks: TracingRow[][],
|
const availableH =
|
||||||
startX: number,
|
this.canvasHeight - this.currentY - footerReserve - 12;
|
||||||
width: number,
|
const rowGap = Math.max(
|
||||||
) => {
|
4,
|
||||||
let y = y0;
|
(availableH - maxR * FOUR_LINE_GRID_H) / (maxR + 1),
|
||||||
for (const visualRow of blocks) {
|
);
|
||||||
const nSeg = visualRow.length;
|
|
||||||
const segGap = 6;
|
const y0 = this.currentY + rowGap;
|
||||||
const segW = (width - (nSeg - 1) * segGap) / nSeg;
|
|
||||||
for (let s = 0; s < nSeg; s++) {
|
const columnGutter = Math.max(
|
||||||
this.drawTracingRow(
|
COLUMN_GUTTER_MIN,
|
||||||
visualRow[s],
|
Math.round(lineW * COLUMN_GUTTER_RATIO),
|
||||||
startX + s * (segW + segGap),
|
);
|
||||||
|
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,
|
y,
|
||||||
rowSlotH,
|
halfContentW,
|
||||||
segW,
|
INTRA_COLUMN_LETTER_BAND_RATIO,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (i < rightRows.length) {
|
||||||
|
this.drawLettersInHalf(
|
||||||
|
rightRows[i],
|
||||||
|
rightX,
|
||||||
|
y,
|
||||||
|
halfContentW,
|
||||||
|
INTRA_COLUMN_LETTER_BAND_RATIO,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
y += rowSlotH + 8;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
drawSide(leftVisualRows, M, colW);
|
|
||||||
drawSide(rightVisualRows, M + colW + gapCol, colW);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
+9
-6
@@ -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)、右栏 N–Z(Nn 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-5
@@ -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 {
|
return {
|
||||||
mode: 'overview',
|
mode: 'letter-tracing-two-column',
|
||||||
|
leftRows: toRows(leftLetters),
|
||||||
|
rightRows: toRows(rightLetters),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// letter-tracing-upper-lower
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
{
|
{
|
||||||
"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
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user