328 lines
9.2 KiB
TypeScript
328 lines
9.2 KiB
TypeScript
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';
|
||
|
||
export interface PinyinDictationData {
|
||
mode: PinyinDictationMode;
|
||
}
|
||
|
||
// ── 布局 ──
|
||
|
||
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'; // 「其中——」文字色
|
||
|
||
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,
|
||
);
|
||
}
|
||
|
||
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.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,
|
||
);
|
||
bottomY = Math.max(bottomY, endY);
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|