297 lines
8.4 KiB
TypeScript
297 lines
8.4 KiB
TypeScript
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;
|
||
}
|
||
}
|