Files
doodle-mini/miniprogram/mathPages/shared/service/numberDecomposeDraw.ts
T
2026-04-09 17:43:22 +08:00

573 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 10以内数的分与合绘制服务
*/
import { BaseDrawService } from '../../../core/draw/baseDraw';
import { getImage } from '../../../utils/index';
import { getRandomNumberColor } from '../../../constants/colors';
/**
* 10以内数的分与合数据
*/
export interface NumberDecomposeData {
problems: Array<{
whole: number | null;
part1: number | null;
part2: number | null;
imageIndex?: number;
imageType?: 'fruits' | 'twelve-animals';
}>;
mode: 'with-image' | 'decompose' | 'compose';
}
/**
* 10以内数的分与合绘制服务
*/
class NumberDecomposeDraw extends BaseDrawService {
decomposeData: NumberDecomposeData | null = null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.decomposeData = null;
}
/**
* 绘制10以内数的分与合内容
*/
async draw(decomposeData: NumberDecomposeData) {
if (!decomposeData || !decomposeData.problems) {
return;
}
this.decomposeData = decomposeData;
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
decomposeData: this.decomposeData,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
/**
* 绘制内容区域
*/
private async drawContent(params: {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
decomposeData: NumberDecomposeData;
canvasWidth: number;
startY: number;
}): Promise<void> {
const { canvas, ctx, decomposeData, canvasWidth, startY } = params;
const { problems, mode } = decomposeData;
if (mode === 'with-image') {
await this.drawWithImageMode(
canvas,
ctx,
problems,
canvasWidth,
startY,
);
} else if (mode === 'decompose') {
await this.drawDecomposeOrComposeMode(
ctx,
problems,
canvasWidth,
startY,
false,
);
} else if (mode === 'compose') {
await this.drawDecomposeOrComposeMode(
ctx,
problems,
canvasWidth,
startY,
true,
);
}
}
/**
* 有图片模式:一行三列,总共三行(9个题目)
*/
private async drawWithImageMode(
canvas: WechatMiniprogram.Canvas,
ctx: RenderingContext,
problems: Array<{
whole: number | null;
part1: number | null;
part2: number | null;
imageIndex?: number;
imageType?: 'fruits' | 'twelve-animals';
}>,
canvasWidth: number,
startY: number,
) {
const leftMargin = 30;
const rightMargin = 30;
const topMargin = 20;
const boxSpacing = 20;
const rowSpacing = 32;
// 计算每个框的尺寸
const availableWidth = canvasWidth - leftMargin - rightMargin;
const boxWidth = (availableWidth - boxSpacing * 2) / 3; // 3列,2个间距
const imageAreaHeight = 120; // 图片区域高度
const treeAreaHeight = 80; // 二叉树区域高度
let currentY = startY + topMargin;
// 绘制3列3行
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 3; col++) {
const problemIndex = row * 3 + col;
if (problemIndex >= problems.length) {
continue;
}
const problem = problems[problemIndex];
const boxX = leftMargin + col * (boxWidth + boxSpacing);
const boxY =
currentY +
row * (imageAreaHeight + treeAreaHeight + rowSpacing);
// 绘制一个完整的题目框
await this.drawWithImageProblem(
ctx,
canvas,
problem,
boxX,
boxY,
boxWidth,
imageAreaHeight,
treeAreaHeight,
);
}
}
}
/**
* 分模式/组合模式:一行3个,总共5行(15个题目)
* @param isInverted true 为组合模式(倒置二叉树),false 为分模式(正常二叉树)
*/
private async drawDecomposeOrComposeMode(
ctx: RenderingContext,
problems: Array<{
whole: number | null;
part1: number | null;
part2: number | null;
}>,
canvasWidth: number,
startY: number,
isInverted: boolean,
) {
const leftMargin = 30;
const rightMargin = 30;
const topMargin = 20;
const boxSpacing = 15;
const rowSpacing = 140;
// 计算每个框的尺寸
const availableWidth = canvasWidth - leftMargin - rightMargin;
const boxWidth = (availableWidth - boxSpacing * 2) / 3; // 3列,2个间距
const boxHeight = 80; // 二叉树区域高度
let currentY = startY + topMargin;
// 绘制3列5行
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 3; col++) {
const problemIndex = row * 3 + col;
if (problemIndex >= problems.length) {
continue;
}
const problem = problems[problemIndex];
const boxX = leftMargin + col * (boxWidth + boxSpacing);
const boxY = currentY + row * rowSpacing;
// 绘制二叉树
this.drawTree(
ctx,
problem,
boxX,
boxY,
boxWidth,
boxHeight,
isInverted,
);
}
}
}
/**
* 绘制有图片模式的单个题目
*/
private async drawWithImageProblem(
ctx: RenderingContext,
canvas: WechatMiniprogram.Canvas,
problem: {
whole: number | null;
part1: number | null;
part2: number | null;
imageIndex?: number;
imageType?: 'fruits' | 'twelve-animals';
},
boxX: number,
boxY: number,
boxWidth: number,
imageAreaHeight: number,
treeAreaHeight: number,
) {
const borderRadius = 12;
// 绘制实线框(只绘制图片区域的框)
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
this.drawRoundedRectInternal(
ctx,
boxX,
boxY,
boxWidth,
imageAreaHeight,
borderRadius,
false,
);
// 图片区域(增加底部间距,避免与二叉树根节点重叠)
const imageBottomPadding = 20; // 图片区域底部额外间距
const effectiveImageHeight = imageAreaHeight - imageBottomPadding;
if (problem.imageIndex && problem.imageType && problem.whole) {
await this.drawImagesInBox(
ctx,
canvas,
problem.whole,
problem.imageIndex,
problem.imageType,
boxX,
boxY,
boxWidth,
effectiveImageHeight,
);
}
// 二叉树区域(增加与图片区域的间距)
const treeTopSpacing = -5; // 二叉树区域顶部间距
const treeY = boxY + imageAreaHeight + treeTopSpacing;
this.drawTree(
ctx,
problem,
boxX,
treeY,
boxWidth,
treeAreaHeight,
false, // 不是倒置
);
}
/**
* 绘制二叉树结构
* @param isInverted 是否倒置(组合模式为true)
*/
private drawTree(
ctx: RenderingContext,
problem: {
whole: number | null;
part1: number | null;
part2: number | null;
},
boxX: number,
boxY: number,
boxWidth: number,
boxHeight: number,
isInverted: boolean,
) {
const nodeRadius = 18;
const nodeSpacing = 40; // 节点之间的水平间距
const verticalSpacing = 70; // 根节点和子节点的垂直间距
if (isInverted) {
// 组合模式:倒置二叉树
// 顶部:两个子节点(part1, part2
// 底部:根节点(whole,需要填写)
const centerX = boxX + boxWidth / 2;
const topY = boxY + boxHeight / 2 - verticalSpacing / 2;
const bottomY = boxY + boxHeight / 2 + verticalSpacing / 2;
// 绘制两个子节点(顶部)
const leftNodeX = centerX - nodeSpacing / 2 - nodeRadius;
const rightNodeX = centerX + nodeSpacing / 2 + nodeRadius;
// 左子节点
this.drawNode(ctx, leftNodeX, topY, nodeRadius, problem.part1);
// 右子节点
this.drawNode(ctx, rightNodeX, topY, nodeRadius, problem.part2);
// 绘制根节点(底部)
this.drawNode(ctx, centerX, bottomY, nodeRadius, problem.whole);
// 绘制连接线(从子节点到根节点)
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(leftNodeX, topY + nodeRadius);
ctx.lineTo(centerX, bottomY - nodeRadius);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(rightNodeX, topY + nodeRadius);
ctx.lineTo(centerX, bottomY - nodeRadius);
ctx.stroke();
} else {
// 分解模式:正常二叉树
// 顶部:根节点(whole
// 底部:两个子节点(part1, part2,其中一个需要填写)
const centerX = boxX + boxWidth / 2;
const topY = boxY + boxHeight / 2 - verticalSpacing / 2;
const bottomY = boxY + boxHeight / 2 + verticalSpacing / 2;
// 绘制根节点(顶部)
this.drawNode(ctx, centerX, topY, nodeRadius, problem.whole);
// 绘制两个子节点(底部)
const leftNodeX = centerX - nodeSpacing / 2 - nodeRadius;
const rightNodeX = centerX + nodeSpacing / 2 + nodeRadius;
// 左子节点
this.drawNode(ctx, leftNodeX, bottomY, nodeRadius, problem.part1);
// 右子节点
this.drawNode(ctx, rightNodeX, bottomY, nodeRadius, problem.part2);
// 绘制连接线(从根节点到子节点)
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(centerX, topY + nodeRadius);
ctx.lineTo(leftNodeX, bottomY - nodeRadius);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(centerX, topY + nodeRadius);
ctx.lineTo(rightNodeX, bottomY - nodeRadius);
ctx.stroke();
}
}
/**
* 绘制节点(圆圈和数字)
*/
private drawNode(
ctx: RenderingContext,
x: number,
y: number,
radius: number,
value: number | null,
) {
// 绘制圆角正方形
const size = radius * 2; // 正方形边长
const cornerRadius = 8; // 圆角半径
const halfSize = size / 2;
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
// 绘制圆角矩形
ctx.beginPath();
ctx.moveTo(x - halfSize + cornerRadius, y - halfSize);
ctx.lineTo(x + halfSize - cornerRadius, y - halfSize);
ctx.quadraticCurveTo(
x + halfSize,
y - halfSize,
x + halfSize,
y - halfSize + cornerRadius,
);
ctx.lineTo(x + halfSize, y + halfSize - cornerRadius);
ctx.quadraticCurveTo(
x + halfSize,
y + halfSize,
x + halfSize - cornerRadius,
y + halfSize,
);
ctx.lineTo(x - halfSize + cornerRadius, y + halfSize);
ctx.quadraticCurveTo(
x - halfSize,
y + halfSize,
x - halfSize,
y + halfSize - cornerRadius,
);
ctx.lineTo(x - halfSize, y - halfSize + cornerRadius);
ctx.quadraticCurveTo(
x - halfSize,
y - halfSize,
x - halfSize + cornerRadius,
y - halfSize,
);
ctx.closePath();
ctx.fill();
ctx.stroke();
// 如果有值,绘制数字
if (value !== null) {
ctx.fillStyle = getRandomNumberColor();
ctx.font = `bold ${24}px "Microsoft Yahei"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(value), x, y);
}
}
/**
* 在框内绘制多张图片
*/
private async drawImagesInBox(
ctx: RenderingContext,
canvas: WechatMiniprogram.Canvas,
count: number,
imageIndex: number,
imageType: 'fruits' | 'twelve-animals',
boxX: number,
boxY: number,
boxWidth: number,
boxHeight: number,
) {
// 图片配置
const imageConfig = {
'twelve-animals': {
folder: 'twelve-animals',
maxIndex: 12,
},
fruits: {
folder: 'fruits',
maxIndex: 22,
},
};
const config = imageConfig[imageType] || imageConfig['twelve-animals'];
const horizontalPadding = 5;
const verticalPadding = 10;
const availableWidth = boxWidth - horizontalPadding * 2;
const availableHeight = boxHeight - verticalPadding * 2;
// 根据数量确定每行的图片数和图片大小
let imagesPerRow: number;
let imageSize: number;
if (count <= 4) {
imagesPerRow = count <= 2 ? count : 2;
imageSize =
Math.min(availableWidth / imagesPerRow, availableHeight / 2) -
4;
} else if (count <= 6) {
imagesPerRow = 3;
imageSize = Math.min(availableWidth / 3, availableHeight / 2) - 4;
} else {
imagesPerRow = 3;
imageSize = Math.min(availableWidth / 3, availableHeight / 3) - 4;
}
const rows = Math.ceil(count / imagesPerRow);
const imageSpacing =
(availableWidth - imageSize * imagesPerRow) / (imagesPerRow + 1);
const rowSpacing =
rows > 1 ? (availableHeight - imageSize * rows) / (rows + 1) : 0;
// 加载图片
let boxImage: any = null;
try {
const imagePath = `/mathPages/shared/assets/${config.folder}/${imageIndex}.png`;
boxImage = await getImage(canvas, imagePath);
} catch (error) {
console.error(`加载${config.folder}/${imageIndex}图片失败:`, error);
return;
}
// 绘制图片
for (let i = 0; i < count; i++) {
const row = Math.floor(i / imagesPerRow);
const col = i % imagesPerRow;
const imageX =
boxX +
horizontalPadding +
imageSpacing +
col * (imageSize + imageSpacing);
const imageY =
boxY +
verticalPadding +
// 这两段代码是用于计算每一张图片在Y轴方向上的实际位置(imageY 变量)
// 第一行:如果有多于一行,行间用 rowSpacing,否则单行时图片整体垂直居中排列。
(rows > 1 ? rowSpacing : (availableHeight - imageSize) / 2) +
// 第二行:加上具体第 row 行的纵向偏移(每张图片的高度加上行间距,再乘以行号)。
row * (imageSize + (rows > 1 ? rowSpacing : 0));
if (boxImage) {
// 计算图片高度(等比例缩放)
// @ts-ignore - 微信小程序图片对象有 width 和 height 属性
const scaledHeight =
(boxImage.height / boxImage.width) * imageSize;
ctx.drawImage(
boxImage,
imageX,
imageY,
imageSize,
scaledHeight,
);
}
}
}
/**
* 绘制圆角矩形(虚线边框)
*/
private drawRoundedRectInternal(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
radius: number,
isDashed: boolean = false,
) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(
x + width,
y + height,
x + width - radius,
y + height,
);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
if (isDashed) {
ctx.setLineDash([6 / 3, 6 / 3]); // 约2px的虚线
} else {
ctx.setLineDash([]);
}
ctx.stroke();
ctx.setLineDash([]); // 重置为实线
}
}
export default NumberDecomposeDraw;