Files
doodle-mini/miniprogram/chinesePages/handwritingSheet/draw/wordDailyCheckinDraw.ts
T

354 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BaseDrawService } from '../../../core/draw/baseDraw';
import {
GRID_COLORS,
GRID_DASH,
TRACING_COLORS,
} from '../../../core/data/tracingStyles';
import { TONEOZ_PINYIN, fontFamilyOf } from '../../../core/font/fontProfiles';
import { loadFontFace } from '../../../core/font/fontLoader';
import {
SECTION_PADDING_TOP,
SECTION_PADDING_BOTTOM,
HEADER_TITLE_GAP,
HEADER_META_GAP,
CONTENT_TOP_GAP,
FOOTER_PHRASE_GAP,
FOOTER_INDEX_GAP,
drawSectionHeader,
drawSectionFooter,
getRandomEncouragementPhrase,
} from '../../../core/draw/dailyPracticeDrawHelper';
import {
getScalingTransform,
drawSvgPath,
drawTianZiGrid,
} from '../../shared/drawUtils';
export interface DailyCheckinHanziItem {
character: string;
pinyin: string;
strokes: string[];
}
const PAGE_MARGIN = 14;
const SECTION_PADDING_X = 18;
const COLUMNS = 11;
const ROWS_PER_SECTION = 4;
const BLOCK_GAP = 0;
const PINYIN_ROW_RATIO = 0.52; // 拼音/笔画行高度占田字格高度比例
interface DrawSingleStrokeParams {
ctx: RenderingContext;
strokes: string[];
offsetX: number;
offsetY: number;
width: number;
height: number;
fromIndex: number;
toIndex: number;
fillStyle: string;
}
function drawStrokesRange({
ctx,
strokes,
offsetX,
offsetY,
width,
height,
fromIndex,
toIndex,
fillStyle,
}: DrawSingleStrokeParams) {
const padding = Math.min(width, height) * 0.1;
const transform = getScalingTransform(width, height, padding);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.miterLimit = 10;
ctx.fillStyle = fillStyle;
for (let s = fromIndex; s <= toIndex && s < strokes.length; s++) {
ctx.save();
ctx.translate(
offsetX + transform.xOffset,
offsetY + height - transform.yOffset,
);
ctx.scale(transform.scale, -transform.scale);
ctx.beginPath();
drawSvgPath(ctx, strokes[s]);
ctx.fill();
ctx.restore();
}
ctx.restore();
}
export default class WordDailyCheckinDraw extends BaseDrawService {
async draw(items: DailyCheckinHanziItem[]) {
this.prepareDraw();
await loadFontFace(TONEOZ_PINYIN);
this.drawPageDivider();
const sectionRects = this.getSectionRects();
const itemsPerSection = ROWS_PER_SECTION;
for (let i = 0; i < sectionRects.length; i++) {
const rect = sectionRects[i];
const sectionItems = items.slice(
i * itemsPerSection,
(i + 1) * itemsPerSection,
);
if (sectionItems.length === 0) continue;
this.drawSection(rect, sectionItems, i + 1);
}
}
private drawPageDivider() {
const cy = this.canvasHeight / 2;
this.drawLine(PAGE_MARGIN, cy, this.canvasWidth - PAGE_MARGIN, cy, {
isDashed: true,
dashPattern: [5, 5],
color: '#D6D2CC',
lineWidth: 1,
});
}
private getSectionRects() {
const halfH = this.canvasHeight / 2;
const fullW = this.canvasWidth;
return [
{
x: PAGE_MARGIN,
y: PAGE_MARGIN,
w: fullW - PAGE_MARGIN * 2,
h: halfH - PAGE_MARGIN,
},
{
x: PAGE_MARGIN,
y: halfH,
w: fullW - PAGE_MARGIN * 2,
h: this.canvasHeight - PAGE_MARGIN - halfH,
},
];
}
private drawSection(
rect: { x: number; y: number; w: number; h: number },
items: DailyCheckinHanziItem[],
sectionIndex: number,
) {
const phrase = getRandomEncouragementPhrase();
drawSectionHeader(this.ctx, rect, '汉字每日打卡');
drawSectionFooter(this.ctx, rect, phrase, `- ${sectionIndex} -`);
this.drawSectionContent(rect, items);
}
private drawSectionContent(
rect: { x: number; y: number; w: number; h: number },
items: DailyCheckinHanziItem[],
) {
const contentTop =
rect.y +
SECTION_PADDING_TOP +
HEADER_TITLE_GAP +
HEADER_META_GAP +
CONTENT_TOP_GAP;
const contentBottom =
rect.y +
rect.h -
SECTION_PADDING_BOTTOM -
FOOTER_PHRASE_GAP -
FOOTER_INDEX_GAP;
const contentHeight = contentBottom - contentTop;
const contentLeft = rect.x + SECTION_PADDING_X;
const contentWidth = rect.w - SECTION_PADDING_X * 2;
// 单元尺寸:田字格为正方形,受宽高约束取较小值
const cellWFromWidth = contentWidth / COLUMNS;
// 每个 hanzi 块高度 = pinyinRow + tianzigeRow,取 pinyin = ratio * tianzige
const blockHeightFactor = 1 + PINYIN_ROW_RATIO;
const totalGap = (ROWS_PER_SECTION - 1) * BLOCK_GAP;
const cellWFromHeight =
(contentHeight - totalGap) / (blockHeightFactor * ROWS_PER_SECTION);
const cellW = Math.min(cellWFromWidth, cellWFromHeight);
const tianzigeH = cellW;
const pinyinH = cellW * PINYIN_ROW_RATIO;
const blockH = tianzigeH + pinyinH;
const totalH = ROWS_PER_SECTION * blockH + totalGap;
const startX = contentLeft + (contentWidth - cellW * COLUMNS) / 2;
const startY = contentTop + (contentHeight - totalH) / 2;
for (let i = 0; i < items.length && i < ROWS_PER_SECTION; i++) {
const blockY = startY + i * (blockH + BLOCK_GAP);
this.drawHanziBlock(items[i], startX, blockY, cellW, pinyinH);
}
}
private drawHanziBlock(
item: DailyCheckinHanziItem,
startX: number,
startY: number,
cellW: number,
pinyinH: number,
) {
const tianzigeH = cellW;
const totalW = cellW * COLUMNS;
// 拼音 + 笔画行(顶部)
this.drawPinyinStrokeRow(item, startX, startY, totalW, pinyinH, cellW);
// 田字格练习行(底部)
const tianzigeTop = startY + pinyinH;
this.drawTianzigeRow(item, startX, tianzigeTop, cellW, tianzigeH);
}
private drawPinyinStrokeRow(
item: DailyCheckinHanziItem,
startX: number,
startY: number,
totalW: number,
rowH: number,
cellW: number,
) {
const ctx = this.ctx;
const y1 = startY + rowH / 3;
const y2 = startY + (rowH * 2) / 3;
const yBot = startY + rowH;
ctx.save();
// 外边框
ctx.strokeStyle = GRID_COLORS.border;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.strokeRect(startX, startY, totalW, rowH);
// 中间两条虚线(四线三格)—— 仅第一格
ctx.strokeStyle = GRID_COLORS.middleLine;
ctx.setLineDash([...GRID_DASH]);
ctx.beginPath();
ctx.moveTo(startX, y1);
ctx.lineTo(startX + cellW, y1);
ctx.moveTo(startX, y2);
ctx.lineTo(startX + cellW, y2);
ctx.stroke();
ctx.beginPath();
ctx.setLineDash([]);
const vx = startX + cellW;
ctx.moveTo(vx, startY);
ctx.lineTo(vx, yBot);
ctx.stroke();
ctx.restore();
// 列分隔线
// ctx.strokeStyle = GRID_COLORS.border;
// ctx.setLineDash([]);
// for (let c = 1; c < COLUMNS; c++) {
// const vx = startX + c * cellW;
// ctx.beginPath();
// ctx.moveTo(vx, startY);
// ctx.lineTo(vx, yBot);
// ctx.stroke();
// }
// 第一格:绘制拼音
if (item.pinyin) {
const cx = startX + cellW / 2;
const fontSize = Math.round(rowH * 0.65);
ctx.save();
ctx.font = `${fontSize}px ${fontFamilyOf(TONEOZ_PINYIN)}`;
ctx.fillStyle = TRACING_COLORS.strong;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillText(item.pinyin, cx, y2);
ctx.restore();
}
// 笔画格:第 2 格起,按笔画顺序展示当前进度
const strokeCount = item.strokes.length;
for (let k = 1; k <= strokeCount && k < COLUMNS; k++) {
const cellX = startX + cellW / 2 + k * 15;
const currentIdx = k - 1;
// 已绘制的历史笔画:浅灰
if (currentIdx > 0) {
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: rowH,
fromIndex: 0,
toIndex: currentIdx - 1,
fillStyle: TRACING_COLORS.guide,
});
}
// 当前笔画:红色高亮
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: rowH,
fromIndex: currentIdx,
toIndex: currentIdx,
fillStyle: '#D94B4B',
});
}
}
private drawTianzigeRow(
item: DailyCheckinHanziItem,
startX: number,
startY: number,
cellW: number,
cellH: number,
) {
const ctx = this.ctx;
const TRACING_INDICES = new Set<number>([5, 8]); // 第 6 个和第 9 个(0-based: 5, 8
for (let c = 0; c < COLUMNS; c++) {
const cellX = startX + c * cellW;
const cx = cellX + cellW / 2;
const cy = startY + cellH / 2;
drawTianZiGrid({ ctx, cx, cy, size: cellW });
if (c === 0) {
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: cellH,
fromIndex: 0,
toIndex: item.strokes.length - 1,
fillStyle: TRACING_COLORS.strong,
});
} else if (TRACING_INDICES.has(c)) {
drawStrokesRange({
ctx,
strokes: item.strokes,
offsetX: cellX,
offsetY: startY,
width: cellW,
height: cellH,
fromIndex: 0,
toIndex: item.strokes.length - 1,
fillStyle: TRACING_COLORS.guide,
});
}
}
}
}