354 lines
11 KiB
TypeScript
354 lines
11 KiB
TypeScript
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,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|