feat:九九乘法表

This commit is contained in:
R524809
2026-01-16 18:02:16 +08:00
parent 7dd10c7902
commit 9c13b44806
25 changed files with 1388 additions and 13 deletions
@@ -0,0 +1,216 @@
/**
* 计算练习题绘制服务
*/
import { BaseDrawService } from '../../../service/baseDraw';
/**
* 计算练习题数据
*/
export interface CalculationPracticeData {
problems: Array<{
left: number;
operator: '+' | '-';
right: number;
result: number;
}>;
}
/**
* 计算练习题绘制服务
*/
class CalculationPracticeDraw extends BaseDrawService {
practiceData: CalculationPracticeData | null = null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.practiceData = null;
}
/**
* 绘制计算练习题内容
*/
async draw(practiceData: CalculationPracticeData) {
if (!practiceData || !practiceData.problems) {
return;
}
this.practiceData = practiceData;
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
problems: this.practiceData.problems,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
/**
* 绘制内容区域
*/
private async drawContent(params: {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
problems: Array<{
left: number;
operator: '+' | '-';
right: number;
result: number;
}>;
canvasWidth: number;
startY: number;
}): Promise<void> {
const { ctx, problems, canvasWidth, startY } = params;
const rowStartY = startY + 30; // 顶部间距
const margin = 30;
const rows = 12;
const cols = 3;
const rowSpacing = 60; // 行间距
const colSpacing = 15; // 列间距
// 计算可用宽度
const availableWidth = canvasWidth - margin * 2;
const colWidth = (availableWidth - colSpacing * (cols - 1)) / cols;
// 数字和符号相关参数
const fontSize = 18;
const numberSpacing = 5; // 数字之间的间距
const boxSize = 28; // 答案框大小
const boxBorderColor = '#555'; // 答案框边框颜色
const symbolWidth = 16; // 符号宽度
// 计算每道题的起始位置(10行3列)
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const problemIndex = row * cols + col;
if (problemIndex >= problems.length) {
break;
}
const problem = problems[problemIndex];
const problemX = margin + col * (colWidth + colSpacing);
const problemY = rowStartY + row * rowSpacing;
// 绘制单道题
this.drawSingleProblem(
ctx,
problemX,
problemY,
colWidth,
problem,
{
fontSize,
numberSpacing,
boxSize,
boxBorderColor,
symbolWidth,
},
);
}
}
}
/**
* 绘制单道题
*/
private drawSingleProblem(
ctx: RenderingContext,
x: number,
y: number,
width: number,
problem: {
left: number;
operator: '+' | '-';
right: number;
result: number;
},
options: {
fontSize: number;
numberSpacing: number;
boxSize: number;
boxBorderColor: string;
symbolWidth: number;
},
) {
const {
fontSize,
numberSpacing,
boxSize,
boxBorderColor,
symbolWidth,
} = options;
// 计算每个元素的宽度(统一使用,确保对齐)
const numberWidth = fontSize * 1.5; // 数字宽度(考虑两位数)
// const elementWidth = Math.max(numberWidth, boxSize);
const elementWidth = numberWidth;
// 计算总宽度并居中
const totalWidth =
elementWidth +
numberSpacing +
symbolWidth +
numberSpacing +
elementWidth +
numberSpacing +
symbolWidth +
numberSpacing +
elementWidth;
const startX = x + (width - totalWidth) / 2;
let currentX = startX;
// 答案框的Y坐标
const resultBoxY = y;
// 数字和符号的Y坐标(与答案框垂直居中对齐)
const centerY = resultBoxY + boxSize / 2;
// 绘制左侧数字
const leftCenterX = currentX + elementWidth / 2;
ctx.fillStyle = '#000';
ctx.font = `${fontSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(problem.left), leftCenterX, centerY);
currentX += elementWidth + numberSpacing;
// 绘制运算符(与数字垂直居中对齐)
const operatorX = currentX + symbolWidth / 2;
this.drawSymbol(
operatorX,
centerY,
problem.operator,
symbolWidth * 0.8,
);
currentX = operatorX + symbolWidth / 2 + numberSpacing;
// 绘制右侧数字
const rightCenterX = currentX + elementWidth / 2;
ctx.fillText(String(problem.right), rightCenterX, centerY);
currentX += elementWidth + numberSpacing;
// 绘制等号(与数字垂直居中对齐)
const equalsX = currentX + symbolWidth / 2;
this.drawSymbol(equalsX, centerY, '=', symbolWidth * 0.8);
currentX = equalsX + symbolWidth / 2 + numberSpacing;
// 绘制答案框
const resultBoxX = currentX + (elementWidth - boxSize) / 2;
this.drawRoundedRect(resultBoxX, resultBoxY, boxSize, boxSize, {
isDashed: false,
radius: 4,
color: boxBorderColor,
lineWidth: 1,
});
}
}
export default CalculationPracticeDraw;
@@ -0,0 +1,199 @@
/**
* 九九乘法表绘制服务
*/
import { BaseDrawService } from '../../../service/baseDraw';
import { getRandomUniqueNumberColors } from '../../../constants/colors';
/**
* 数字转中文(1-99
*/
function numberToChinese(num: number): string {
const digits = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
const tens = [
'',
'十',
'二十',
'三十',
'四十',
'五十',
'六十',
'七十',
'八十',
'九十',
];
if (num === 0) return '零';
if (num < 10) return digits[num];
if (num === 10) return '十';
if (num < 20) return '十' + digits[num % 10];
if (num % 10 === 0) return tens[Math.floor(num / 10)];
return tens[Math.floor(num / 10)] + digits[num % 10];
}
/**
* 生成九九乘法表汉字口诀
* @param a 第一个乘数
* @param b 第二个乘数
* @param result 结果
*/
function getChineseFormula(a: number, b: number, result: number): string {
const aChinese = numberToChinese(a);
const bChinese = numberToChinese(b);
const resultChinese = numberToChinese(result);
return `${aChinese}${bChinese}${resultChinese}`;
}
/**
* 九九乘法表数据
*/
export interface MultiplicationTableData {
// 不需要额外数据,九九乘法表是固定的
}
/**
* 九九乘法表绘制服务
*/
class MultiplicationTableDraw extends BaseDrawService {
tableData: MultiplicationTableData | null = null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.tableData = null;
}
/**
* 绘制九九乘法表内容
*/
async draw(tableData?: MultiplicationTableData) {
this.tableData = tableData || {};
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
/**
* 绘制内容区域
*/
private async drawContent(params: {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
canvasWidth: number;
startY: number;
}): Promise<void> {
const { ctx, canvasWidth, startY } = params;
// 获取9种颜色(1-9列,每列一种颜色)
const colors = getRandomUniqueNumberColors(9);
// 布局参数
const margin = 30; // 左右边距
const topMargin = 40; // 顶部间距
const sectionSpacing = 40; // 上下两部分之间的间距
const dividerMargin = 20; // 分割线左右边距
// 计算可用宽度
const availableWidth = canvasWidth - margin * 2;
const cellWidth = availableWidth / 9; // 每列宽度
const rowHeight = 26; // 每行高度(减小行高)
const fontSize = 12; // 字体大小(减小字体)
let currentY = startY + topMargin;
// ========== 绘制数字版九九乘法表 ==========
const numberTableStartY = currentY;
// 绘制表格边框和内容
for (let row = 1; row <= 9; row++) {
const rowY = numberTableStartY + (row - 1) * rowHeight;
for (let col = 1; col <= row; col++) {
const colX = margin + (col - 1) * cellWidth;
const color = colors[col - 1];
// 绘制单元格边框
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.strokeRect(colX, rowY, cellWidth, rowHeight);
// 生成公式:col x row = result
const result = col * row;
const formula = `${col}×${row}=${result}`;
// 绘制公式(带颜色)
ctx.fillStyle = color;
ctx.font = `bold ${fontSize}px "Microsoft Yahei"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
formula,
colX + cellWidth / 2,
rowY + rowHeight / 2,
);
}
}
// 数字版结束位置
const numberTableEndY = numberTableStartY + 9 * rowHeight;
currentY = numberTableEndY + sectionSpacing;
// ========== 绘制虚线分割线 ==========
this.drawLine(
dividerMargin,
currentY,
canvasWidth - dividerMargin,
currentY,
{
isDashed: true,
dashPattern: [4, 4],
color: '#999',
lineWidth: 1,
},
);
currentY += sectionSpacing;
// ========== 绘制汉字版九九乘法表 ==========
const chineseTableStartY = currentY;
const hzFontSize = 8;
// 绘制表格边框和内容
for (let row = 1; row <= 9; row++) {
const rowY = chineseTableStartY + (row - 1) * rowHeight;
for (let col = 1; col <= row; col++) {
const colX = margin + (col - 1) * cellWidth;
const color = colors[col - 1]; // 使用和数字版相同的颜色
// 绘制单元格边框
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.strokeRect(colX, rowY, cellWidth, rowHeight);
// 生成汉字口诀
const result = col * row;
const chineseFormula = getChineseFormula(col, row, result);
// 绘制汉字口诀(带颜色)
ctx.fillStyle = color;
ctx.font = `bold ${hzFontSize}px "Microsoft Yahei"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
chineseFormula,
colX + cellWidth / 2,
rowY + rowHeight / 2,
);
}
}
}
}
export default MultiplicationTableDraw;
@@ -0,0 +1,366 @@
/**
* 一位数加法绘制服务
*/
import { BaseDrawService } from '../../../service/baseDraw';
import { getRandomNumberColor } from '../../../constants/colors';
/**
* 一位数加法数据
*/
export interface OneDigitAdditionData {
problems: Array<{
left: number;
right: number;
result: number;
}>;
}
/**
* 一位数加法绘制服务
*/
class OneDigitAdditionDraw extends BaseDrawService {
additionData: OneDigitAdditionData | null = null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.additionData = null;
}
/**
* 绘制一位数加法内容
*/
async draw(additionData: OneDigitAdditionData) {
if (!additionData || !additionData.problems) {
return;
}
this.additionData = additionData;
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
problems: this.additionData.problems,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
/**
* 绘制内容区域
*/
private async drawContent(params: {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
problems: Array<{
left: number;
right: number;
result: number;
}>;
canvasWidth: number;
startY: number;
}): Promise<void> {
const { ctx, problems, canvasWidth, startY } = params;
const rowStartY = startY + 30; // 顶部间距
const margin = 50;
const rows = 6;
const rowSpacing = 110; // 行间距
// 计算可用宽度(单列居中)
const availableWidth = canvasWidth - margin * 2;
const colCenterX = canvasWidth / 2; // 列的中心点
// 圆点相关参数(适度调大)
const dotRadius = 5; // 圆点半径(调大)
const dotSpacing = 8; // 圆点之间的间距(调大)
const dotsPerRow = 5; // 每行最多5个圆点
const dotRowSpacing = 8; // 圆点行之间的间距(调大)
// 数字和符号相关参数
const fontSize = 26;
const numberSpacing = 14; // 数字之间的间距
const boxSize = 36; // 答案框大小
const boxBorderColor = '#555'; // 答案框边框颜色
// 计算每道题的起始位置(6行1列)
for (let row = 0; row < rows; row++) {
const problemIndex = row;
if (problemIndex >= problems.length) {
break;
}
const problem = problems[problemIndex];
const problemY = rowStartY + row * rowSpacing;
// 绘制单道题(在页面中居中)
this.drawSingleProblem(
ctx,
colCenterX - availableWidth / 2, // 列的起始X坐标
problemY,
availableWidth, // 使用可用宽度
problem,
{
dotRadius,
dotSpacing,
dotsPerRow,
dotRowSpacing,
fontSize,
numberSpacing,
boxSize,
boxBorderColor,
},
);
// 绘制行之间的虚线分割(除了最后一行)
if (row < rows - 1) {
const dividerY = rowStartY + (row + 1) * rowSpacing - 20;
this.drawDashedDivider(dividerY, margin);
}
}
}
/**
* 绘制单道题
*/
private drawSingleProblem(
ctx: RenderingContext,
x: number,
y: number,
width: number,
problem: { left: number; right: number; result: number },
options: {
dotRadius: number;
dotSpacing: number;
dotsPerRow: number;
dotRowSpacing: number;
fontSize: number;
numberSpacing: number;
boxSize: number;
boxBorderColor: string;
},
) {
const {
dotRadius,
dotSpacing,
dotsPerRow,
dotRowSpacing,
fontSize,
numberSpacing,
boxSize,
boxBorderColor,
} = options;
// 为整个运算公式生成统一的随机颜色
const problemColor = getRandomNumberColor();
// 计算5个圆点的最大宽度(统一使用,确保间距一致)
const maxDotsWidth = this.calculateDotsWidth(
dotsPerRow, // 使用5个圆点的宽度
dotsPerRow,
dotRadius,
dotSpacing,
);
// 计算每个元素的宽度(统一使用5个圆点的宽度)
const leftElementWidth = Math.max(maxDotsWidth, fontSize);
const rightElementWidth = Math.max(maxDotsWidth, fontSize);
const resultElementWidth = Math.max(maxDotsWidth, boxSize);
// 计算总宽度并居中
const symbolWidth = 24; // 符号宽度(缩小)
const totalWidth =
leftElementWidth +
numberSpacing +
symbolWidth + // 加号宽度
numberSpacing +
rightElementWidth +
numberSpacing +
symbolWidth + // 等号宽度
numberSpacing +
resultElementWidth;
const startX = x + (width - totalWidth) / 2;
let currentX = startX;
// 计算统一的圆点区域高度(始终使用两行的高度,保持每行高度一致)
const dotsAreaHeight = 2 * dotRadius * 2 + dotRowSpacing; // 2行的高度
// 圆点区域的底部Y坐标(所有圆点都从底部开始绘制)
const dotsBottomY = y + dotsAreaHeight;
// 数字/方框的Y坐标(下方,与圆点底部对齐)
const resultBoxY = dotsBottomY + 12;
const numberY = resultBoxY + (boxSize - fontSize) / 2 + 2;
// 绘制左侧圆点和数字
const leftCenterX = currentX + leftElementWidth / 2;
const leftDotsWidth = this.calculateDotsWidth(
problem.left,
dotsPerRow,
dotRadius,
dotSpacing,
);
this.drawDots(
ctx,
leftCenterX - leftDotsWidth / 2,
dotsBottomY, // 从底部开始绘制
problem.left,
problemColor,
dotRadius,
dotSpacing,
dotsPerRow,
dotRowSpacing,
);
ctx.fillStyle = '#000';
ctx.font = `bold ${fontSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(String(problem.left), leftCenterX, numberY);
currentX += leftElementWidth + numberSpacing;
// 绘制加号(与数字垂直居中对齐)
const plusX = currentX + symbolWidth / 2;
const plusY = numberY + fontSize / 2;
this.drawSymbol(plusX, plusY, '+', symbolWidth * 0.8);
currentX = plusX + symbolWidth / 2 + numberSpacing;
// 绘制右侧圆点和数字
const rightCenterX = currentX + rightElementWidth / 2;
const rightDotsWidth = this.calculateDotsWidth(
problem.right,
dotsPerRow,
dotRadius,
dotSpacing,
);
this.drawDots(
ctx,
rightCenterX - rightDotsWidth / 2,
dotsBottomY, // 从底部开始绘制
problem.right,
problemColor,
dotRadius,
dotSpacing,
dotsPerRow,
dotRowSpacing,
);
ctx.fillStyle = '#000';
ctx.fillText(String(problem.right), rightCenterX, numberY);
currentX += rightElementWidth + numberSpacing;
// 绘制等号(与数字垂直居中对齐)
const equalsX = currentX + symbolWidth / 2;
const equalsY = numberY + fontSize / 2;
this.drawSymbol(equalsX, equalsY, '=', symbolWidth * 0.8);
currentX = equalsX + symbolWidth / 2 + numberSpacing;
// 绘制结果圆点和答案框
const resultCenterX = currentX + resultElementWidth / 2;
const resultDotsWidth = this.calculateDotsWidth(
problem.result,
dotsPerRow,
dotRadius,
dotSpacing,
);
this.drawDots(
ctx,
resultCenterX - resultDotsWidth / 2,
dotsBottomY, // 从底部开始绘制
problem.result,
problemColor,
dotRadius,
dotSpacing,
dotsPerRow,
dotRowSpacing,
);
// 绘制答案框(与数字垂直对齐)
const resultBoxX = resultCenterX - boxSize / 2;
this.drawRoundedRect(resultBoxX, resultBoxY, boxSize, boxSize, {
isDashed: false,
radius: 4,
color: boxBorderColor,
lineWidth: 1,
});
}
/**
* 绘制圆点
* y 参数是圆点区域的底部Y坐标
* 先绘制下面一行(满5个后),再绘制上面一行
*/
private drawDots(
ctx: RenderingContext,
x: number,
y: number, // 圆点区域的底部Y坐标
count: number,
color: string,
radius: number,
spacing: number,
dotsPerRow: number,
rowSpacing: number,
) {
for (let i = 0; i < count; i++) {
let row: number;
let col: number;
if (count <= dotsPerRow) {
// 只有一行,在底部(row = 0 表示最下面一行)
row = 0;
col = i;
} else {
// 多行情况:先绘制下面一行(前5个),再绘制上面一行(剩余的)
if (i < dotsPerRow) {
// 下面一行(row = 0 表示最下面一行)
row = 0;
col = i;
} else {
// 上面一行(row = 1 表示上面一行)
row = 1;
col = i - dotsPerRow;
}
}
// 从底部向上计算Y坐标
// row = 0 时在最下面,row = 1 时在上面
const dotX = x + col * (radius * 2 + spacing);
const dotY = y - (row * (radius * 2 + rowSpacing) + radius * 2);
this.drawDot(ctx, dotX + radius, dotY + radius, radius, color);
}
}
/**
* 计算圆点区域的宽度
*/
private calculateDotsWidth(
count: number,
dotsPerRow: number,
radius: number,
spacing: number,
): number {
const actualCols = Math.min(count, dotsPerRow);
if (actualCols === 0) return 0;
return actualCols * radius * 2 + (actualCols - 1) * spacing;
}
/**
* 计算圆点区域的高度
*/
private calculateDotsHeight(
count: number,
dotsPerRow: number,
radius: number,
rowSpacing: number,
): number {
const rows = Math.ceil(count / dotsPerRow);
if (rows === 0) return 0;
return rows * radius * 2 + (rows - 1) * rowSpacing;
}
}
export default OneDigitAdditionDraw;