264 lines
7.9 KiB
TypeScript
264 lines
7.9 KiB
TypeScript
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
|
import type {
|
|
LetterTracingData,
|
|
TracingRow,
|
|
} from '../../../core/generators/letter-tracing-generator';
|
|
|
|
const M = 24;
|
|
const BLUE = '#4A90E2';
|
|
const ORANGE = '#FF8C42';
|
|
|
|
/**
|
|
* 字母描红绘制(tracing-writing + letter-tracing 的客户端实现)
|
|
*/
|
|
export default class LetterTracingDraw extends BaseDrawService {
|
|
constructor(
|
|
canvas: Canvas,
|
|
ctx: RenderingContext,
|
|
options?: Record<string, unknown>,
|
|
) {
|
|
super(canvas, ctx, options);
|
|
}
|
|
|
|
async draw(data: LetterTracingData) {
|
|
this.prepareDraw();
|
|
await this.drawHeaderAndDivider();
|
|
|
|
switch (data.mode) {
|
|
case 'single-letter':
|
|
this.drawSingleLetter(data);
|
|
break;
|
|
case 'full-alphabet':
|
|
this.drawFullAlphabet(data.rows);
|
|
break;
|
|
case 'upper-lower-split':
|
|
this.drawUpperLowerSplit(data.upperGlyphs, data.lowerGlyphs);
|
|
break;
|
|
case 'two-column':
|
|
this.drawTwoColumn(data.leftRows, data.rightRows);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** 四线三格:顶线、中虚线、基线、底线 */
|
|
private drawFourLines(x: number, y: number, w: number, h: number) {
|
|
const ctx = this.ctx;
|
|
const yTop = y;
|
|
const yMid = y + h * 0.28;
|
|
const yBase = y + h * 0.62;
|
|
const yBot = y + h;
|
|
|
|
ctx.strokeStyle = BLUE;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, yTop);
|
|
ctx.lineTo(x + w, yTop);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = ORANGE;
|
|
ctx.setLineDash([6, 6]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, yMid);
|
|
ctx.lineTo(x + w, yMid);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = BLUE;
|
|
ctx.setLineDash([]);
|
|
ctx.lineWidth = 1.2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, yBase);
|
|
ctx.lineTo(x + w, yBase);
|
|
ctx.stroke();
|
|
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, yBot);
|
|
ctx.lineTo(x + w, yBot);
|
|
ctx.stroke();
|
|
}
|
|
|
|
private drawCharInCell(
|
|
ch: string,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
opacity: number,
|
|
bold: boolean,
|
|
) {
|
|
const ctx = this.ctx;
|
|
if (opacity <= 0) return;
|
|
const fontSize = Math.min(h * 0.72, w * 0.85);
|
|
ctx.save();
|
|
ctx.globalAlpha = opacity;
|
|
ctx.fillStyle = bold ? '#111' : '#666';
|
|
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'alphabetic';
|
|
const baselineY = y + h * 0.62;
|
|
ctx.fillText(ch, x + w / 2, baselineY);
|
|
ctx.restore();
|
|
}
|
|
|
|
private drawTracingRow(
|
|
row: TracingRow,
|
|
startX: number,
|
|
y: number,
|
|
rowH: number,
|
|
totalWidth: number,
|
|
) {
|
|
const gap = 4;
|
|
const n = row.cells.length;
|
|
const cellW = (totalWidth - gap * (n - 1)) / n;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const cx = startX + i * (cellW + gap);
|
|
this.drawFourLines(cx, y, cellW, rowH);
|
|
const cell = row.cells[i];
|
|
this.drawCharInCell(
|
|
cell.char,
|
|
cx,
|
|
y,
|
|
cellW,
|
|
rowH,
|
|
cell.opacity,
|
|
cell.isGuide,
|
|
);
|
|
}
|
|
}
|
|
|
|
private drawSingleLetter(
|
|
data: Extract<LetterTracingData, { mode: 'single-letter' }>,
|
|
) {
|
|
const ctx = this.ctx;
|
|
const { teaching, rows } = data;
|
|
const cw = this.canvasWidth;
|
|
let y = this.currentY + 8;
|
|
const teachH = Math.min(220, (this.canvasHeight - y) * 0.42);
|
|
|
|
// 教学区背景
|
|
ctx.fillStyle = '#f8fafc';
|
|
ctx.fillRect(M, y, cw - M * 2, teachH);
|
|
|
|
// 左侧 emoji
|
|
ctx.font = `${Math.min(88, teachH * 0.45)}px sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(teaching.emoji, M + (cw - M * 2) * 0.22, y + teachH / 2);
|
|
|
|
// 右侧文字
|
|
const tx = M + (cw - M * 2) * 0.42;
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillStyle = '#111';
|
|
ctx.font = 'bold 44px sans-serif';
|
|
ctx.fillText(`${teaching.upper} ${teaching.lower}`, tx, y + 16);
|
|
ctx.font = '22px sans-serif';
|
|
ctx.fillStyle = '#333';
|
|
ctx.fillText(teaching.sentence, tx, y + 72);
|
|
ctx.fillStyle = '#666';
|
|
ctx.font = '18px sans-serif';
|
|
ctx.fillText(teaching.word, tx, y + 104);
|
|
|
|
y += teachH + 12;
|
|
this.drawDashedDivider(y, M, '#ccc');
|
|
y += 14;
|
|
|
|
const rest = this.canvasHeight - y - M;
|
|
const rowH = Math.max(36, (rest - 8) / rows.length);
|
|
const rowWidth = cw - M * 2;
|
|
for (let r = 0; r < rows.length; r++) {
|
|
this.drawTracingRow(
|
|
rows[r],
|
|
M,
|
|
y + r * (rowH + 10),
|
|
rowH,
|
|
rowWidth,
|
|
);
|
|
}
|
|
}
|
|
|
|
private drawFullAlphabet(rows: TracingRow[]) {
|
|
const y0 = this.currentY + 6;
|
|
const bottom = this.canvasHeight - M;
|
|
const totalH = bottom - y0;
|
|
const rowH = Math.max(14, totalH / rows.length - 2);
|
|
const rowWidth = this.canvasWidth - M * 2;
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const y = y0 + i * (rowH + 2);
|
|
this.drawTracingRow(rows[i], M, y, rowH, rowWidth);
|
|
}
|
|
}
|
|
|
|
private drawUpperLowerSplit(upper: string[], lower: string[]) {
|
|
const ctx = this.ctx;
|
|
const cw = this.canvasWidth;
|
|
let y = this.currentY + 6;
|
|
const innerW = cw - M * 2;
|
|
|
|
const drawBlockTitle = (t: string) => {
|
|
ctx.fillStyle = '#111';
|
|
ctx.font = 'bold 18px sans-serif';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(t, M, y);
|
|
y += 26;
|
|
};
|
|
|
|
const drawGlyphGrid = (chars: string[], cols: number) => {
|
|
const rows = Math.ceil(chars.length / cols);
|
|
const cellW = (innerW - (cols - 1) * 4) / cols;
|
|
const cellH = 32;
|
|
|
|
for (let r = 0; r < rows; r++) {
|
|
for (let c = 0; c < cols; c++) {
|
|
const idx = r * cols + c;
|
|
if (idx >= chars.length) break;
|
|
const cx = M + c * (cellW + 4);
|
|
this.drawFourLines(cx, y, cellW, cellH);
|
|
this.drawCharInCell(
|
|
chars[idx],
|
|
cx,
|
|
y,
|
|
cellW,
|
|
cellH,
|
|
0.85,
|
|
true,
|
|
);
|
|
}
|
|
y += cellH + 6;
|
|
}
|
|
};
|
|
|
|
drawBlockTitle('大写字母 Uppercase');
|
|
drawGlyphGrid(upper, 13);
|
|
y += 8;
|
|
this.drawDashedDivider(y, M, '#bbb');
|
|
y += 14;
|
|
drawBlockTitle('小写字母 Lowercase');
|
|
drawGlyphGrid(lower, 13);
|
|
}
|
|
|
|
private drawTwoColumn(leftRows: TracingRow[], rightRows: TracingRow[]) {
|
|
const y0 = this.currentY + 6;
|
|
const bottom = this.canvasHeight - M;
|
|
const totalH = bottom - y0;
|
|
const maxRows = Math.max(leftRows.length, rightRows.length);
|
|
const rowH = Math.max(16, totalH / maxRows - 3);
|
|
const gapCol = 16;
|
|
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
|
|
|
|
for (let i = 0; i < leftRows.length; i++) {
|
|
const y = y0 + i * (rowH + 3);
|
|
this.drawTracingRow(leftRows[i], M, y, rowH, colW);
|
|
}
|
|
const xR = M + colW + gapCol;
|
|
for (let i = 0; i < rightRows.length; i++) {
|
|
const y = y0 + i * (rowH + 3);
|
|
this.drawTracingRow(rightRows[i], xR, y, rowH, colW);
|
|
}
|
|
}
|
|
}
|