feat: 开发完成拼音每日一练
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { PinyinBaseDraw } from '../../shared/pinyinBaseDraw';
|
||||
import {
|
||||
generatePinyinDaily,
|
||||
type PinyinDailySection,
|
||||
} from '../../shared/pinyinDailyGenerator';
|
||||
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';
|
||||
|
||||
const PAGE_MARGIN = 14;
|
||||
const SECTION_PADDING_X = 18;
|
||||
|
||||
const GRID_COL_GAP = 6;
|
||||
const GRID_ROW_GAP = 8;
|
||||
|
||||
export default class PinyinDailyDraw extends PinyinBaseDraw {
|
||||
async draw(dayIndex: number) {
|
||||
const dailyData = generatePinyinDaily(dayIndex);
|
||||
|
||||
this.prepareDraw();
|
||||
await loadFontFace(TONEOZ_PINYIN);
|
||||
|
||||
this.drawPageDividers();
|
||||
|
||||
const sectionRects = this.getSectionRects();
|
||||
for (let i = 0; i < sectionRects.length; i++) {
|
||||
const section = dailyData.sections[i];
|
||||
if (section) {
|
||||
this.drawSection(sectionRects[i], section, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawPageDividers() {
|
||||
const cx = this.canvasWidth / 2;
|
||||
const cy = this.canvasHeight / 2;
|
||||
const dashStyle = {
|
||||
isDashed: true,
|
||||
dashPattern: [5, 5],
|
||||
color: '#D6D2CC',
|
||||
lineWidth: 1,
|
||||
};
|
||||
|
||||
this.drawLine(
|
||||
cx,
|
||||
PAGE_MARGIN,
|
||||
cx,
|
||||
this.canvasHeight - PAGE_MARGIN,
|
||||
dashStyle,
|
||||
);
|
||||
this.drawLine(
|
||||
PAGE_MARGIN,
|
||||
cy,
|
||||
this.canvasWidth - PAGE_MARGIN,
|
||||
cy,
|
||||
dashStyle,
|
||||
);
|
||||
}
|
||||
|
||||
private getSectionRects() {
|
||||
const halfW = this.canvasWidth / 2;
|
||||
const halfH = this.canvasHeight / 2;
|
||||
|
||||
return [
|
||||
{
|
||||
x: PAGE_MARGIN,
|
||||
y: PAGE_MARGIN,
|
||||
w: halfW - PAGE_MARGIN,
|
||||
h: halfH - PAGE_MARGIN,
|
||||
},
|
||||
{
|
||||
x: halfW,
|
||||
y: PAGE_MARGIN,
|
||||
w: this.canvasWidth - PAGE_MARGIN - halfW,
|
||||
h: halfH - PAGE_MARGIN,
|
||||
},
|
||||
{
|
||||
x: PAGE_MARGIN,
|
||||
y: halfH,
|
||||
w: halfW - PAGE_MARGIN,
|
||||
h: this.canvasHeight - PAGE_MARGIN - halfH,
|
||||
},
|
||||
{
|
||||
x: halfW,
|
||||
y: halfH,
|
||||
w: this.canvasWidth - PAGE_MARGIN - halfW,
|
||||
h: this.canvasHeight - PAGE_MARGIN - halfH,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private drawSection(
|
||||
rect: { x: number; y: number; w: number; h: number },
|
||||
section: PinyinDailySection,
|
||||
sectionIndex: number,
|
||||
) {
|
||||
const phrase = getRandomEncouragementPhrase();
|
||||
drawSectionHeader(this.ctx, rect, '拼音每日打卡');
|
||||
this.drawSectionGrid(rect, section);
|
||||
drawSectionFooter(this.ctx, rect, phrase, `- ${sectionIndex} -`);
|
||||
}
|
||||
|
||||
private drawSectionGrid(
|
||||
rect: { x: number; y: number; w: number; h: number },
|
||||
section: PinyinDailySection,
|
||||
) {
|
||||
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 contentWidth = rect.w - SECTION_PADDING_X * 2;
|
||||
|
||||
const cols = section.cols;
|
||||
const rows = section.rows;
|
||||
const cellW = (contentWidth - GRID_COL_GAP * (cols - 1)) / cols;
|
||||
const cellH =
|
||||
(contentHeight - GRID_ROW_GAP * (rows.length - 1)) / rows.length;
|
||||
const fontFamily = fontFamilyOf(TONEOZ_PINYIN);
|
||||
|
||||
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
|
||||
const row = rows[rowIdx];
|
||||
const y = contentTop + rowIdx * (cellH + GRID_ROW_GAP);
|
||||
|
||||
for (let colIdx = 0; colIdx < cols; colIdx++) {
|
||||
const cell = row.cells[colIdx];
|
||||
const x =
|
||||
rect.x +
|
||||
SECTION_PADDING_X +
|
||||
colIdx * (cellW + GRID_COL_GAP);
|
||||
|
||||
this.drawFourLineGrid(x, y, cellW, cellH);
|
||||
|
||||
if (cell) {
|
||||
const cx = x + cellW / 2;
|
||||
const fontSize = Math.round(cellH * 0.7);
|
||||
const baselineY = y + (cellH * 2) / 3;
|
||||
|
||||
let charToDraw = cell.char;
|
||||
const variants = section.task.variants || [];
|
||||
const hasVariants = variants.length > 0;
|
||||
|
||||
if (cols === 5) {
|
||||
if (hasVariants) {
|
||||
if (colIdx === 0) {
|
||||
charToDraw = section.task.char;
|
||||
} else {
|
||||
charToDraw =
|
||||
variants[colIdx - 1] || section.task.char;
|
||||
}
|
||||
} else {
|
||||
charToDraw = section.task.char;
|
||||
}
|
||||
} else if (cols === 4) {
|
||||
let drawnRowIdx = -1;
|
||||
if (rowIdx === 0) drawnRowIdx = 0;
|
||||
else if (rowIdx === 1) drawnRowIdx = 1;
|
||||
else if (rowIdx === 3) drawnRowIdx = 2;
|
||||
else if (rowIdx === 5) drawnRowIdx = 3;
|
||||
|
||||
if (drawnRowIdx !== -1 && hasVariants) {
|
||||
charToDraw =
|
||||
variants[drawnRowIdx] || section.task.char;
|
||||
} else {
|
||||
charToDraw = section.task.char;
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
this.ctx.fillStyle = cell.color;
|
||||
this.ctx.globalAlpha = cell.alpha;
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'alphabetic';
|
||||
this.ctx.fillText(charToDraw, cx, baselineY);
|
||||
this.ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
PINYIN_SECTIONS,
|
||||
type PinyinSection,
|
||||
type PinyinSubCategory,
|
||||
SHENGMU,
|
||||
DAN_YUNMU,
|
||||
FU_YUNMU,
|
||||
QIANBI_YUNMU,
|
||||
HOUBI_YUNMU,
|
||||
TESHU_YUNMU,
|
||||
ZHENGTI_RENDU,
|
||||
PINGSHEYIN,
|
||||
QIAOSHEYIN,
|
||||
} from '../../../core/data/pinyin';
|
||||
import { PINYIN_STYLE } from '../../shared/pinyinConstants';
|
||||
import { PinyinBaseDraw } from '../../shared/pinyinBaseDraw';
|
||||
import type { PinyinDictationData } from './pinyinDictationDraw';
|
||||
|
||||
const {
|
||||
MARGIN_X,
|
||||
CELL_H,
|
||||
CELL_H_V2,
|
||||
ROW_GAP,
|
||||
ROW_GAP_V2,
|
||||
SECTION_GAP,
|
||||
TITLE_AFTER_GAP,
|
||||
CONTENT_TOP_GAP,
|
||||
SECTION_TITLE_FONT,
|
||||
SUB_LABEL_FONT,
|
||||
SECTION_TITLE_COLOR,
|
||||
SUB_LABEL_COLOR,
|
||||
PINYIN_FONT_SIZE_RATIO,
|
||||
PINYIN_FONT_SIZE_RATIO_V2,
|
||||
} = PINYIN_STYLE;
|
||||
|
||||
const MIN_CELL_W = 44;
|
||||
const SUB_INDENT = 20;
|
||||
const SECTION_TO_SUB_GAP = 8;
|
||||
const QIZHONG_GAP = 16;
|
||||
const SUB_LABEL_H = 14;
|
||||
const SUB_LABEL_GAP = 4;
|
||||
const SUB_BLOCK_GAP = 8;
|
||||
|
||||
/** V1 + V2 描红 / 默写练习 */
|
||||
export default class PinyinDictationSheetDraw extends PinyinBaseDraw {
|
||||
private cellW = MIN_CELL_W;
|
||||
private contentW = 0;
|
||||
private currentCellH = CELL_H;
|
||||
private currentRowGap = ROW_GAP;
|
||||
private currentFontSizeRatio = PINYIN_FONT_SIZE_RATIO;
|
||||
|
||||
async draw(data: PinyinDictationData) {
|
||||
this.prepareDraw();
|
||||
|
||||
this.contentW = this.canvasWidth - 2 * MARGIN_X;
|
||||
|
||||
if (data.mode.endsWith('-v2')) {
|
||||
this.cellW = this.contentW / 8;
|
||||
this.currentCellH = CELL_H_V2;
|
||||
this.currentRowGap = ROW_GAP_V2;
|
||||
this.currentFontSizeRatio = PINYIN_FONT_SIZE_RATIO_V2;
|
||||
} else {
|
||||
const maxCols = Math.floor(this.contentW / MIN_CELL_W);
|
||||
this.cellW = this.contentW / maxCols;
|
||||
this.currentCellH = CELL_H;
|
||||
this.currentRowGap = ROW_GAP;
|
||||
this.currentFontSizeRatio = PINYIN_FONT_SIZE_RATIO;
|
||||
}
|
||||
|
||||
await this.drawHeaderAndDivider();
|
||||
this.drawContent(data);
|
||||
}
|
||||
|
||||
// ── 内容入口 ──
|
||||
|
||||
private drawContent(data: PinyinDictationData) {
|
||||
const isTracing = data.mode.includes('tracing');
|
||||
let y = this.currentY + CONTENT_TOP_GAP;
|
||||
|
||||
if (data.mode.endsWith('-v2')) {
|
||||
this.drawV2Content(y, isTracing);
|
||||
} else {
|
||||
for (const section of PINYIN_SECTIONS) {
|
||||
y = this.drawV1Section(section, y, isTracing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── V1:原有布局(声母 / 韵母 / 整体认读 + 子分类) ──
|
||||
|
||||
private drawV1Section(
|
||||
section: PinyinSection,
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
): number {
|
||||
let y = startY;
|
||||
y = this.drawTitle(section.title, section.count, y);
|
||||
y += TITLE_AFTER_GAP;
|
||||
|
||||
y = this.drawItemRows(
|
||||
section.items,
|
||||
MARGIN_X,
|
||||
y,
|
||||
isTracing,
|
||||
this.cellW,
|
||||
this.currentCellH,
|
||||
);
|
||||
|
||||
if (section.subCategories && section.subCategories.length > 0) {
|
||||
y += SECTION_TO_SUB_GAP;
|
||||
y = this.drawSubCategories(section.subCategories, y, isTracing, section.key);
|
||||
}
|
||||
|
||||
y += SECTION_GAP;
|
||||
return y;
|
||||
}
|
||||
|
||||
private drawSubCategories(
|
||||
subCategories: PinyinSubCategory[],
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
sectionKey: string,
|
||||
): number {
|
||||
const ctx = this.ctx;
|
||||
let y = startY;
|
||||
|
||||
ctx.font = SUB_LABEL_FONT;
|
||||
ctx.fillStyle = SUB_LABEL_COLOR;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText('其中——', MARGIN_X, y);
|
||||
y += QIZHONG_GAP;
|
||||
|
||||
const subX = MARGIN_X + SUB_INDENT;
|
||||
|
||||
if (sectionKey === 'shengmu') {
|
||||
y = this.drawShengmuSubs(subCategories, subX, y, isTracing);
|
||||
} else {
|
||||
for (const sub of subCategories) {
|
||||
y = this.drawLabeledBlock(
|
||||
`${sub.label}(${sub.count}个)`,
|
||||
sub.items,
|
||||
subX,
|
||||
y,
|
||||
isTracing,
|
||||
);
|
||||
y += SUB_BLOCK_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
|
||||
private drawShengmuSubs(
|
||||
subs: PinyinSubCategory[],
|
||||
subX: number,
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
): number {
|
||||
const qiaoshe = subs.find((s) => s.key === 'qiaoshe');
|
||||
const pingshe = subs.find((s) => s.key === 'pingshe');
|
||||
const col2X = MARGIN_X + Math.round(this.contentW / 2);
|
||||
|
||||
let bottomY = startY;
|
||||
|
||||
if (qiaoshe) {
|
||||
const endY = this.drawLabeledBlock(
|
||||
`翘舌音(${qiaoshe.count}个)`,
|
||||
qiaoshe.items,
|
||||
subX,
|
||||
startY,
|
||||
isTracing,
|
||||
);
|
||||
bottomY = Math.max(bottomY, endY);
|
||||
}
|
||||
|
||||
if (pingshe) {
|
||||
const endY = this.drawLabeledBlock(
|
||||
`平舌音(${pingshe.count}个)`,
|
||||
pingshe.items,
|
||||
col2X,
|
||||
startY,
|
||||
isTracing,
|
||||
);
|
||||
bottomY = Math.max(bottomY, endY);
|
||||
}
|
||||
|
||||
return bottomY + SUB_BLOCK_GAP;
|
||||
}
|
||||
|
||||
private drawLabeledBlock(
|
||||
label: string,
|
||||
items: string[],
|
||||
startX: number,
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
): number {
|
||||
const ctx = this.ctx;
|
||||
let y = startY;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = SUB_LABEL_FONT;
|
||||
ctx.fillStyle = SECTION_TITLE_COLOR;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(label, startX, y);
|
||||
ctx.restore();
|
||||
|
||||
y += SUB_LABEL_H + SUB_LABEL_GAP;
|
||||
|
||||
return this.drawItemRows(items, startX, y, isTracing, this.cellW, this.currentCellH);
|
||||
}
|
||||
|
||||
// ── V2:8 列 + 扁平分块 ──
|
||||
|
||||
private drawV2Content(startY: number, isTracing: boolean): void {
|
||||
const sections = [
|
||||
{ title: '声母', count: 23, items: SHENGMU },
|
||||
{ title: '单韵母', count: 6, items: DAN_YUNMU },
|
||||
{ title: '复韵母', count: 8, items: FU_YUNMU },
|
||||
{ title: '前鼻韵母', count: 5, items: QIANBI_YUNMU },
|
||||
{ title: '后鼻韵母', count: 4, items: HOUBI_YUNMU },
|
||||
{ title: '特殊韵母', count: 1, items: TESHU_YUNMU },
|
||||
{ title: '整体认读音节', count: 16, items: ZHENGTI_RENDU },
|
||||
{ title: '平舌音', count: 3, items: PINGSHEYIN },
|
||||
{ title: '翘舌音', count: 4, items: QIAOSHEYIN },
|
||||
];
|
||||
|
||||
let y = startY;
|
||||
for (const section of sections) {
|
||||
y = this.drawTitle(section.title, section.count, y);
|
||||
y += TITLE_AFTER_GAP;
|
||||
y = this.drawFixedColsRows(section.items, MARGIN_X, y, isTracing, 8);
|
||||
y += SECTION_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 共用绘制方法 ──
|
||||
|
||||
private drawTitle(title: string, count: number, y: number): number {
|
||||
const ctx = this.ctx;
|
||||
ctx.font = SECTION_TITLE_FONT;
|
||||
ctx.fillStyle = SECTION_TITLE_COLOR;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(title, MARGIN_X, y);
|
||||
|
||||
const titleW = ctx.measureText(title).width;
|
||||
ctx.fillStyle = SUB_LABEL_COLOR;
|
||||
ctx.fillText(`(${count}个)`, MARGIN_X + titleW, y);
|
||||
|
||||
return y;
|
||||
}
|
||||
|
||||
private drawItemRows(
|
||||
items: string[],
|
||||
startX: number,
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
cellW: number,
|
||||
cellH: number,
|
||||
): number {
|
||||
const maxWidth = this.canvasWidth - startX - MARGIN_X;
|
||||
const maxCols = Math.max(1, Math.floor(maxWidth / cellW));
|
||||
return this.drawFixedColsRows(items, startX, startY, isTracing, maxCols);
|
||||
}
|
||||
|
||||
private drawFixedColsRows(
|
||||
items: string[],
|
||||
startX: number,
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
cols: number,
|
||||
): number {
|
||||
let y = startY;
|
||||
const cellW = this.cellW;
|
||||
const cellH = this.currentCellH;
|
||||
|
||||
for (let i = 0; i < items.length; i += cols) {
|
||||
const rowItems = items.slice(i, i + cols);
|
||||
|
||||
this.drawFourLineRow(startX, y, cols, cellW, cellH);
|
||||
|
||||
if (isTracing) {
|
||||
for (let j = 0; j < rowItems.length; j++) {
|
||||
const cx = startX + j * cellW + cellW / 2;
|
||||
this.drawPinyinText(rowItems[j], cx, y, cellH, this.currentFontSizeRatio);
|
||||
}
|
||||
}
|
||||
|
||||
y += cellH + this.currentRowGap;
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
}
|
||||
@@ -1,327 +1,33 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import {
|
||||
PINYIN_SECTIONS,
|
||||
type PinyinSection,
|
||||
type PinyinSubCategory,
|
||||
} from '../../../core/data/pinyin';
|
||||
import {
|
||||
GRID_COLORS,
|
||||
GRID_DASH,
|
||||
TRACING_COLORS,
|
||||
} from '../../../core/data/tracingStyles';
|
||||
import type { PinyinDictationMode } from '../pinyinDictation.config';
|
||||
import { TONEOZ_PINYIN, fontFamilyOf } from '../../../core/font/fontProfiles';
|
||||
import PinyinDictationSheetDraw from './PinyinDictationSheetDraw';
|
||||
import PinyinDailyDraw from './PinyinDailyDraw';
|
||||
|
||||
export interface PinyinDictationData {
|
||||
mode: PinyinDictationMode;
|
||||
dayIndex?: number;
|
||||
}
|
||||
|
||||
// ── 布局 ──
|
||||
|
||||
const MARGIN_X = 24; // 左右页边距
|
||||
const MIN_CELL_W = 44; // 格子最小宽度,实际宽度按内容区等分后可能略大
|
||||
const CELL_H = 30; // 四线三格高度
|
||||
const ROW_GAP = 5; // 同一分类中,行与行之间的纵向间距
|
||||
const SUB_INDENT = 20; // 子分类(翘舌音、单韵母等)相对 MARGIN_X 的额外缩进
|
||||
|
||||
// ── 垂直间距 ──
|
||||
|
||||
const CONTENT_TOP_GAP = 8; // Header 分割线到第一个分类标题的间距
|
||||
const TITLE_AFTER_GAP = 24; // 分类标题(一、声母)到其下方格子的间距
|
||||
const SECTION_GAP = 8; // 大分类之间(声母 ↔ 韵母 ↔ 整体认读)的间距
|
||||
const SECTION_TO_SUB_GAP = 8; // 主格子到「其中——」的间距
|
||||
const QIZHONG_GAP = 16; // 「其中——」到第一个子分类块的间距
|
||||
const SUB_LABEL_H = 14; // 子分类标签文字占用高度
|
||||
const SUB_LABEL_GAP = 4; // 子分类标签到其下方格子的间距
|
||||
const SUB_BLOCK_GAP = 8; // 相邻子分类块之间的间距
|
||||
|
||||
// ── 字体 & 颜色 ──
|
||||
|
||||
const SECTION_TITLE_FONT = 'bold 14px "Microsoft Yahei"'; // 大分类标题
|
||||
const SUB_LABEL_FONT = '11px "Microsoft Yahei"'; // 子分类标签
|
||||
const SECTION_TITLE_COLOR = '#322E25'; // 标题/标签文字色
|
||||
const SUB_LABEL_COLOR = '#c0392b'; // 「其中——」文字色
|
||||
|
||||
/**
|
||||
* 拼音听写/描红绘制分发层。
|
||||
* 根据 data.mode 委托给对应的独立 DrawService 完成实际绘制。
|
||||
*/
|
||||
export default class PinyinDictationDraw extends BaseDrawService {
|
||||
private cellW = MIN_CELL_W;
|
||||
private contentW = 0;
|
||||
|
||||
async draw(data: PinyinDictationData) {
|
||||
this.prepareDraw();
|
||||
|
||||
this.contentW = this.canvasWidth - 2 * MARGIN_X;
|
||||
const maxCols = Math.floor(this.contentW / MIN_CELL_W);
|
||||
this.cellW = this.contentW / maxCols;
|
||||
|
||||
await this.drawHeaderAndDivider();
|
||||
this.drawContent(data);
|
||||
}
|
||||
|
||||
private drawContent(data: PinyinDictationData) {
|
||||
const isTracing = data.mode === 'pinyin-tracing';
|
||||
let y = this.currentY + CONTENT_TOP_GAP;
|
||||
|
||||
for (const section of PINYIN_SECTIONS) {
|
||||
y = this.drawSection(section, y, isTracing);
|
||||
}
|
||||
}
|
||||
|
||||
private drawSection(
|
||||
section: PinyinSection,
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
): number {
|
||||
const ctx = this.ctx;
|
||||
let y = startY;
|
||||
|
||||
ctx.font = SECTION_TITLE_FONT;
|
||||
ctx.fillStyle = SECTION_TITLE_COLOR;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(`${section.title}(${section.count}个)`, MARGIN_X, y);
|
||||
y += TITLE_AFTER_GAP;
|
||||
|
||||
y = this.drawItemRows(
|
||||
section.items,
|
||||
MARGIN_X,
|
||||
y,
|
||||
isTracing,
|
||||
this.cellW,
|
||||
CELL_H,
|
||||
);
|
||||
|
||||
if (section.subCategories && section.subCategories.length > 0) {
|
||||
y += SECTION_TO_SUB_GAP;
|
||||
y = this.drawSubCategories(
|
||||
section.subCategories,
|
||||
y,
|
||||
isTracing,
|
||||
section.key,
|
||||
if (data.mode === 'pinyin-daily') {
|
||||
const delegate = new PinyinDailyDraw(
|
||||
this.canvas,
|
||||
this.ctx,
|
||||
this.options,
|
||||
);
|
||||
}
|
||||
|
||||
y += SECTION_GAP;
|
||||
return y;
|
||||
}
|
||||
|
||||
private drawSubCategories(
|
||||
subCategories: PinyinSubCategory[],
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
sectionKey: string,
|
||||
): number {
|
||||
const ctx = this.ctx;
|
||||
let y = startY;
|
||||
|
||||
ctx.font = SUB_LABEL_FONT;
|
||||
ctx.fillStyle = SUB_LABEL_COLOR;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText('其中——', MARGIN_X, y);
|
||||
y += QIZHONG_GAP;
|
||||
|
||||
const subX = MARGIN_X + SUB_INDENT;
|
||||
|
||||
if (sectionKey === 'shengmu') {
|
||||
y = this.drawShengmuSubs(subCategories, subX, y, isTracing);
|
||||
await delegate.draw(data.dayIndex ?? 0);
|
||||
} else {
|
||||
for (const sub of subCategories) {
|
||||
y = this.drawLabeledSubBlock(
|
||||
`${sub.label}(${sub.count}个)`,
|
||||
sub.items,
|
||||
subX,
|
||||
y,
|
||||
isTracing,
|
||||
);
|
||||
y += SUB_BLOCK_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
|
||||
/**
|
||||
* 声母子分类:翘舌音 / 平舌音左右并列,标签在格子上方
|
||||
*/
|
||||
private drawShengmuSubs(
|
||||
subs: PinyinSubCategory[],
|
||||
subX: number,
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
): number {
|
||||
const qiaoshe = subs.find((s) => s.key === 'qiaoshe');
|
||||
const pingshe = subs.find((s) => s.key === 'pingshe');
|
||||
const col2X = MARGIN_X + Math.round(this.contentW / 2);
|
||||
|
||||
let bottomY = startY;
|
||||
|
||||
if (qiaoshe) {
|
||||
const endY = this.drawLabeledSubBlock(
|
||||
`翘舌音(${qiaoshe.count}个)`,
|
||||
qiaoshe.items,
|
||||
subX,
|
||||
startY,
|
||||
isTracing,
|
||||
const delegate = new PinyinDictationSheetDraw(
|
||||
this.canvas,
|
||||
this.ctx,
|
||||
this.options,
|
||||
);
|
||||
bottomY = Math.max(bottomY, endY);
|
||||
await delegate.draw(data);
|
||||
}
|
||||
|
||||
if (pingshe) {
|
||||
const endY = this.drawLabeledSubBlock(
|
||||
`平舌音(${pingshe.count}个)`,
|
||||
pingshe.items,
|
||||
col2X,
|
||||
startY,
|
||||
isTracing,
|
||||
);
|
||||
bottomY = Math.max(bottomY, endY);
|
||||
}
|
||||
|
||||
return bottomY + SUB_BLOCK_GAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带标签的子分类块:标签在格子上方
|
||||
*/
|
||||
private drawLabeledSubBlock(
|
||||
label: string,
|
||||
items: string[],
|
||||
startX: number,
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
): number {
|
||||
const ctx = this.ctx;
|
||||
let y = startY;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = SUB_LABEL_FONT;
|
||||
ctx.fillStyle = SECTION_TITLE_COLOR;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(label, startX, y);
|
||||
ctx.restore();
|
||||
|
||||
y += SUB_LABEL_H + SUB_LABEL_GAP;
|
||||
|
||||
return this.drawItemRows(
|
||||
items,
|
||||
startX,
|
||||
y,
|
||||
isTracing,
|
||||
this.cellW,
|
||||
CELL_H,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制四线三格拼音行,同一行的格子共享边框
|
||||
*/
|
||||
private drawItemRows(
|
||||
items: string[],
|
||||
startX: number,
|
||||
startY: number,
|
||||
isTracing: boolean,
|
||||
cellW: number,
|
||||
cellH: number,
|
||||
): number {
|
||||
const maxWidth = this.canvasWidth - startX - MARGIN_X;
|
||||
const maxCols = Math.max(1, Math.floor(maxWidth / cellW));
|
||||
let y = startY;
|
||||
|
||||
for (let i = 0; i < items.length; i += maxCols) {
|
||||
const rowItems = items.slice(i, i + maxCols);
|
||||
const cols = rowItems.length;
|
||||
|
||||
this.drawFourLineRow(startX, y, cols, cellW, cellH);
|
||||
|
||||
if (isTracing) {
|
||||
for (let j = 0; j < cols; j++) {
|
||||
const cx = startX + j * cellW + cellW / 2;
|
||||
this.drawPinyinText(rowItems[j], cx, y, cellH);
|
||||
}
|
||||
}
|
||||
|
||||
y += cellH + ROW_GAP;
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制一行四线三格
|
||||
*/
|
||||
private drawFourLineRow(
|
||||
x: number,
|
||||
y: number,
|
||||
cols: number,
|
||||
cellW: number,
|
||||
cellH: number,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
const totalW = cols * cellW;
|
||||
const yTop = y;
|
||||
const y1 = y + cellH / 3;
|
||||
const y2 = y + (cellH * 2) / 3;
|
||||
const yBot = y + cellH;
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.strokeStyle = GRID_COLORS.border;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yTop);
|
||||
ctx.lineTo(x + totalW, yTop);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = GRID_COLORS.middleLine;
|
||||
ctx.setLineDash([...GRID_DASH]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y1);
|
||||
ctx.lineTo(x + totalW, y1);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y2);
|
||||
ctx.lineTo(x + totalW, y2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = GRID_COLORS.border;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yBot);
|
||||
ctx.lineTo(x + totalW, yBot);
|
||||
ctx.stroke();
|
||||
|
||||
for (let i = 0; i <= cols; i++) {
|
||||
const vx = x + i * cellW;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(vx, yTop);
|
||||
ctx.lineTo(vx, yBot);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在四线三格中绘制拼音:
|
||||
* alphabetic 基线对齐第 3 条线(中下虚线),
|
||||
* 小写字母主体填充中间格(第 2~3 线之间),
|
||||
* 声调标记占据上格(第 1~2 线之间)。
|
||||
*/
|
||||
private drawPinyinText(
|
||||
pinyin: string,
|
||||
cx: number,
|
||||
gridY: number,
|
||||
gridH: number,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
const fontSize = Math.round(gridH * 0.65);
|
||||
const baselineY = gridY + (gridH * 2) / 3;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `${fontSize}px ${fontFamilyOf(TONEOZ_PINYIN)}`;
|
||||
ctx.fillStyle = TRACING_COLORS.guide;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillText(pinyin, cx, baselineY);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user