feat: 开发英语启蒙的入口页
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import type {
|
||||
LetterTracingData,
|
||||
TracingRow,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
import { fontFamilyOf } from '../../shared/data/fontProfiles';
|
||||
import {
|
||||
calcLetterFontSizes,
|
||||
drawFourLineGrid,
|
||||
drawLetterInFourLineGrid,
|
||||
FOUR_LINE_GRID_H,
|
||||
type LetterFontSizes,
|
||||
} from '../../shared/draw/drawTools';
|
||||
const COLOR_BLACK = '#1a1a1a';
|
||||
const COLOR_LIGHT_RED = '#FF8E8E';
|
||||
|
||||
/** 左右大写/小写两栏之间的间距(相对行宽 + 下限,避免过窄屏过小) */
|
||||
const COLUMN_GUTTER_RATIO = 0.02;
|
||||
const COLUMN_GUTTER_MIN = 14;
|
||||
|
||||
/**
|
||||
* 单栏内字母排版区域占栏宽比例(<1 则同栏字母更紧凑、字间距更小)
|
||||
*/
|
||||
const INTRA_COLUMN_LETTER_BAND_RATIO = 1;
|
||||
|
||||
type CasePairingData = Extract<
|
||||
LetterTracingData,
|
||||
{ mode: 'letter-tracing-case-pairing' }
|
||||
>;
|
||||
|
||||
/**
|
||||
* 分栏对照绘制:
|
||||
* 四线三格横跨整页宽度,字母分左右两侧书写——
|
||||
* 左栏大写、右栏小写(各 repetitions 个),中间留间距;同栏内字母略收紧。
|
||||
* 每侧第一个字母黑色,后续浅红色。
|
||||
*/
|
||||
export default class CasePairingDraw extends BaseDrawService {
|
||||
private letterSizes: LetterFontSizes =
|
||||
calcLetterFontSizes(FOUR_LINE_GRID_H);
|
||||
|
||||
async draw(data: CasePairingData) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import type { LetterTracingData } from '../generators/letter-tracing-generator';
|
||||
import singleLetterDraw from './singleLetterDraw';
|
||||
import CasePairingDraw from './casePairingDraw';
|
||||
import TwoColumnDraw from './twoColumnDraw';
|
||||
import UpperLowerDraw from './upperLowerDraw';
|
||||
import SingleLineDraw from './singleLineDraw';
|
||||
import TripleDraw from './tripleDraw';
|
||||
|
||||
/**
|
||||
* 字母描红绘制分发层。
|
||||
* 根据 data.mode 委托给对应的独立 DrawService 完成实际绘制。
|
||||
*/
|
||||
export default class LetterTracingDraw extends BaseDrawService {
|
||||
async draw(data: LetterTracingData) {
|
||||
const DrawClass = {
|
||||
'letter-tracing-single': singleLetterDraw,
|
||||
'letter-tracing-upper-lower': UpperLowerDraw,
|
||||
'letter-tracing-case-pairing': CasePairingDraw,
|
||||
'letter-tracing-two-column': TwoColumnDraw,
|
||||
'letter-tracing-half': SingleLineDraw,
|
||||
'letter-tracing-three': TripleDraw,
|
||||
}[data.mode];
|
||||
|
||||
const delegate = new DrawClass(this.canvas, this.ctx, this.options);
|
||||
await delegate.draw(data as any);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import { getImage } from '../../../utils/index';
|
||||
import type {
|
||||
LetterTracingData,
|
||||
HighlightGrid,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
import { fontFamilyOf } from '../../shared/data/fontProfiles';
|
||||
import {
|
||||
calcLetterFontSizes,
|
||||
drawFourLineGrid,
|
||||
drawLetterInFourLineGrid,
|
||||
FOUR_LINE_GRID_H,
|
||||
getLetterFontSize,
|
||||
type LetterFontSizes,
|
||||
} from '../../shared/draw/drawTools';
|
||||
|
||||
const LINE_INK_ALPHA = 'rgba(50, 46, 37, 1)';
|
||||
const ACCENT_RED = 'rgba(252, 46, 0, 1)';
|
||||
const TITLE_TEXT_FONT = '14px sans-serif';
|
||||
const TITLE_ICON_GAP = 4;
|
||||
|
||||
type SingleLetterData = Extract<
|
||||
LetterTracingData,
|
||||
{ mode: 'letter-tracing-single' }
|
||||
>;
|
||||
|
||||
export default class SingleLetterDraw extends BaseDrawService {
|
||||
private letterSizes: LetterFontSizes =
|
||||
calcLetterFontSizes(FOUR_LINE_GRID_H);
|
||||
private get letterFace(): string {
|
||||
return fontFamilyOf(this.letterSizes._profile);
|
||||
}
|
||||
|
||||
async draw(data: SingleLetterData) {
|
||||
this.prepareDraw();
|
||||
await this.drawHeaderAndDivider();
|
||||
await this.drawContent(data);
|
||||
this.drawPrintFooter();
|
||||
}
|
||||
|
||||
private async drawContent(data: SingleLetterData) {
|
||||
const { teaching, highlightGrid, traceImgPath } = data;
|
||||
const ctx = this.ctx;
|
||||
|
||||
// ── 上区:left:78 top:120 w:451 h:180 ──
|
||||
const TOP_X = 78;
|
||||
const TOP_Y = 120;
|
||||
|
||||
const letterImgX = TOP_X + 15;
|
||||
const letterImgY = TOP_Y + 9;
|
||||
const letterImgW = 180;
|
||||
const letterImgH = 103.5;
|
||||
|
||||
try {
|
||||
const letterImg = (await getImage(
|
||||
this.canvas,
|
||||
teaching.letterImgPath,
|
||||
)) as WechatMiniprogram.Image;
|
||||
const aspect = (letterImg.width || 1) / (letterImg.height || 1);
|
||||
let dw = letterImgW;
|
||||
let dh = dw / aspect;
|
||||
if (dh > letterImgH) {
|
||||
dh = letterImgH;
|
||||
dw = dh * aspect;
|
||||
}
|
||||
ctx.drawImage(
|
||||
letterImg,
|
||||
letterImgX + (letterImgW - dw) / 2,
|
||||
letterImgY + (letterImgH - dh) / 2,
|
||||
dw,
|
||||
dh,
|
||||
);
|
||||
} catch {
|
||||
ctx.font = `bold 60px ${this.letterFace}`;
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
`${teaching.upper} ${teaching.lower}`,
|
||||
letterImgX + letterImgW / 2,
|
||||
letterImgY + letterImgH / 2,
|
||||
);
|
||||
}
|
||||
|
||||
this.drawSingleLetterSentence(
|
||||
teaching.sentence,
|
||||
teaching.upper,
|
||||
TOP_X,
|
||||
TOP_Y + 9 + 120,
|
||||
);
|
||||
|
||||
// 右侧配图
|
||||
const appleX = TOP_X + 271;
|
||||
const appleY = TOP_Y;
|
||||
const appleW = 180;
|
||||
const appleH = 180;
|
||||
|
||||
try {
|
||||
const wordImg = (await getImage(
|
||||
this.canvas,
|
||||
teaching.wordImgPath,
|
||||
)) as WechatMiniprogram.Image;
|
||||
const aspect = (wordImg.width || 1) / (wordImg.height || 1);
|
||||
let dw = appleW;
|
||||
let dh = dw / aspect;
|
||||
if (dh > appleH) {
|
||||
dh = appleH;
|
||||
dw = dh * aspect;
|
||||
}
|
||||
ctx.drawImage(
|
||||
wordImg,
|
||||
appleX + (appleW - dw) / 2,
|
||||
appleY + (appleH - dh) / 2,
|
||||
dw,
|
||||
dh,
|
||||
);
|
||||
} catch {
|
||||
ctx.font = `${Math.min(80, appleH * 0.5)}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
teaching.emoji,
|
||||
appleX + appleW / 2,
|
||||
appleY + appleH / 2,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 中区 ──
|
||||
const HL_X = 26,
|
||||
HL_Y = 320,
|
||||
HL_W = 272,
|
||||
HL_H = 181;
|
||||
const TR_X = 298,
|
||||
TR_Y = 320,
|
||||
TR_W = 272,
|
||||
TR_H = 181;
|
||||
|
||||
ctx.strokeStyle = LINE_INK_ALPHA;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
ctx.strokeRect(HL_X, HL_Y, HL_W, HL_H);
|
||||
ctx.strokeRect(TR_X, TR_Y, TR_W, TR_H);
|
||||
|
||||
this.drawHighlightSection(highlightGrid, HL_X, HL_Y);
|
||||
await this.drawTraceItSection(
|
||||
traceImgPath,
|
||||
teaching.upper,
|
||||
teaching.lower,
|
||||
TR_X,
|
||||
TR_Y,
|
||||
);
|
||||
|
||||
// ── 下区:4 行四线三格 + 字母临摹(倒置直角三角形)──
|
||||
// 每行:left:24 w:547 h:43,top 分别 524.5 / 590.5 / 656.5 / 722.5
|
||||
const lineX = 24;
|
||||
const lineW = 547;
|
||||
const lineTops = [524.5, 590.5, 656.5, 722.5];
|
||||
|
||||
// 8 → 6 → 3 → 1 组(每组为 Upper+Lower),左对齐,形成倒置直角三角形
|
||||
const groupsPerLine = [8, 6, 3, 1];
|
||||
const slotW = lineW / groupsPerLine[0]; // 以第一行的 8 组为基准
|
||||
|
||||
for (let r = 0; r < lineTops.length; r++) {
|
||||
const y = lineTops[r];
|
||||
drawFourLineGrid(ctx, lineX, y, lineW, FOUR_LINE_GRID_H);
|
||||
|
||||
const n = groupsPerLine[r] ?? 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const alpha = n <= 1 ? 1 : 1 - (i / (n - 1)) * 0.7;
|
||||
this.drawUpperLowerPairInFourLines(
|
||||
teaching.upper,
|
||||
teaching.lower,
|
||||
lineX + i * slotW,
|
||||
y,
|
||||
slotW,
|
||||
alpha,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在四线三格中绘制 Upper+Lower 对(共享基线,间距远小于组间距)
|
||||
*/
|
||||
private drawUpperLowerPairInFourLines(
|
||||
upper: string,
|
||||
lower: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
alpha: number,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
const sizes = this.letterSizes;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
|
||||
|
||||
const pairGap = 3;
|
||||
|
||||
const upperFs = getLetterFontSize(upper, sizes);
|
||||
ctx.font = `${upperFs}px ${this.letterFace}`;
|
||||
const upperW = ctx.measureText(upper).width;
|
||||
|
||||
const lowerFs = getLetterFontSize(lower, sizes);
|
||||
ctx.font = `${lowerFs}px ${this.letterFace}`;
|
||||
const lowerW = ctx.measureText(lower).width;
|
||||
|
||||
const totalPairW = upperW + pairGap + lowerW;
|
||||
const pairStartX = x + (w - totalPairW) / 2;
|
||||
const color = 'rgba(26, 26, 26, 1)';
|
||||
|
||||
drawLetterInFourLineGrid(
|
||||
ctx,
|
||||
upper,
|
||||
pairStartX + upperW / 2,
|
||||
y,
|
||||
FOUR_LINE_GRID_H,
|
||||
sizes,
|
||||
{ fontFamily: this.letterFace, color },
|
||||
);
|
||||
drawLetterInFourLineGrid(
|
||||
ctx,
|
||||
lower,
|
||||
pairStartX + upperW + pairGap + lowerW / 2,
|
||||
y,
|
||||
FOUR_LINE_GRID_H,
|
||||
sizes,
|
||||
{ fontFamily: this.letterFace, color },
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawSingleLetterSentence(
|
||||
sentence: string,
|
||||
letter: string,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
ctx.font = `36px ${this.letterFace}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
let cx = x;
|
||||
for (let i = 0; i < sentence.length; i++) {
|
||||
const ch = sentence[i];
|
||||
ctx.fillStyle =
|
||||
ch.toUpperCase() === letter ? ACCENT_RED : 'rgba(0, 0, 0, 1)';
|
||||
ctx.fillText(ch, cx, y);
|
||||
cx += ctx.measureText(ch).width;
|
||||
}
|
||||
}
|
||||
|
||||
private drawHighlightSection(grid: HighlightGrid, sx: number, sy: number) {
|
||||
const ctx = this.ctx;
|
||||
const titleX = sx + 16;
|
||||
const titleY = sy + 12;
|
||||
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const hlIcon = '\u{1F50D}';
|
||||
ctx.font =
|
||||
"16px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif";
|
||||
ctx.fillText(hlIcon, titleX, titleY + 1);
|
||||
const hlIconW = ctx.measureText(hlIcon).width;
|
||||
|
||||
ctx.font = TITLE_TEXT_FONT;
|
||||
ctx.fillText(
|
||||
'Highlight it !',
|
||||
titleX + hlIconW + TITLE_ICON_GAP,
|
||||
titleY,
|
||||
);
|
||||
|
||||
const gridX = sx + 16;
|
||||
const gridY = sy + 45;
|
||||
const cellSize = 40;
|
||||
const cols = grid.rows[0]?.length ?? 6;
|
||||
const rows = grid.rows.length;
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const cx = gridX + c * cellSize;
|
||||
const cy = gridY + r * cellSize;
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 1)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
ctx.strokeRect(cx, cy, cellSize, cellSize);
|
||||
|
||||
ctx.font = `18px ${this.letterFace}`;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
grid.rows[r][c],
|
||||
cx + cellSize / 2,
|
||||
cy + cellSize / 2 + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async drawTraceItSection(
|
||||
traceImgPath: string,
|
||||
upper: string,
|
||||
lower: string,
|
||||
sx: number,
|
||||
sy: number,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
const titleX = sx + 25;
|
||||
const titleY = sy + 12;
|
||||
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const traceIcon = '\u{1F58A}\u{FE0F}';
|
||||
ctx.font =
|
||||
"16px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif";
|
||||
ctx.fillText(traceIcon, titleX, titleY);
|
||||
const traceIconW = ctx.measureText(traceIcon).width;
|
||||
|
||||
ctx.font = TITLE_TEXT_FONT;
|
||||
ctx.fillText(
|
||||
'Trace It !',
|
||||
titleX + traceIconW + TITLE_ICON_GAP,
|
||||
titleY,
|
||||
);
|
||||
|
||||
// 四线三格(设计稿绝对坐标)
|
||||
const lx = 316,
|
||||
lw = 236;
|
||||
const lines = [364.5, 404.5, 444.5, 484.5];
|
||||
|
||||
ctx.strokeStyle = LINE_INK_ALPHA;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx, lines[0]);
|
||||
ctx.lineTo(lx + lw, lines[0]);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([4, 4]);
|
||||
for (const ly of [lines[1], lines[2]]) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx, ly);
|
||||
ctx.lineTo(lx + lw, ly);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx, lines[3]);
|
||||
ctx.lineTo(lx + lw, lines[3]);
|
||||
ctx.stroke();
|
||||
|
||||
// 笔顺图
|
||||
const imgX = 337,
|
||||
imgY = 365,
|
||||
imgW = 159,
|
||||
imgH = 80;
|
||||
try {
|
||||
const img = (await getImage(
|
||||
this.canvas,
|
||||
traceImgPath,
|
||||
)) as WechatMiniprogram.Image;
|
||||
const aspect = (img.width || 1) / (img.height || 1);
|
||||
let dw = imgW;
|
||||
let dh = dw / aspect;
|
||||
if (dh > imgH) {
|
||||
dh = imgH;
|
||||
dw = dh * aspect;
|
||||
}
|
||||
ctx.drawImage(
|
||||
img,
|
||||
imgX + (imgW - dw) / 2,
|
||||
imgY + (imgH - dh) / 2,
|
||||
dw,
|
||||
dh,
|
||||
);
|
||||
} catch {
|
||||
const fs = Math.min(imgH * 0.6, imgW * 0.18);
|
||||
ctx.font = `bold ${fs}px ${this.letterFace}`;
|
||||
ctx.fillStyle = '#222';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(`${upper} ${lower}`, imgX + imgW / 2, imgY + imgH / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import type {
|
||||
LetterTracingData,
|
||||
SingleLineRow,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
import {
|
||||
fontFamilyOf,
|
||||
PRINT_CLEARLY_DASHED,
|
||||
} from '../../shared/data/fontProfiles';
|
||||
import {
|
||||
calcLetterFontSizes,
|
||||
drawFourLineGrid,
|
||||
drawLetterInFourLineGrid,
|
||||
FOUR_LINE_GRID_H,
|
||||
loadLetterFont,
|
||||
type LetterFontSizes,
|
||||
} from '../../shared/draw/drawTools';
|
||||
|
||||
const SLOTS_PER_ROW = 6;
|
||||
const PAIR_GAP = 25;
|
||||
|
||||
type SingleLineData = Extract<
|
||||
LetterTracingData,
|
||||
{ mode: 'letter-tracing-half' }
|
||||
>;
|
||||
|
||||
/**
|
||||
* 单字母逐行绘制:
|
||||
* 13 行四线三格横跨整页宽度,每行划分为 6 组位置。
|
||||
* 前 4 组展示大小写字母(Aa),后 2 组空白供用户书写。
|
||||
* 颜色递进:黑色 → 浅红 → 半透明浅红 → 虚线字体+半透明浅红。
|
||||
*/
|
||||
export default class SingleLineDraw extends BaseDrawService {
|
||||
private letterSizes: LetterFontSizes =
|
||||
calcLetterFontSizes(FOUR_LINE_GRID_H);
|
||||
|
||||
private dashedSizes: LetterFontSizes = calcLetterFontSizes(
|
||||
FOUR_LINE_GRID_H,
|
||||
PRINT_CLEARLY_DASHED,
|
||||
);
|
||||
|
||||
async draw(data: SingleLineData) {
|
||||
this.prepareDraw();
|
||||
await Promise.all([
|
||||
this.drawHeaderAndDivider(),
|
||||
loadLetterFont(PRINT_CLEARLY_DASHED),
|
||||
]);
|
||||
this.drawContent(data.rows);
|
||||
this.drawPrintFooter();
|
||||
}
|
||||
|
||||
private drawContent(rows: SingleLineRow[]) {
|
||||
const M = 24;
|
||||
const lineW = this.canvasWidth - M * 2;
|
||||
const rowCount = rows.length;
|
||||
|
||||
const footerReserve = 56;
|
||||
const availableH =
|
||||
this.canvasHeight - this.currentY - footerReserve - 12;
|
||||
const rowGap = Math.max(
|
||||
4,
|
||||
(availableH - rowCount * FOUR_LINE_GRID_H) / (rowCount + 1),
|
||||
);
|
||||
const y0 = this.currentY + rowGap;
|
||||
|
||||
const slotW = lineW / SLOTS_PER_ROW;
|
||||
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const y = y0 + i * (FOUR_LINE_GRID_H + rowGap);
|
||||
|
||||
drawFourLineGrid(this.ctx, M, y, lineW, FOUR_LINE_GRID_H);
|
||||
|
||||
const row = rows[i];
|
||||
for (let j = 0; j < row.cells.length; j++) {
|
||||
const cell = row.cells[j];
|
||||
if (!cell) continue;
|
||||
|
||||
const pairCx = M + j * slotW + slotW / 2;
|
||||
const isDashed = cell.style === 'dashed';
|
||||
const sizes = isDashed ? this.dashedSizes : this.letterSizes;
|
||||
const fontFamily = fontFamilyOf(
|
||||
isDashed ? PRINT_CLEARLY_DASHED : sizes._profile,
|
||||
);
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.globalAlpha = cell.alpha;
|
||||
|
||||
drawLetterInFourLineGrid(
|
||||
this.ctx,
|
||||
cell.upper,
|
||||
pairCx - PAIR_GAP / 2,
|
||||
y,
|
||||
FOUR_LINE_GRID_H,
|
||||
sizes,
|
||||
{ fontFamily, color: cell.color },
|
||||
);
|
||||
|
||||
drawLetterInFourLineGrid(
|
||||
this.ctx,
|
||||
cell.lower,
|
||||
pairCx + PAIR_GAP / 2,
|
||||
y,
|
||||
FOUR_LINE_GRID_H,
|
||||
sizes,
|
||||
{ fontFamily, color: cell.color },
|
||||
);
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import type {
|
||||
LetterTracingData,
|
||||
TripleSection,
|
||||
SingleLineRow,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
import {
|
||||
fontFamilyOf,
|
||||
PRINT_CLEARLY_DASHED,
|
||||
} from '../../shared/data/fontProfiles';
|
||||
import {
|
||||
calcLetterFontSizes,
|
||||
drawFourLineGrid,
|
||||
drawLetterInFourLineGrid,
|
||||
FOUR_LINE_GRID_H,
|
||||
loadLetterFont,
|
||||
type LetterFontSizes,
|
||||
} from '../../shared/draw/drawTools';
|
||||
|
||||
// 每行可容纳的格子数(用于三字母精练模板,一行展示 6 个位置)
|
||||
const SLOTS_PER_ROW = 6;
|
||||
// 大小写字母配对间的间隔(px,字母间距)
|
||||
const PAIR_GAP = 25;
|
||||
// 同一字母区域内相邻两行四线三格之间的间距(px)
|
||||
const TRIPLE_ROW_GAP = 10;
|
||||
// 相邻两个字母区域之间的间距(px),大于 TRIPLE_ROW_GAP
|
||||
const TRIPLE_SECTION_GAP = 20;
|
||||
|
||||
type TripleData = Extract<LetterTracingData, { mode: 'letter-tracing-three' }>;
|
||||
|
||||
/**
|
||||
* 三字母精练绘制:
|
||||
* 3 个字母区域,每区域 4 行四线三格。
|
||||
* 前两行展示完整颜色递进(黑→浅红→半透明浅红→虚线),
|
||||
* 后两行只展示黑色+浅红色两组,其余空白供用户书写。
|
||||
* 区域之间间距大于行间距。
|
||||
*/
|
||||
export default class TripleDraw extends BaseDrawService {
|
||||
private letterSizes: LetterFontSizes =
|
||||
calcLetterFontSizes(FOUR_LINE_GRID_H);
|
||||
|
||||
private dashedSizes: LetterFontSizes = calcLetterFontSizes(
|
||||
FOUR_LINE_GRID_H,
|
||||
PRINT_CLEARLY_DASHED,
|
||||
);
|
||||
|
||||
async draw(data: TripleData) {
|
||||
this.prepareDraw();
|
||||
await Promise.all([
|
||||
this.drawHeaderAndDivider(),
|
||||
loadLetterFont(PRINT_CLEARLY_DASHED),
|
||||
]);
|
||||
this.drawContent(data.sections);
|
||||
this.drawPrintFooter();
|
||||
}
|
||||
|
||||
private drawContent(sections: TripleSection[]) {
|
||||
const M = 24;
|
||||
const lineW = this.canvasWidth - M * 2;
|
||||
const slotW = lineW / SLOTS_PER_ROW;
|
||||
|
||||
let y = this.currentY + 10;
|
||||
|
||||
for (let s = 0; s < sections.length; s++) {
|
||||
const section = sections[s];
|
||||
|
||||
for (let r = 0; r < section.rows.length; r++) {
|
||||
drawFourLineGrid(this.ctx, M, y, lineW, FOUR_LINE_GRID_H);
|
||||
this.drawRow(section.rows[r], M, y, slotW);
|
||||
y += FOUR_LINE_GRID_H;
|
||||
if (r < section.rows.length - 1) {
|
||||
y += TRIPLE_ROW_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
if (s < sections.length - 1) {
|
||||
y += TRIPLE_SECTION_GAP;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawRow(
|
||||
row: SingleLineRow,
|
||||
marginX: number,
|
||||
y: number,
|
||||
slotW: number,
|
||||
) {
|
||||
for (let j = 0; j < row.cells.length; j++) {
|
||||
const cell = row.cells[j];
|
||||
if (!cell) continue;
|
||||
|
||||
const pairCx = marginX + j * slotW + slotW / 2;
|
||||
const isDashed = cell.style === 'dashed';
|
||||
const sizes = isDashed ? this.dashedSizes : this.letterSizes;
|
||||
const fontFamily = fontFamilyOf(
|
||||
isDashed ? PRINT_CLEARLY_DASHED : sizes._profile,
|
||||
);
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.globalAlpha = cell.alpha;
|
||||
|
||||
drawLetterInFourLineGrid(
|
||||
this.ctx,
|
||||
cell.upper,
|
||||
pairCx - PAIR_GAP / 2,
|
||||
y,
|
||||
FOUR_LINE_GRID_H,
|
||||
sizes,
|
||||
{ fontFamily, color: cell.color },
|
||||
);
|
||||
|
||||
drawLetterInFourLineGrid(
|
||||
this.ctx,
|
||||
cell.lower,
|
||||
pairCx + PAIR_GAP / 2,
|
||||
y,
|
||||
FOUR_LINE_GRID_H,
|
||||
sizes,
|
||||
{ fontFamily, color: cell.color },
|
||||
);
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import type {
|
||||
LetterTracingData,
|
||||
TracingRow,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
import { fontFamilyOf } from '../../shared/data/fontProfiles';
|
||||
import {
|
||||
calcLetterFontSizes,
|
||||
drawFourLineGrid,
|
||||
drawLetterInFourLineGrid,
|
||||
FOUR_LINE_GRID_H,
|
||||
type LetterFontSizes,
|
||||
} from '../../shared/draw/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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import type { LetterTracingData } from '../generators/letter-tracing-generator';
|
||||
import {
|
||||
PRINT_CLEARLY_BOLD,
|
||||
PRINT_CLEARLY_DASHED,
|
||||
fontFamilyOf,
|
||||
} from '../../shared/data/fontProfiles';
|
||||
import {
|
||||
calcLetterFontSizes,
|
||||
drawFourLineGrid,
|
||||
drawLetterInFourLineGrid,
|
||||
FOUR_LINE_GRID_H,
|
||||
loadLetterFont,
|
||||
type LetterFontSizes,
|
||||
} from '../../shared/draw/drawTools';
|
||||
|
||||
type UpperLowerData = Extract<
|
||||
LetterTracingData,
|
||||
{ mode: 'letter-tracing-upper-lower' }
|
||||
>;
|
||||
|
||||
/** 页面左右边距(margin,px),用于排版时留白 */
|
||||
const M = 24;
|
||||
/** 上下两大区(Uppercase / lowercase)之间的间距 */
|
||||
const SECTION_GAP = 25;
|
||||
/** 标题字号 */
|
||||
const TITLE_SIZE = 28;
|
||||
/** 标题与第一行四线三格的间距 */
|
||||
const TITLE_GAP = 25;
|
||||
/** 同一区域内相邻四线三格行之间的间距 */
|
||||
const ROW_GAP = 24;
|
||||
const TITLE_COLOR = '#000';
|
||||
|
||||
/** 浅红色描红(不透明 hex,避免打印时半透明发灰) */
|
||||
const TRACE_LIGHT_RED = '#FF8E8E';
|
||||
|
||||
const DASHED_PROFILE = PRINT_CLEARLY_DASHED;
|
||||
const TITLE_PROFILE = PRINT_CLEARLY_BOLD;
|
||||
const LETTER_FACE = fontFamilyOf(DASHED_PROFILE);
|
||||
const TITLE_FACE = fontFamilyOf(TITLE_PROFILE);
|
||||
|
||||
/**
|
||||
* 字母总览:上区大写、下区小写;每区标题 + 4 行四线三格(7+7+6+6)。
|
||||
* 列宽按 7 列均分;后两行 6 字左对齐,与前两行逐列对齐(占前 6 列)。
|
||||
*/
|
||||
export default class UpperLowerDraw extends BaseDrawService {
|
||||
private letterSizes: LetterFontSizes = calcLetterFontSizes(
|
||||
FOUR_LINE_GRID_H,
|
||||
DASHED_PROFILE,
|
||||
);
|
||||
|
||||
async draw(data: UpperLowerData) {
|
||||
this.prepareDraw();
|
||||
await Promise.allSettled([
|
||||
loadLetterFont(DASHED_PROFILE),
|
||||
loadLetterFont(TITLE_PROFILE),
|
||||
]);
|
||||
await this.drawHeaderAndDivider();
|
||||
this.drawUpperLowerBody(data.upperRows, data.lowerRows);
|
||||
await this.drawPrintFooter();
|
||||
}
|
||||
|
||||
private drawUpperLowerBody(
|
||||
upperRows: string[][],
|
||||
lowerRows: string[][],
|
||||
): void {
|
||||
const lineW = this.canvasWidth - M * 2;
|
||||
const slotW = lineW / 7;
|
||||
|
||||
let y = this.currentY + SECTION_GAP - 7;
|
||||
|
||||
y = this.drawLetterBlock(
|
||||
'Uppercase Letters',
|
||||
upperRows,
|
||||
y,
|
||||
lineW,
|
||||
slotW,
|
||||
);
|
||||
y += SECTION_GAP + 7;
|
||||
this.drawLetterBlock('lowercase letters', lowerRows, y, lineW, slotW);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制一个分区:居中标题 + 4 行四线三格与字母。
|
||||
* @returns 该分区最后一行四线三格底边 Y
|
||||
*/
|
||||
private drawLetterBlock(
|
||||
title: string,
|
||||
rows: string[][],
|
||||
startY: number,
|
||||
lineW: number,
|
||||
slotW: number,
|
||||
): number {
|
||||
const ctx = this.ctx;
|
||||
const centerX = M + lineW / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `bold ${TITLE_SIZE}px ${TITLE_FACE}`;
|
||||
ctx.fillStyle = TITLE_COLOR;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(title, centerX, startY);
|
||||
ctx.restore();
|
||||
|
||||
let y = startY + TITLE_SIZE + TITLE_GAP;
|
||||
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
const chars = rows[r];
|
||||
drawFourLineGrid(ctx, M, y, lineW, FOUR_LINE_GRID_H);
|
||||
|
||||
/** 与 7 字行共用列宽 slotW,后两行 6 字左对齐(占前 6 列),与上行逐列对齐 */
|
||||
const offsetX = M;
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const cx = offsetX + slotW * (i + 0.5);
|
||||
drawLetterInFourLineGrid(
|
||||
ctx,
|
||||
chars[i],
|
||||
cx,
|
||||
y,
|
||||
FOUR_LINE_GRID_H,
|
||||
this.letterSizes,
|
||||
{
|
||||
fontFamily: LETTER_FACE,
|
||||
color: TRACE_LIGHT_RED,
|
||||
},
|
||||
);
|
||||
}
|
||||
y += FOUR_LINE_GRID_H;
|
||||
if (r < rows.length - 1) {
|
||||
y += ROW_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../../shared/data/alphabet';
|
||||
|
||||
/** 字母描红的布局模式(与产品文档六类练习一致) */
|
||||
export type LetterTracingMode =
|
||||
/** 1 看图识字:教学区 + 描红行(前三行大写、后三行小写) */
|
||||
| 'letter-tracing-single'
|
||||
/** 2 字母总览:Uppercase / Lowercase 分区,每区四行 7+7+6+6 */
|
||||
| 'letter-tracing-upper-lower'
|
||||
/** 3 大小写配对:左栏 A–M、右栏 N–Z,每行最多 4 组 Aa */
|
||||
| 'letter-tracing-case-pairing'
|
||||
/** 4 分栏对照:半组字母,左栏大写、右栏小写,每行 4 个 */
|
||||
| 'letter-tracing-two-column'
|
||||
/** 5 三字母精练:每页聚焦 3 个字母,每字母 4 行(2 行示范 + 2 行简练) */
|
||||
| 'letter-tracing-three'
|
||||
/** 6 单字母逐行:每行一个字母,13 字母半表(A–M 或 N–Z) */
|
||||
| 'letter-tracing-half';
|
||||
|
||||
/** 字母大小写模式(部分模式不使用) */
|
||||
export type LetterCaseMode = 'upper' | 'lower' | 'both';
|
||||
|
||||
/** A–M / N–Z 半表(分栏对照) */
|
||||
export type AlphabetHalf = 'A-M' | 'N-Z';
|
||||
|
||||
/** 字母描红生成器的配置项 */
|
||||
export interface LetterTracingGeneratorConfig {
|
||||
mode: LetterTracingMode;
|
||||
letterCase: LetterCaseMode;
|
||||
selectedLetter?: string;
|
||||
repetitions: number;
|
||||
fadePattern: 'gradient' | 'first-only';
|
||||
/** 分栏对照 / 单字母逐行:练习 A–M 或 N–Z */
|
||||
alphabetHalf?: AlphabetHalf;
|
||||
/** 三字母精练:当前页聚焦的 3 个字母(如 ['A','B','C']) */
|
||||
tripleLetters?: string[];
|
||||
}
|
||||
|
||||
/** 单个描红格子的数据 */
|
||||
export interface TracingCell {
|
||||
char: string;
|
||||
opacity: number;
|
||||
isGuide: boolean;
|
||||
/** 与 char 组成大小写一对(如 A + a),用于大小写配对格内展示 */
|
||||
pairChar?: string;
|
||||
}
|
||||
|
||||
/** 一行描红数据,包含展示字符和若干格子 */
|
||||
export interface TracingRow {
|
||||
displayChar: string;
|
||||
cells: TracingCell[];
|
||||
}
|
||||
|
||||
/** 单字母逐行:一行中一个大小写配对的展示格子 */
|
||||
export interface SingleLineCell {
|
||||
upper: string;
|
||||
lower: string;
|
||||
/** 'solid' 正常渲染 | 'dashed' 使用虚线字体 */
|
||||
style: 'solid' | 'dashed';
|
||||
color: string;
|
||||
alpha: number;
|
||||
}
|
||||
|
||||
/** 单字母逐行:一行数据(6 个位置,前 4 个有字母,后 2 个空) */
|
||||
export interface SingleLineRow {
|
||||
letter: string;
|
||||
cells: (SingleLineCell | null)[];
|
||||
}
|
||||
|
||||
/** 三字母精练:一个字母区域(4 行),前两行完整示范,后两行简练(黑+浅红) */
|
||||
export interface TripleSection {
|
||||
letter: string;
|
||||
rows: SingleLineRow[];
|
||||
}
|
||||
|
||||
/** Highlight It 格子数据(3×6 网格,混入目标字母) */
|
||||
export interface HighlightGrid {
|
||||
rows: string[][];
|
||||
targetLetter: string;
|
||||
}
|
||||
|
||||
/** 看图描红教学区 */
|
||||
export interface TeachingBlock {
|
||||
upper: string;
|
||||
lower: string;
|
||||
word: string;
|
||||
sentence: string;
|
||||
emoji: string;
|
||||
/** 字母笔顺图路径(/englishPages/shared/assets/letter/X.png) */
|
||||
letterImgPath: string;
|
||||
/** 配图路径(/englishPages/shared/assets/letterImgs/Word.png) */
|
||||
wordImgPath: string;
|
||||
}
|
||||
|
||||
export type LetterTracingData =
|
||||
| {
|
||||
mode: 'letter-tracing-single';
|
||||
teaching: TeachingBlock;
|
||||
highlightGrid: HighlightGrid;
|
||||
/** Trace It 区域使用的字母笔顺图路径(同 teaching.letterImgPath) */
|
||||
traceImgPath: string;
|
||||
/** 下部 4 行四线三格(无字符填充,纯空行) */
|
||||
tracingLineCount: number;
|
||||
}
|
||||
| {
|
||||
mode: 'letter-tracing-case-pairing';
|
||||
leftRows: TracingRow[];
|
||||
rightRows: TracingRow[];
|
||||
}
|
||||
| {
|
||||
mode: 'letter-tracing-two-column';
|
||||
leftRows: TracingRow[];
|
||||
rightRows: TracingRow[];
|
||||
}
|
||||
| {
|
||||
mode: 'letter-tracing-half';
|
||||
rows: SingleLineRow[];
|
||||
}
|
||||
| {
|
||||
mode: 'letter-tracing-three';
|
||||
sections: TripleSection[];
|
||||
}
|
||||
| {
|
||||
mode: 'letter-tracing-upper-lower';
|
||||
upperRows: string[][];
|
||||
lowerRows: string[][];
|
||||
};
|
||||
|
||||
/** 将用户输入的字母标准化为大写单字符,默认 'A' */
|
||||
export function normalizeLetter(c: string): string {
|
||||
const u = c.trim().toUpperCase();
|
||||
return u.length ? u[0] : 'A';
|
||||
}
|
||||
|
||||
/** 构建 Highlight It 的 3×6 随机字母网格,保证混入 2~5 个目标字母 */
|
||||
function buildHighlightGrid(
|
||||
target: string,
|
||||
gridRows: number,
|
||||
gridCols: number,
|
||||
): HighlightGrid {
|
||||
const total = gridRows * gridCols;
|
||||
const targetCount = 2 + Math.floor(Math.random() * 4); // 2~5
|
||||
const others = LETTERS_UPPER.filter((l) => l !== target);
|
||||
|
||||
const chars: string[] = [];
|
||||
for (let i = 0; i < targetCount; i++) chars.push(target);
|
||||
for (let i = targetCount; i < total; i++) {
|
||||
chars.push(others[Math.floor(Math.random() * others.length)]);
|
||||
}
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
|
||||
const rows: string[][] = [];
|
||||
for (let r = 0; r < gridRows; r++) {
|
||||
rows.push(chars.slice(r * gridCols, (r + 1) * gridCols));
|
||||
}
|
||||
return { rows, targetLetter: target };
|
||||
}
|
||||
|
||||
function buildCells(
|
||||
char: string,
|
||||
count: number,
|
||||
fadePattern: 'gradient' | 'first-only',
|
||||
pairChar?: string,
|
||||
): TracingCell[] {
|
||||
const n = Math.max(1, Math.min(12, count));
|
||||
const cells: TracingCell[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
let opacity: number;
|
||||
if (fadePattern === 'first-only') {
|
||||
opacity = i === 0 ? 1 : 0;
|
||||
} else if (n <= 1) {
|
||||
opacity = 1;
|
||||
} else {
|
||||
opacity = Math.max(0.12, 1 - i / (n - 1));
|
||||
}
|
||||
cells.push({
|
||||
char,
|
||||
opacity,
|
||||
isGuide: i === 0,
|
||||
...(pairChar !== undefined ? { pairChar } : {}),
|
||||
});
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function rowForChar(
|
||||
ch: string,
|
||||
repetitions: number,
|
||||
fadePattern: 'gradient' | 'first-only',
|
||||
pairChar?: string,
|
||||
): TracingRow {
|
||||
return {
|
||||
displayChar: ch,
|
||||
cells: buildCells(ch, repetitions, fadePattern, pairChar),
|
||||
};
|
||||
}
|
||||
|
||||
/** 一行内多个字母,各自占一段描红格(用于分栏对照等) */
|
||||
function rowForManyChars(
|
||||
chars: string[],
|
||||
repetitions: number,
|
||||
fadePattern: 'gradient' | 'first-only',
|
||||
): TracingRow {
|
||||
const cells: TracingCell[] = [];
|
||||
for (const ch of chars) {
|
||||
cells.push(...buildCells(ch, repetitions, fadePattern));
|
||||
}
|
||||
return {
|
||||
displayChar: chars.join(''),
|
||||
cells,
|
||||
};
|
||||
}
|
||||
|
||||
function chunk<T>(arr: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) {
|
||||
out.push(arr.slice(i, i + size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 总览区 26 字母拆成 7 + 7 + 6 + 6 四行(前两行 7 个、后两行 6 个,与四线三格排版一致) */
|
||||
function overviewRowsFrom(letters: string[]): string[][] {
|
||||
return [
|
||||
letters.slice(0, 7),
|
||||
letters.slice(7, 14),
|
||||
letters.slice(14, 20),
|
||||
letters.slice(20, 26),
|
||||
];
|
||||
}
|
||||
|
||||
const _COLOR_BLACK = '#1a1a1a';
|
||||
const _COLOR_LIGHT_RED = '#FF8E8E';
|
||||
|
||||
/** 构建 6 slot 的单行字母数据:前 4 格有字母(颜色递进),后 2 格空白 */
|
||||
function buildSingleLineCells(L: string): (SingleLineCell | null)[] {
|
||||
const lower = L.toLowerCase();
|
||||
return [
|
||||
{ upper: L, lower, style: 'solid', color: _COLOR_BLACK, alpha: 1 },
|
||||
{ upper: L, lower, style: 'solid', color: _COLOR_LIGHT_RED, alpha: 1 },
|
||||
{
|
||||
upper: L,
|
||||
lower,
|
||||
style: 'solid',
|
||||
color: _COLOR_LIGHT_RED,
|
||||
alpha: 0.5,
|
||||
},
|
||||
{
|
||||
upper: L,
|
||||
lower,
|
||||
style: 'dashed',
|
||||
color: _COLOR_LIGHT_RED,
|
||||
alpha: 0.5,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
];
|
||||
}
|
||||
|
||||
/** 构建 6 slot 的简练行数据:前 2 格有字母(黑色+浅红色),后 4 格空白 */
|
||||
function buildSimpleCells(L: string): (SingleLineCell | null)[] {
|
||||
const lower = L.toLowerCase();
|
||||
return [
|
||||
{ upper: L, lower, style: 'solid', color: _COLOR_BLACK, alpha: 1 },
|
||||
{ upper: L, lower, style: 'solid', color: _COLOR_LIGHT_RED, alpha: 1 },
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置生成字母描红数据
|
||||
* - picture-tracing:教学区 + 6 行描红
|
||||
* - case-pairing:左 A–M / 右 N–Z,每行最多 4 组大小写配对
|
||||
* - column-compare:半表,左大写右小写,每行 4 个
|
||||
* - overview:Uppercase / Lowercase Letters,每区四行 7+7+6+6
|
||||
*/
|
||||
export function generateLetterTracing(
|
||||
config: LetterTracingGeneratorConfig,
|
||||
): LetterTracingData {
|
||||
const { mode, repetitions, fadePattern } = config;
|
||||
|
||||
if (mode === 'letter-tracing-single') {
|
||||
const key = normalizeLetter(config.selectedLetter ?? 'A');
|
||||
const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A;
|
||||
const upper = key;
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
const hasLetterAsset = key === 'A' || key === 'B';
|
||||
const letterFile = hasLetterAsset ? key : 'A';
|
||||
const letterImgPath = `/englishPages/shared/assets/letter/${letterFile}.png`;
|
||||
|
||||
const wordImgPath = `/englishPages/shared/assets/letterImgs/Apple.png`;
|
||||
|
||||
const teaching: TeachingBlock = {
|
||||
upper,
|
||||
lower,
|
||||
word: meta.word,
|
||||
sentence: `${upper} is for ${meta.word}`,
|
||||
emoji: meta.emoji,
|
||||
letterImgPath,
|
||||
wordImgPath,
|
||||
};
|
||||
|
||||
const highlightGrid = buildHighlightGrid(upper, 3, 6);
|
||||
|
||||
return {
|
||||
mode: 'letter-tracing-single',
|
||||
teaching,
|
||||
highlightGrid,
|
||||
traceImgPath: letterImgPath,
|
||||
tracingLineCount: 8,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'letter-tracing-case-pairing') {
|
||||
const half =
|
||||
config.alphabetHalf === 'N-Z'
|
||||
? LETTERS_UPPER.slice(13)
|
||||
: LETTERS_UPPER.slice(0, 13);
|
||||
const leftRows = half.map((L) =>
|
||||
rowForChar(L, repetitions, fadePattern),
|
||||
);
|
||||
const rightRows = half.map((L) =>
|
||||
rowForChar(L.toLowerCase(), repetitions, fadePattern),
|
||||
);
|
||||
return { mode: 'letter-tracing-case-pairing', leftRows, rightRows };
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'letter-tracing-three') {
|
||||
const letters = config.tripleLetters ?? ['A', 'B', 'C'];
|
||||
const sections: TripleSection[] = letters.map((L) => {
|
||||
const filledRow: SingleLineRow = {
|
||||
letter: L,
|
||||
cells: buildSingleLineCells(L),
|
||||
};
|
||||
const simpleRow: SingleLineRow = {
|
||||
letter: L,
|
||||
cells: buildSimpleCells(L),
|
||||
};
|
||||
return {
|
||||
letter: L,
|
||||
rows: [filledRow, filledRow, simpleRow, simpleRow],
|
||||
};
|
||||
});
|
||||
return { mode: 'letter-tracing-three', sections };
|
||||
}
|
||||
|
||||
if (mode === 'letter-tracing-half') {
|
||||
const half =
|
||||
config.alphabetHalf === 'N-Z'
|
||||
? LETTERS_UPPER.slice(13)
|
||||
: LETTERS_UPPER.slice(0, 13);
|
||||
|
||||
const rows: SingleLineRow[] = half.map((L) => ({
|
||||
letter: L,
|
||||
cells: buildSingleLineCells(L),
|
||||
}));
|
||||
|
||||
return { mode: 'letter-tracing-half', rows };
|
||||
}
|
||||
|
||||
// letter-tracing-upper-lower
|
||||
return {
|
||||
mode: 'letter-tracing-upper-lower',
|
||||
upperRows: overviewRowsFrom([...LETTERS_UPPER]),
|
||||
lowerRows: overviewRowsFrom(LETTERS_UPPER.map((L) => L.toLowerCase())),
|
||||
};
|
||||
}
|
||||
|
||||
/** 与 WorksheetDefinition.generatorConfig 合并后的运行时配置 */
|
||||
export function mergeLetterTracingConfig(
|
||||
base: Partial<LetterTracingGeneratorConfig>,
|
||||
overrides: Partial<LetterTracingGeneratorConfig>,
|
||||
): LetterTracingGeneratorConfig {
|
||||
return {
|
||||
mode: (overrides.mode ??
|
||||
base.mode ??
|
||||
'letter-tracing-single') as LetterTracingMode,
|
||||
letterCase: (overrides.letterCase ??
|
||||
base.letterCase ??
|
||||
'upper') as LetterCaseMode,
|
||||
selectedLetter: overrides.selectedLetter ?? base.selectedLetter,
|
||||
repetitions: Math.max(
|
||||
1,
|
||||
(overrides.repetitions ?? base.repetitions ?? 5) as number,
|
||||
),
|
||||
fadePattern: (overrides.fadePattern ??
|
||||
base.fadePattern ??
|
||||
'gradient') as 'gradient' | 'first-only',
|
||||
alphabetHalf: (overrides.alphabetHalf ??
|
||||
base.alphabetHalf ??
|
||||
'A-M') as AlphabetHalf,
|
||||
tripleLetters: overrides.tripleLetters ??
|
||||
base.tripleLetters ?? ['A', 'B', 'C'],
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import LetterTracingDraw from '../shared/draw/letterTracingDraw';
|
||||
import LetterTracingDraw from './draw/letterTracingDraw';
|
||||
import { createPage, type CanvasDataState } from '../../base/pageMixin';
|
||||
import { defaultShareConfig } from '../../config/config';
|
||||
import { getWorksheetById } from '../../core/data/worksheets';
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
normalizeLetter,
|
||||
type LetterTracingGeneratorConfig,
|
||||
type LetterTracingMode,
|
||||
} from '../shared/generators/letter-tracing-generator';
|
||||
} from './generators/letter-tracing-generator';
|
||||
import { LETTERS_UPPER, LETTERS_PAIRS } from '../shared/data/alphabet';
|
||||
import {
|
||||
DEFAULT_LETTER_PROFILE,
|
||||
@@ -45,13 +45,13 @@ const MODES = [
|
||||
desc: '左 A–M、右 N–Z 配对描红',
|
||||
},
|
||||
{
|
||||
id: 'letter-tracing-single-line',
|
||||
id: 'letter-tracing-half',
|
||||
icon: 'square-half',
|
||||
label: '13字母半表',
|
||||
desc: '每行一个字母,13 字母半表',
|
||||
},
|
||||
{
|
||||
id: 'letter-tracing-triple',
|
||||
id: 'letter-tracing-three',
|
||||
icon: 'ABC-list',
|
||||
label: '三字母精练',
|
||||
desc: '每页聚焦 3 个字母深度书写',
|
||||
@@ -200,8 +200,8 @@ createPage(
|
||||
: 'upper';
|
||||
const needsHalf =
|
||||
this.data.traceMode === 'letter-tracing-case-pairing' ||
|
||||
this.data.traceMode === 'letter-tracing-single-line';
|
||||
const isTriple = this.data.traceMode === 'letter-tracing-triple';
|
||||
this.data.traceMode === 'letter-tracing-half';
|
||||
const isTriple = this.data.traceMode === 'letter-tracing-three';
|
||||
return mergeLetterTracingConfig(base, {
|
||||
selectedLetter: this.data.selectedLetter,
|
||||
letterCase,
|
||||
@@ -306,15 +306,15 @@ createPage(
|
||||
const showLetterPicker = traceMode === 'letter-tracing-single';
|
||||
const showCaseToggle =
|
||||
traceMode === 'letter-tracing-case-pairing' ||
|
||||
traceMode === 'letter-tracing-single-line';
|
||||
traceMode === 'letter-tracing-half';
|
||||
const showNextLetter = traceMode === 'letter-tracing-single';
|
||||
const showTripleGroupPicker = traceMode === 'letter-tracing-triple';
|
||||
const showTripleGroupPicker = traceMode === 'letter-tracing-three';
|
||||
const selectedLetter = options?.preserveLetter
|
||||
? (this.data.selectedLetter ?? 'A')
|
||||
: normalizeLetter(merged.selectedLetter ?? '');
|
||||
const letterCaseLower =
|
||||
traceMode === 'letter-tracing-case-pairing' ||
|
||||
traceMode === 'letter-tracing-single-line'
|
||||
traceMode === 'letter-tracing-half'
|
||||
? merged.alphabetHalf === 'N-Z'
|
||||
: traceMode === 'letter-tracing-single'
|
||||
? merged.letterCase === 'lower'
|
||||
|
||||
Reference in New Issue
Block a user