feat:架构代码优化

This commit is contained in:
R524809
2025-12-11 13:02:44 +08:00
parent 2c32235568
commit 3c0e2af10d
89 changed files with 2774 additions and 2816 deletions
@@ -0,0 +1,137 @@
# MathPage 公共工具
这个目录包含了数学页面的公共工具和复用代码。
## mathPageMixin.ts
提供了所有数学页面的公共功能,包括:
- Canvas 初始化
- 分享功能(小程序分享、朋友圈分享)
- 导出打印功能
- 页面信息初始化
### 使用方法
```typescript
import { getMathPageCommonMethods } from '../common/mathPageMixin';
import YourDrawService from '../service/yourDrawService';
// 获取公共方法,传入页面路径
const commonMethods = getMathPageCommonMethods({
pagePath: 'yourPage/yourPage', // 相对于 mathPages 目录的路径
});
Page({
// Canvas 相关属性
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as YourDrawService | null,
// 页面数据(包含公共数据)
data: {
functionId: '',
pageTitle: '',
subTitle: '',
hasContent: false,
showShareDialog: false,
boxWidth: 0,
boxHeight: 0,
// ... 其他页面特定的数据
},
onLoad(options: { id?: string }) {
const functionId = options.id || 'your-function-id';
// 使用公共方法初始化页面信息
this.initPageInfo(functionId, '默认标题');
},
onReady() {
// 使用公共方法初始化 Canvas
this.initCanvas({
createDrawService: (canvas, ctx, options) => {
return new YourDrawService(canvas, ctx, options);
},
drawServiceOptions: {
// 传递给绘制服务的选项
},
onCanvasReady: () => {
// Canvas 初始化完成后的回调
this.onRandom(); // 或其他初始化操作
},
});
},
// 实现页面特定的绘制逻辑
async drawCanvas() {
if (!this.ctx || !this.drawService || !this.yourData) {
return;
}
try {
await this.drawService.draw(this.yourData);
this.setData({ hasContent: true });
} catch (error) {
console.error('绘制失败:', error);
this.setData({ hasContent: false });
}
},
// 页面特定的方法
onRandom() {
// 生成数据逻辑
this.drawCanvas();
},
// ========== 使用公共方法 ==========
initCanvas: commonMethods.initCanvas,
exportToPrint: commonMethods.exportToPrint,
onShareAppMessage: commonMethods.onShareAppMessage,
onShareTimeline: commonMethods.onShareTimeline,
onCloseShareDialog: commonMethods.onCloseShareDialog,
onShareSuccess: commonMethods.onShareSuccess,
initPageInfo: commonMethods.initPageInfo,
});
```
### 提供的公共方法
1. **initCanvas(options)** - 初始化 Canvas
- `createDrawService`: 创建绘制服务的工厂函数
- `drawServiceOptions`: 传递给绘制服务的选项
- `onCanvasReady`: Canvas 初始化完成后的回调
2. **drawCanvas()** - 绘制 Canvas 内容(需要子类实现)
3. **exportToPrint()** - 导出打印
4. **onShareAppMessage()** - 小程序分享
5. **onShareTimeline()** - 朋友圈分享
6. **onCloseShareDialog()** - 关闭分享引导弹窗
7. **onShareSuccess()** - 分享成功回调
8. **initPageInfo(functionId, defaultTitle)** - 初始化页面信息
-`MATH_FUNCTION_TYPES` 中获取标题和描述
- 设置导航栏标题
### 优势
-**代码复用**: 消除了重复代码
-**类型安全**: 完整的 TypeScript 类型支持
-**易于维护**: 公共功能集中管理
-**灵活扩展**: 每个页面可以重写特定方法
### 迁移指南
从旧代码迁移到使用 mixin
1. 导入公共方法
2. 移除重复的方法(exportToPrint、分享相关等)
3. 使用 `initCanvas` 替代原有的 Canvas 初始化代码
4. 使用 `initPageInfo` 替代页面信息初始化代码
5. 将公共方法添加到 Page 配置中
@@ -0,0 +1,104 @@
/**
* 数学页面公共样式
*/
page {
background-color: #f6f6f6;
}
.page-container {
background-color: #f6f6f6;
padding: 0 24rpx;
box-sizing: border-box;
padding-bottom: 160rpx; // 为底部按钮预留空间
.empty {
height: 50rpx;
}
}
.wrapper {
background-color: #ffffff;
border-radius: 20rpx;
padding: 36rpx 24rpx;
box-shadow: 0 6rpx 8rpx rgba(0, 0, 0, 0.15);
margin: 30rpx 0;
display: flex;
flex-direction: column;
.wrapper-title {
font-size: 32rpx;
color: #141414;
margin-bottom: 36rpx;
font-weight: bold;
text-align: center;
}
.canvas-wrapper {
display: flex;
justify-content: center;
align-items: center;
min-height: 400rpx;
background: #f8f9fa;
border-radius: 12rpx;
border: 2rpx dashed #dee2e6;
}
.canvas-content {
max-width: 100%;
border-radius: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.random-button-area {
margin-top: 40rpx;
display: flex;
align-items: center;
gap: 20rpx;
.type-selector {
flex: 1; // 占据剩余空间
height: 80rpx;
background-color: #fff;
border: 2rpx solid #e5e5e5;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
box-sizing: border-box;
.type-selector-text {
font-size: 28rpx;
color: #333;
}
.type-selector-arrow {
font-size: 20rpx;
color: #999;
}
}
.random-button {
flex: 2;
}
}
}
/* 底部按钮区域 */
.bottom-btn-box {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
height: 180rpx;
display: flex;
justify-content: space-between;
background-color: #fff;
box-shadow: 0px -3.9rpx 5.2rpx rgba(0, 0, 0, 0.15);
padding: 24rpx;
padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
border-radius: 16rpx 16rpx 0 0;
z-index: 2;
}
@@ -0,0 +1,97 @@
/**
* 数学页面专用的 Mixin
* 基于通用 pageMixin,提供数学模块特定的配置
*/
import {
getPageCommonMethods,
applyPageMixin,
createPage,
CanvasDataState,
PageCanvasInstance,
InitCanvasOptions,
ShareOptions,
PageCommonMethodsConfig,
} from '../../../base/pageMixin';
import {
MATH_FUNCTION_TYPES,
MathFunctionType,
} from '../../../constants/mathFunctions';
/**
* 数学模块的页面实例类型(向后兼容)
*/
export type MathPageCanvasInstance = PageCanvasInstance;
/**
* 数学模块的分享配置
*/
const mathShareConfig = {
title: '涂鸦丫-数学学习涂鸦卡',
imageUrl: 'https://cdn.joeyone.cn/doodle/share-img/index-share-v2.png',
};
/**
* 数学模块的页面信息查找函数
*/
function mathPageInfoLookup(functionId: string) {
const functionItem = MATH_FUNCTION_TYPES.find(
(item: MathFunctionType) => item.id === functionId,
);
if (functionItem) {
return {
title: functionItem.title,
desc: functionItem.desc,
};
}
return undefined;
}
/**
* 获取数学页面的公共方法
* 这些方法可以在所有数学页面中复用
* @deprecated 使用 getMathPageCommonMethods() 即可,会自动应用数学模块配置
*/
export function getMathPageCommonMethods() {
const config: PageCommonMethodsConfig = {
shareConfig: mathShareConfig,
pageInfoLookup: mathPageInfoLookup,
};
return getPageCommonMethods(config);
}
/**
* 应用数学页面公共方法的辅助函数
* 自动混入公共方法,简化页面代码
* @param pageOptions 页面选项
* @returns 合并后的页面选项
* @deprecated 使用 applyMathPageMixin() 即可,会自动应用数学模块配置
*/
export function applyMathPageMixin(pageOptions: any) {
const config: PageCommonMethodsConfig = {
shareConfig: mathShareConfig,
pageInfoLookup: mathPageInfoLookup,
};
return applyPageMixin(pageOptions, config);
}
/**
* 创建数学页面的便捷函数
* 自动应用公共方法并注册为页面
* @param pageOptions 页面选项
*/
export function createMathPage(pageOptions: any) {
const config: PageCommonMethodsConfig = {
shareConfig: mathShareConfig,
pageInfoLookup: mathPageInfoLookup,
};
createPage(pageOptions, config);
}
// 导出类型,保持向后兼容
export type { CanvasDataState, InitCanvasOptions, ShareOptions };
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"toy-button": "../../../../ui/button/button"
}
}
@@ -0,0 +1,16 @@
.bottom-btn-box {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
height: 180rpx;
display: flex;
justify-content: space-between;
background-color: #fff;
box-shadow: 0px -3.9rpx 5.2rpx rgba(0, 0, 0, 0.15);
padding: 24rpx;
padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
border-radius: 16rpx 16rpx 0 0;
z-index: 2;
}
@@ -0,0 +1,18 @@
Component({
properties: {
disabled: {
type: Boolean,
value: false,
},
},
methods: {
onShare() {
this.triggerEvent('share');
},
onExport() {
this.triggerEvent('export');
},
},
});
@@ -0,0 +1,24 @@
<view class="bottom-btn-box">
<toy-button
openType="share"
type="green"
flat="{{true}}"
bind:click="onShare"
width="220rpx"
height="80rpx"
icon="wechat"
icon-class-prefix="toy-icon">
分享
</toy-button>
<toy-button
type="primary"
flat="{{true}}"
bind:click="onExport"
width="420rpx"
height="80rpx"
disabled="{{disabled}}"
icon="download"
icon-class-prefix="toy-icon">
下载打印
</toy-button>
</view>
@@ -0,0 +1,7 @@
{
"component": true,
"usingComponents": {
"toy-button": "../../../../ui/button/button",
"van-action-sheet": "../../../../miniprogram_npm/@vant/weapp/action-sheet/index"
}
}
@@ -0,0 +1,33 @@
.random-button-area {
margin-top: 40rpx;
display: flex;
align-items: center;
gap: 20rpx;
.type-selector {
flex: 1; // 占据剩余空间
height: 80rpx;
background-color: #fff;
border: 2rpx solid #e5e5e5;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
box-sizing: border-box;
.type-selector-text {
font-size: 28rpx;
color: #333;
}
.type-selector-arrow {
font-size: 20rpx;
color: #999;
}
}
.random-button {
flex: 2;
}
}
@@ -0,0 +1,36 @@
Component({
properties: {
currentTypeName: {
type: String,
value: '',
},
typeActions: {
type: Array,
value: [],
},
},
data: {
showSelector: false,
},
methods: {
onShowSelector() {
this.setData({ showSelector: true });
},
onClose() {
this.setData({ showSelector: false });
},
onSelect(event: any) {
const { name, value } = event.detail;
this.setData({ showSelector: false });
this.triggerEvent('select', { name, value });
},
onRandom() {
this.triggerEvent('random');
},
},
});
@@ -0,0 +1,23 @@
<view class="random-button-area">
<view class="type-selector" bind:tap="onShowSelector">
<text class="type-selector-text">{{currentTypeName}}</text>
<text class="type-selector-arrow">▼</text>
</view>
<toy-button
class="random-button"
type="primary"
bind:click="onRandom"
width="100%"
height="80rpx"
icon="refresh"
icon-class-prefix="toy-icon">
随机生成
</toy-button>
</view>
<van-action-sheet
show="{{showSelector}}"
actions="{{typeActions}}"
bind:select="onSelect"
bind:cancel="onClose"
bind:close="onClose"
cancel-text="取消" />
@@ -0,0 +1,576 @@
import { getImage } from '../../../utils/index';
interface DrawAdditionContentParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
problems: Array<{
type: 'addition' | 'subtraction';
left: number;
right: number;
result: number;
}>;
canvasWidth: number;
startY: number;
imageType?: string; // 'twelve-animals' 或 'fruits'
}
/**
* 绘制圆角矩形(虚线或实线边框)
*/
function drawRoundedRect(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
radius: number,
isDashed: boolean = true,
) {
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([2, 2]); // 虚线
} else {
ctx.setLineDash([]); // 实线
}
ctx.stroke();
ctx.setLineDash([]); // 重置为实线
}
/**
* 绘制实线圆角方框(用于填写答案)
*/
function drawAnswerBox(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
) {
const radius = 6; // 圆角大小
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.setLineDash([]); // 实线
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();
ctx.stroke();
}
/**
* 绘制右侧答案框
*/
function drawAnswerBoxArea(
ctx: RenderingContext,
params: {
rightBoxX: number;
boxY: number;
rightBoxWidth: number;
borderRadius: number;
boxHeight: number;
padding: number;
problemType: 'addition' | 'subtraction';
},
) {
const {
rightBoxX,
boxY,
rightBoxWidth,
borderRadius,
boxHeight,
padding,
problemType,
} = params;
const plusSignSize = 18; // 加号大小(缩小)
const minusSignSize = 18; // 减号大小(缩小)
const equalsSignSize = 18; // 等号大小(缩小)
const spacing = 8; // 加号左右间距(方便修改和调试)
const answerBoxWidth = 34; // 答案框宽度
const answerBoxHeight = 34; // 答案框高度
// 绘制右侧答案框边框
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
drawRoundedRect(
ctx,
rightBoxX,
boxY,
rightBoxWidth,
boxHeight,
borderRadius,
true,
);
const rightBoxCenterY = boxY + boxHeight / 2;
const rightBoxAvailableWidth = rightBoxWidth - padding * 2;
const answerStartY = rightBoxCenterY - answerBoxHeight / 2;
// 根据问题类型确定运算符号大小
const operatorSignSize =
problemType === 'addition' ? plusSignSize : minusSignSize;
// 计算总宽度并水平居中(加法和减法逻辑相同)
const totalAnswerWidth =
answerBoxWidth +
spacing +
operatorSignSize +
spacing +
answerBoxWidth +
spacing +
equalsSignSize +
spacing +
answerBoxWidth;
const answerStartX =
rightBoxX + padding + (rightBoxAvailableWidth - totalAnswerWidth) / 2;
// 计算各个元素的位置
const box1X = answerStartX;
const operatorX = box1X + answerBoxWidth + spacing;
const box2X = operatorX + plusSignSize + spacing;
const equalsX = box2X + answerBoxWidth + spacing;
const box3X = equalsX + equalsSignSize + spacing;
// 绘制第一个方框(圆角)
drawAnswerBox(ctx, box1X, answerStartY, answerBoxWidth, answerBoxHeight);
// 绘制运算符号(加号或减号)
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.beginPath();
if (problemType === 'addition') {
// 加号:横线和竖线
ctx.moveTo(operatorX, rightBoxCenterY);
ctx.lineTo(operatorX + operatorSignSize, rightBoxCenterY);
ctx.moveTo(
operatorX + operatorSignSize / 2,
rightBoxCenterY - operatorSignSize / 2,
);
ctx.lineTo(
operatorX + operatorSignSize / 2,
rightBoxCenterY + operatorSignSize / 2,
);
} else {
// 减号:只有横线
ctx.moveTo(operatorX, rightBoxCenterY);
ctx.lineTo(operatorX + operatorSignSize, rightBoxCenterY);
}
ctx.stroke();
// 绘制第二个方框(圆角)
drawAnswerBox(ctx, box2X, answerStartY, answerBoxWidth, answerBoxHeight);
// 绘制等号(缩短)
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(equalsX, rightBoxCenterY - 3);
ctx.lineTo(equalsX + equalsSignSize, rightBoxCenterY - 3);
ctx.moveTo(equalsX, rightBoxCenterY + 3);
ctx.lineTo(equalsX + equalsSignSize, rightBoxCenterY + 3);
ctx.stroke();
// 绘制第三个方框(答案,圆角)
drawAnswerBox(ctx, box3X, answerStartY, answerBoxWidth, answerBoxHeight);
}
// 根据图片类型设置图片大小
function getImageSize(imageType: 'twelve-animals' | 'fruits'): number {
return imageType === 'twelve-animals' ? 40 : 36;
}
/**
* 绘制加减法计算内容区域
* 左侧框:展示图片(加法:加号左右展示图片;减法:总数图片,后几个加划线)
* 右侧框:填写运算方式(实线方框 + 实线方框 = 实线方框 或 实线方框 - 实线方框 = 实线方框)
*/
export async function drawAdditionContent({
canvas,
ctx,
problems,
canvasWidth,
startY,
imageType: _imageType = '', // 不再使用,每个运算会随机选择图片类型
}: DrawAdditionContentParams): Promise<void> {
// 根据图片类型确定图片目录和最大索引
const imageConfig = {
'twelve-animals': {
folder: 'twelve-animals',
maxIndex: 12,
},
fruits: {
folder: 'fruits',
maxIndex: 22,
},
};
const leftMargin = 24;
const rightMargin = 24;
const itemSpacing = 140;
// 调整左右框宽度比例:6:4 (约420:280)
const leftBoxWidth = 334; // 左侧图片框固定宽度
const rightBoxWidth = 196; // 右侧答案框固定宽度
const boxHeight = 110;
const borderRadius = 12;
const imageSpacing = 0; // 图片之间的间距(水平方向)
const rowSpacing = 4; // 第一行和第二行之间的间距(垂直方向)
const padding = 15; // 框内边距
const startYPos = startY + 20;
const plusSignSize = 20; // 加号大小(缩小)
const plusSignSpacing = 15; // 加号左右间距(方便修改和调试)
// 计算左侧和右侧的起始X位置
const leftBoxX = leftMargin;
const rightBoxX = canvasWidth - rightMargin - rightBoxWidth;
// 为每个题目随机选择图片类型和图片索引
const problemImageConfigs: Array<{
type: 'twelve-animals' | 'fruits';
imageIndex: number;
}> = [];
for (let i = 0; i < problems.length; i++) {
// 随机选择图片类型
const imageTypes: ('twelve-animals' | 'fruits')[] = [
'twelve-animals',
'fruits',
];
const randomType =
imageTypes[Math.floor(Math.random() * imageTypes.length)];
const config = imageConfig[randomType];
// 为当前题目生成唯一的图片索引
const availableImageIndices = Array.from(
{ length: config.maxIndex },
(_, i) => i + 1,
);
const randomIndex = Math.floor(
Math.random() * availableImageIndices.length,
);
const imageIndex = availableImageIndices[randomIndex];
problemImageConfigs.push({
type: randomType,
imageIndex,
});
}
// 绘制每一行的内容
for (let i = 0; i < problems.length; i++) {
const problem = problems[i];
const boxY = startYPos + i * itemSpacing;
const imageConfigItem = problemImageConfigs[i];
const config = imageConfig[imageConfigItem.type];
// 根据图片类型获取图片大小
const imageSize = getImageSize(imageConfigItem.type);
// 加载图片
let image: any = null;
try {
const imagePath = `/mathPages/assets/${config.folder}/${imageConfigItem.imageIndex}.png`;
image = await getImage(canvas, imagePath);
} catch (error) {
console.error('图片加载失败:', error);
}
// ========== 绘制左侧图片框 ==========
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
drawRoundedRect(
ctx,
leftBoxX,
boxY,
leftBoxWidth,
boxHeight,
borderRadius,
true,
);
const leftBoxCenterY = boxY + boxHeight / 2;
const leftBoxContentX = leftBoxX + padding;
if (problem.type === 'addition') {
// 加法:左侧图片 + 加号 + 右侧图片
const leftImageCount = problem.left;
const rightImageCount = problem.right;
/**
* 根据数量计算每行最多展示的图片数
*/
const getMaxImagesPerRow = (count: number): number => {
if (count <= 6) return 3;
if (count <= 8) return 4;
return 5; // 8-10
};
const leftMaxPerRow = getMaxImagesPerRow(leftImageCount);
const rightMaxPerRow = getMaxImagesPerRow(rightImageCount);
const leftRows = Math.min(
2,
Math.ceil(leftImageCount / leftMaxPerRow),
);
const rightRows = Math.min(
2,
Math.ceil(rightImageCount / rightMaxPerRow),
);
// 计算左侧图片区域的宽度和高度
// 需要根据实际行数计算每行的最大宽度
const leftFirstRowCount = Math.min(leftImageCount, leftMaxPerRow);
const leftSecondRowCount = Math.max(
0,
Math.min(leftMaxPerRow, leftImageCount - leftMaxPerRow),
);
const leftImagesWidth =
Math.max(
leftFirstRowCount * imageSize +
(leftFirstRowCount - 1) * imageSpacing,
leftSecondRowCount * imageSize +
(leftSecondRowCount - 1) * imageSpacing,
) || imageSize; // 至少为 imageSize
// 计算右侧图片区域的宽度和高度
const rightFirstRowCount = Math.min(
rightImageCount,
rightMaxPerRow,
);
const rightSecondRowCount = Math.max(
0,
Math.min(rightMaxPerRow, rightImageCount - rightMaxPerRow),
);
const rightImagesWidth =
Math.max(
rightFirstRowCount * imageSize +
(rightFirstRowCount - 1) * imageSpacing,
rightSecondRowCount * imageSize +
(rightSecondRowCount - 1) * imageSpacing,
) || imageSize; // 至少为 imageSize
// 获取图片实际高度(用于垂直居中计算)
let imageActualHeight = imageSize;
if (image) {
// @ts-ignore
imageActualHeight = (image.height / image.width) * imageSize;
}
// 计算左侧图片区域的实际高度(考虑图片的实际高度和行间距)
const leftImagesHeight =
leftRows * imageActualHeight + (leftRows - 1) * rowSpacing;
// 计算右侧图片区域的实际高度
const rightImagesHeight =
rightRows * imageActualHeight + (rightRows - 1) * rowSpacing;
// 计算加号位置(居中,包含左右间距)
const totalContentWidth =
leftImagesWidth +
plusSignSpacing +
plusSignSize +
plusSignSpacing +
rightImagesWidth;
const availableWidth = leftBoxWidth - padding * 2;
const contentStartX =
leftBoxContentX + (availableWidth - totalContentWidth) / 2;
const leftImagesStartX = contentStartX;
// 垂直居中:使用图片实际高度计算
const leftImagesStartY = leftBoxCenterY - leftImagesHeight / 2;
// 绘制左侧图片(最多两行)
for (let j = 0; j < Math.min(leftImageCount, 10); j++) {
const row = Math.floor(j / leftMaxPerRow);
const col = j % leftMaxPerRow;
// 如果超过两行,停止绘制
if (row >= 2) break;
const imagesInThisRow = Math.min(
leftMaxPerRow,
leftImageCount - row * leftMaxPerRow,
);
const rowWidth =
imagesInThisRow * imageSize +
(imagesInThisRow - 1) * imageSpacing;
const rowStartX =
leftImagesStartX + (leftImagesWidth - rowWidth) / 2;
const imageX = rowStartX + col * (imageSize + imageSpacing);
// 使用图片实际高度和行间距计算垂直位置
const imageY =
leftImagesStartY + row * (imageActualHeight + rowSpacing);
if (image) {
// @ts-ignore
const scaledHeight =
(image.height / image.width) * imageSize;
ctx.drawImage(
image,
imageX,
imageY,
imageSize,
scaledHeight,
);
}
}
// 绘制加号(左右有间距)
const plusX =
leftImagesStartX +
leftImagesWidth +
plusSignSpacing +
plusSignSize / 2;
const plusY = leftBoxCenterY;
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.beginPath();
// 横线
ctx.moveTo(plusX - plusSignSize / 2, plusY);
ctx.lineTo(plusX + plusSignSize / 2, plusY);
// 竖线
ctx.moveTo(plusX, plusY - plusSignSize / 2);
ctx.lineTo(plusX, plusY + plusSignSize / 2);
ctx.stroke();
const rightImagesStartX =
plusX + plusSignSize / 2 + plusSignSpacing;
// 垂直居中:使用图片实际高度计算
const rightImagesStartY = leftBoxCenterY - rightImagesHeight / 2;
// 绘制右侧图片(最多两行)
for (let j = 0; j < Math.min(rightImageCount, 10); j++) {
const row = Math.floor(j / rightMaxPerRow);
const col = j % rightMaxPerRow;
// 如果超过两行,停止绘制
if (row >= 2) break;
const imagesInThisRow = Math.min(
rightMaxPerRow,
rightImageCount - row * rightMaxPerRow,
);
const rowWidth =
imagesInThisRow * imageSize +
(imagesInThisRow - 1) * imageSpacing;
const rowStartX =
rightImagesStartX + (rightImagesWidth - rowWidth) / 2;
const imageX = rowStartX + col * (imageSize + imageSpacing);
// 使用图片实际高度和行间距计算垂直位置
const imageY =
rightImagesStartY + row * (imageActualHeight + rowSpacing);
if (image) {
// @ts-ignore
const scaledHeight =
(image.height / image.width) * imageSize;
ctx.drawImage(
image,
imageX,
imageY,
imageSize,
scaledHeight,
);
}
}
} else {
// 减法:展示总数量的图片,后几个加划线
const totalCount = problem.left; // 总数
const subtractCount = problem.right; // 要减去的数量
// 获取图片实际高度(用于垂直居中计算)
let imageActualHeight = imageSize;
if (image) {
// @ts-ignore
imageActualHeight = (image.height / image.width) * imageSize;
}
// 计算图片布局
const imagesPerRow = 5;
const totalRows = Math.ceil(totalCount / imagesPerRow);
// 使用图片实际高度和行间距计算总高度
const totalHeight =
totalRows * imageActualHeight + (totalRows - 1) * rowSpacing;
const imagesStartY = leftBoxCenterY - totalHeight / 2;
// 绘制所有图片
for (let j = 0; j < totalCount; j++) {
const row = Math.floor(j / imagesPerRow);
const col = j % imagesPerRow;
const imagesInThisRow = Math.min(
imagesPerRow,
totalCount - row * imagesPerRow,
);
const rowWidth =
imagesInThisRow * imageSize +
(imagesInThisRow - 1) * imageSpacing;
const rowStartX =
leftBoxContentX +
(leftBoxWidth - padding * 2 - rowWidth) / 2;
const imageX = rowStartX + col * (imageSize + imageSpacing);
// 使用图片实际高度和行间距计算垂直位置
const imageY =
imagesStartY + row * (imageActualHeight + rowSpacing);
if (image) {
// @ts-ignore
const scaledHeight =
(image.height / image.width) * imageSize;
ctx.drawImage(
image,
imageX,
imageY,
imageSize,
scaledHeight,
);
// 如果是后几个图片,绘制虚线删除线(表示减去)
if (j >= totalCount - subtractCount) {
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.setLineDash([4, 3]); // 设置虚线样式
ctx.beginPath();
// 绘制第一条虚线对角线(从左上到右下)
ctx.moveTo(imageX, imageY);
ctx.lineTo(imageX + imageSize, imageY + scaledHeight);
// // 绘制第二条虚线对角线(从右上到左下)
// ctx.moveTo(imageX + imageSize, imageY);
// ctx.lineTo(imageX, imageY + scaledHeight);
ctx.stroke();
ctx.setLineDash([]); // 重置为实线,防止影响后续绘制
}
}
}
}
// ========== 绘制右侧答案框 ==========
drawAnswerBoxArea(ctx, {
rightBoxX,
boxY,
rightBoxWidth,
borderRadius,
boxHeight,
padding,
problemType: problem.type,
});
}
}
@@ -0,0 +1,67 @@
import { BaseDrawService } from '../../../service/baseDraw';
import { drawAdditionContent } from './additionContentDraw';
/**
* 加减法计算绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class AdditionDraw extends BaseDrawService {
calculationData: {
problems: Array<{
type: 'addition' | 'subtraction';
left: number;
right: number;
result: number;
}>;
} | null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.calculationData = null;
}
async draw(
calculationData: {
problems: Array<{
type: 'addition' | 'subtraction';
left: number;
right: number;
result: number;
}>;
},
_calculationType: string = 'addition-5', // 保留参数以保持接口兼容性
_imageType?: string, // 可选,不再使用,每个运算会随机选择图片类型
) {
if (!calculationData || !calculationData.problems) {
return;
}
this.setPrintConfig();
this.calculationData = calculationData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域(加减法计算)
this.drawDivider();
await drawAdditionContent({
canvas: this.canvas,
ctx: this.ctx,
problems: this.calculationData.problems,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
}
export default AdditionDraw;
@@ -0,0 +1,344 @@
import { getImage } from '../../../utils/index';
interface DrawCompareContentParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
compareData: {
problems: Array<{
leftCount: number;
rightCount: number;
leftImageIndex: number;
rightImageIndex: number;
imageType: 'fruits' | 'twelve-animals';
}>;
};
canvasWidth: number;
startY: number;
}
/**
* 绘制圆角矩形(虚线边框)
*/
function drawRoundedRect(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
radius: number,
isDashed: boolean = true,
) {
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 async function drawCompareContent({
canvas,
ctx,
compareData,
canvasWidth,
startY,
}: DrawCompareContentParams): Promise<void> {
const { problems } = compareData;
// 布局参数
const leftMargin = 30;
const rightMargin = 30;
const topMargin = 12;
const rowSpacing = 116; // 行之间的间距
const columnSpacing = 40; // 两列之间的间距(可调节)
// 计算每列的宽度(减去边距和列间距)
const availableWidth =
canvasWidth - leftMargin - rightMargin - columnSpacing;
const columnWidth = availableWidth / 2;
// 框的尺寸
const boxWidth = 100;
const boxHeight = 100;
const borderRadius = 12;
const circleRadius = 18; // 圆圈半径
// 框与圆圈之间的间距
const boxToCircleSpacing = 12;
// 计算左侧列和右侧列的起始X位置
const leftColumnStartX = leftMargin;
const rightColumnStartX = leftMargin + columnWidth + columnSpacing;
// 计算每列内部的布局(左框 | 圆圈 | 右框,居中)
const totalWidthPerColumn =
boxWidth +
boxToCircleSpacing +
circleRadius * 2 +
boxToCircleSpacing +
boxWidth;
const columnPadding = (columnWidth - totalWidthPerColumn) / 2;
let currentY = startY + topMargin;
// 计算分割线位置(两列中间)
// const dividerX = leftMargin + columnWidth + columnSpacing / 2;
// 固定绘制6行
const totalRows = 6;
for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
const rowY = currentY + rowIndex * rowSpacing;
// 绘制左侧列
const leftProblemIndex = rowIndex * 2;
if (leftProblemIndex < problems.length) {
await drawCompareProblem(
ctx,
canvas,
problems[leftProblemIndex],
leftColumnStartX + columnPadding,
rowY,
boxWidth,
boxHeight,
borderRadius,
circleRadius,
boxToCircleSpacing,
);
}
// 绘制右侧列
const rightProblemIndex = rowIndex * 2 + 1;
if (rightProblemIndex < problems.length) {
await drawCompareProblem(
ctx,
canvas,
problems[rightProblemIndex],
rightColumnStartX + columnPadding,
rowY,
boxWidth,
boxHeight,
borderRadius,
circleRadius,
boxToCircleSpacing,
);
}
}
// 绘制中间分割线(贯穿所有行)
// if (totalRows > 0) {
// ctx.strokeStyle = '#999';
// ctx.lineWidth = 1;
// ctx.beginPath();
// const dividerStartY = currentY;
// const dividerEndY = currentY + (totalRows - 1) * rowSpacing + boxHeight;
// ctx.moveTo(dividerX, dividerStartY);
// ctx.lineTo(dividerX, dividerEndY);
// ctx.stroke();
// }
}
/**
* 绘制一个比较问题(左框 | 圆圈 | 右框)
*/
async function drawCompareProblem(
ctx: RenderingContext,
canvas: WechatMiniprogram.Canvas,
problem: {
leftCount: number;
rightCount: number;
leftImageIndex: number;
rightImageIndex: number;
imageType: 'fruits' | 'twelve-animals';
},
startX: number,
startY: number,
boxWidth: number,
boxHeight: number,
borderRadius: number,
circleRadius: number,
boxToCircleSpacing: number,
) {
const {
leftCount,
rightCount,
leftImageIndex,
rightImageIndex,
imageType,
} = problem;
// 图片配置
const imageConfig = {
'twelve-animals': {
folder: 'twelve-animals',
maxIndex: 12,
},
fruits: {
folder: 'fruits',
maxIndex: 22,
},
};
const config = imageConfig[imageType] || imageConfig['twelve-animals'];
// 计算左框位置
const leftBoxX = startX;
const leftBoxY = startY;
// 计算圆圈位置
const circleX = leftBoxX + boxWidth + boxToCircleSpacing + circleRadius;
const circleY = startY + boxHeight / 2;
// 计算右框位置
const rightBoxX = circleX + circleRadius + boxToCircleSpacing;
const rightBoxY = startY;
// 绘制左框
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
drawRoundedRect(
ctx,
leftBoxX,
leftBoxY,
boxWidth,
boxHeight,
borderRadius,
true,
);
// 绘制左框内的图片
await drawImagesInBox(
ctx,
canvas,
leftCount,
leftImageIndex,
config.folder,
leftBoxX,
leftBoxY,
boxWidth,
boxHeight,
);
// 绘制实心圆圈
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(circleX, circleY, circleRadius, 0, Math.PI * 2);
ctx.fill(); // 先填充白色
ctx.stroke(); // 再绘制边框
// 绘制右框
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
drawRoundedRect(
ctx,
rightBoxX,
rightBoxY,
boxWidth,
boxHeight,
borderRadius,
true,
);
// 绘制右框内的图片
await drawImagesInBox(
ctx,
canvas,
rightCount,
rightImageIndex,
config.folder,
rightBoxX,
rightBoxY,
boxWidth,
boxHeight,
);
}
/**
* 在框内绘制多张图片
*/
async function drawImagesInBox(
ctx: RenderingContext,
canvas: WechatMiniprogram.Canvas,
count: number,
imageIndex: number,
folder: string,
boxX: number,
boxY: number,
boxWidth: number,
boxHeight: number,
) {
const padding = 8; // 框内边距
const availableWidth = boxWidth - padding * 2;
const availableHeight = boxHeight - padding * 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/assets/${folder}/${imageIndex}.png`;
boxImage = await getImage(canvas, imagePath);
} catch (error) {
console.error(`加载${folder}/${imageIndex}图片失败:`, error);
return;
}
// 绘制图片
for (let i = 0; i < count; i++) {
const row = Math.floor(i / imagesPerRow);
const col = i % imagesPerRow;
const imageX =
boxX + padding + imageSpacing + col * (imageSize + imageSpacing);
const imageY =
boxY +
padding +
(rows > 1 ? rowSpacing : (availableHeight - imageSize) / 2) +
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);
}
}
}
@@ -0,0 +1,65 @@
import { BaseDrawService } from '../../../service/baseDraw';
import { drawCompareContent } from './compareContentDraw';
/**
* 数一数比大小绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class CompareDraw extends BaseDrawService {
compareData: {
problems: Array<{
leftCount: number;
rightCount: number;
leftImageIndex: number;
rightImageIndex: number;
imageType: 'fruits' | 'twelve-animals';
}>;
} | null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.compareData = null;
}
async draw(compareData: {
problems: Array<{
leftCount: number;
rightCount: number;
leftImageIndex: number;
rightImageIndex: number;
imageType: 'fruits' | 'twelve-animals';
}>;
}) {
if (!compareData || !compareData.problems) {
return;
}
this.setPrintConfig();
this.compareData = compareData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域(数一数比大小)
this.drawDivider();
await drawCompareContent({
canvas: this.canvas,
ctx: this.ctx,
compareData: this.compareData,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
}
export default CompareDraw;
@@ -0,0 +1,294 @@
import { getImage } from '../../../utils/index';
import { getRandomNumberColor } from '../../../constants/colors';
interface DrawCountMatchContentParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
matchData: {
leftNumbers: number[];
rightNumbers: number[]; // 打乱顺序后的数字数组
};
canvasWidth: number;
startY: number;
imageType?: string; // 'twelve-animals' 或 'fruits'
}
/**
* 绘制圆角矩形(虚线边框)
*/
function drawRoundedRect(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
radius: number,
isDashed: boolean = true,
) {
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([]); // 重置为实线
}
/**
* 绘制数一数连一连内容区域
* 左侧显示数字(虚线圆角框),右侧显示对应数量的图片(虚线圆角框)
* 支持根据 imageType 选择不同的图片文件夹(twelve-animals 或 fruits
*/
export async function drawCountMatchContent({
canvas,
ctx,
matchData,
canvasWidth,
startY,
imageType = 'twelve-animals',
}: DrawCountMatchContentParams): Promise<void> {
const { leftNumbers, rightNumbers } = matchData;
// 根据图片类型确定图片目录和最大索引
const imageConfig = {
'twelve-animals': {
folder: 'twelve-animals',
maxIndex: 12,
},
fruits: {
folder: 'fruits',
maxIndex: 22,
},
};
const config =
imageConfig[imageType as keyof typeof imageConfig] ||
imageConfig['twelve-animals'];
const leftMargin = 24;
const rightMargin = 24;
const itemSpacing = 140;
const LeftBoxWidth = 120;
const rightBoxWidth = 250;
const boxHeight = 110;
const borderRadius = 12;
const imageSpacing = 5; // 图片之间的水平间距(同一行内)
const rowSpacing = 10; // 行之间的垂直间距(两行之间)
const imagesPerRow = 5; // 每行最多5张图片
const maxImages = 10; // 最多10张图片
const padding = 10; // 框内边距
const startYPos = startY + 20;
const linePointRadius = 8;
const linePointSpacing = 12;
/**
* 根据图片数量动态计算图片宽度
* @param count 图片数量
* @returns 图片宽度
*/
const getImageWidth = (count: number): number => {
if (count <= 2) {
return 90;
} else if (count <= 3) {
return 70;
} else if (count <= 4) {
return 55;
} else {
return 40;
}
};
// 计算左侧和右侧的起始X位置
const leftBoxX = leftMargin;
const rightBoxX = canvasWidth - rightMargin - rightBoxWidth;
// 绘制左侧数字框
for (let i = 0; i < leftNumbers.length; i++) {
const boxY = startYPos + i * itemSpacing;
const number = leftNumbers[i];
// 绘制虚线圆角矩形框
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
drawRoundedRect(
ctx,
leftBoxX,
boxY,
LeftBoxWidth,
boxHeight,
borderRadius,
true,
);
// 绘制数字(使用随机颜色)
ctx.fillStyle = getRandomNumberColor();
ctx.font = `bold ${56}px "Microsoft Yahei"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
String(number),
leftBoxX + LeftBoxWidth / 2,
boxY + boxHeight / 2,
);
// 绘制左侧框右侧的连线圆点(直径14,半径7)
const leftCircleX =
leftBoxX + LeftBoxWidth + linePointSpacing + linePointRadius;
const leftCircleY = boxY + boxHeight / 2;
ctx.fillStyle = '#93D333';
ctx.beginPath();
ctx.arc(leftCircleX, leftCircleY, linePointRadius, 0, Math.PI * 2);
ctx.fill();
}
// 为每个框生成唯一的图片索引,确保不同框使用不同的图片
const availableImageIndices = Array.from(
{ length: config.maxIndex },
(_, i) => i + 1,
);
const boxImageIndices: number[] = [];
for (let i = 0; i < rightNumbers.length; i++) {
const randomIndex = Math.floor(
Math.random() * availableImageIndices.length,
);
const imageIndex = availableImageIndices.splice(randomIndex, 1)[0];
boxImageIndices.push(imageIndex);
}
// 绘制右侧图片框
for (let i = 0; i < rightNumbers.length; i++) {
const boxY = startYPos + i * itemSpacing;
const number = rightNumbers[i]; // 这个数字决定了要绘制多少张图片
const boxImageIndex = boxImageIndices[i]; // 这个框使用的图片索引(所有图片都一样)
// 绘制虚线圆角矩形框
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
drawRoundedRect(
ctx,
rightBoxX,
boxY,
rightBoxWidth,
boxHeight,
borderRadius,
true,
);
// 计算要绘制的图片数量(最多10张)
const imageCount = Math.min(number, maxImages);
// 根据图片数量动态计算图片宽度
const imageWidth = getImageWidth(imageCount);
// 加载当前框使用的图片(只加载一次,用于计算高度和绘制)
let boxImage: any = null;
try {
const imagePath = `/mathPages/assets/${config.folder}/${boxImageIndex}.png`;
boxImage = await getImage(canvas, imagePath);
} catch (error) {
console.error(
`加载${config.folder}/${boxImageIndex}图片失败:`,
error,
);
}
// 计算图片高度(等比例缩放)
let imageHeight = imageWidth; // 默认高度
if (boxImage) {
// @ts-ignore - 微信小程序图片对象有 width 和 height 属性
imageHeight = (boxImage.height / boxImage.width) * imageWidth;
}
// 计算每行的图片数量
const imagesInFirstRow = Math.min(imageCount, imagesPerRow);
const imagesInSecondRow =
imageCount > imagesPerRow ? imageCount - imagesPerRow : 0;
// 计算图片的总高度(考虑行数)
const rowCount = imagesInSecondRow > 0 ? 2 : 1;
const totalImageHeight =
rowCount * imageHeight + (rowCount - 1) * rowSpacing;
// 垂直居中:框顶部 + 内边距 + (框高度 - 上下内边距 - 图片总高度) / 2
const startImageY =
boxY + padding + (boxHeight - padding * 2 - totalImageHeight) / 2;
// 计算每行的起始X位置(用于居中)
const firstRowImageCount = imagesInFirstRow;
const firstRowWidth =
firstRowImageCount * imageWidth +
(firstRowImageCount - 1) * imageSpacing;
const firstRowStartX =
rightBoxX +
padding +
(rightBoxWidth - padding * 2 - firstRowWidth) / 2;
const secondRowImageCount = imagesInSecondRow;
const secondRowWidth =
secondRowImageCount * imageWidth +
(secondRowImageCount - 1) * imageSpacing;
const secondRowStartX =
rightBoxX +
padding +
(rightBoxWidth - padding * 2 - secondRowWidth) / 2;
// 绘制图片(同一个框内使用同一张图片)
for (let imgIndex = 0; imgIndex < imageCount; imgIndex++) {
const row = Math.floor(imgIndex / imagesPerRow);
const col = imgIndex % imagesPerRow;
// 计算图片的X位置(根据行数选择不同的起始位置)
const rowStartX = row === 0 ? firstRowStartX : secondRowStartX;
const imageX = rowStartX + col * (imageWidth + imageSpacing);
// 计算图片的Y位置
const imageY = startImageY + row * (imageHeight + rowSpacing);
if (boxImage) {
// 计算图片高度(等比例缩放)
// @ts-ignore - 微信小程序图片对象有 width 和 height 属性
const scaledHeight =
(boxImage.height / boxImage.width) * imageWidth;
ctx.drawImage(
boxImage,
imageX,
imageY,
imageWidth,
scaledHeight,
);
} else {
// 如果图片加载失败,绘制一个小圆点作为备用
ctx.fillStyle = '#ccc';
ctx.beginPath();
ctx.arc(
imageX + imageWidth / 2,
imageY + imageHeight / 2,
imageWidth / 4,
0,
Math.PI * 2,
);
ctx.fill();
}
}
// 绘制右侧框左侧的连线圆点(直径14,半径7)
const rightCircleX = rightBoxX - linePointSpacing - linePointRadius; // 间距15 + 半径linePointRadius
const rightCircleY = boxY + boxHeight / 2;
ctx.fillStyle = '#93D333';
ctx.beginPath();
ctx.arc(rightCircleX, rightCircleY, linePointRadius, 0, Math.PI * 2);
ctx.fill();
}
}
@@ -0,0 +1,56 @@
import { BaseDrawService } from '../../../service/baseDraw';
import { drawCountMatchContent } from './countMatchContentDraw';
/**
* 数一数连一连绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class CountMatchDraw extends BaseDrawService {
matchData: {
leftNumbers: number[];
rightNumbers: number[]; // 打乱顺序后的数字数组
} | null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.matchData = null;
}
async draw(
matchData: { leftNumbers: number[]; rightNumbers: number[] },
imageType: string = 'twelve-animals',
) {
if (!matchData || !matchData.leftNumbers || !matchData.rightNumbers) {
return;
}
this.setPrintConfig();
this.matchData = matchData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域(数一数连一连)
this.drawDivider();
await drawCountMatchContent({
canvas: this.canvas,
ctx: this.ctx,
matchData: this.matchData,
canvasWidth: this.canvasWidth,
startY: this.currentY,
imageType,
});
}
}
export default CountMatchDraw;
@@ -0,0 +1,312 @@
import { getImage } from '../../../utils/index';
import { getRandomNumberColor } from '../../../constants/colors';
interface DrawCountingSelectContentParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
countingData: {
problems: Array<{
count: number;
imageIndex: number;
imageType: 'fruits' | 'twelve-animals';
options?: number[]; // 选一选模式需要,填一填模式不需要
correctIndex?: number; // 选一选模式需要
}>;
};
mode: 'select' | 'fill'; // 模式:'select' 显示选项,'fill' 显示括号
canvasWidth: number;
startY: number;
}
/**
* 绘制圆角矩形(虚线边框)
*/
function drawRoundedRect(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
radius: number,
isDashed: boolean = true,
) {
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([]); // 重置为实线
}
/**
* 绘制数一数选一选/填一填内容区域
* 3x3网格布局,每个框上面是图片,下面根据模式显示选项或括号
*/
export async function drawCountingSelectContent({
canvas,
ctx,
countingData,
mode,
canvasWidth,
startY,
}: DrawCountingSelectContentParams): Promise<void> {
const { problems } = countingData;
// 布局参数
const leftMargin = 30;
const rightMargin = 30;
const topMargin = 20;
const boxSpacing = 20; // 框之间的间距
// 计算每个框的尺寸
const availableWidth = canvasWidth - leftMargin - rightMargin;
const boxWidth = (availableWidth - boxSpacing * 2) / 3; // 3列,2个间距
const boxHeight = 210; // 框的高度(增加)
// 图片区域高度
const imageAreaHeight = 160;
// 底部区域高度(选项表格或括号区域)
const bottomAreaHeight = boxHeight - imageAreaHeight;
// 选项表格相关(仅 select 模式使用)
const optionCellWidth = boxWidth / 3; // 每列宽度(均匀分三列)
let currentY = startY + topMargin;
// 绘制3x3网格
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 * (boxHeight + boxSpacing);
// 绘制一个完整的题目框
await drawProblemBox(
ctx,
canvas,
problem,
boxX,
boxY,
boxWidth,
boxHeight,
imageAreaHeight,
bottomAreaHeight,
optionCellWidth,
mode,
);
}
}
}
/**
* 绘制一个题目框(图片 + 选项表格 或 括号)
*/
async function drawProblemBox(
ctx: RenderingContext,
canvas: WechatMiniprogram.Canvas,
problem: {
count: number;
imageIndex: number;
imageType: 'fruits' | 'twelve-animals';
options?: number[];
correctIndex?: number;
},
boxX: number,
boxY: number,
boxWidth: number,
boxHeight: number,
imageAreaHeight: number,
bottomAreaHeight: number,
optionCellWidth: number,
mode: 'select' | 'fill',
) {
const borderRadius = 12;
// 绘制实线框
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
drawRoundedRect(ctx, boxX, boxY, boxWidth, boxHeight, borderRadius, false);
// 图片区域
const imageAreaX = boxX;
const imageAreaY = boxY;
// 绘制图片
await drawImagesInBox(
ctx,
canvas,
problem.count,
problem.imageIndex,
problem.imageType,
imageAreaX,
imageAreaY,
boxWidth,
imageAreaHeight,
);
// 底部区域
const bottomAreaX = boxX;
const bottomAreaY = boxY + imageAreaHeight;
// 绘制底部区域的顶部横线(两种模式都需要)
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(bottomAreaX, bottomAreaY);
ctx.lineTo(bottomAreaX + boxWidth, bottomAreaY);
ctx.stroke();
if (mode === 'select' && problem.options) {
// 选一选模式:绘制三个数字选项(表格样式)
// 绘制三个单元格(均匀分三列)
for (let i = 0; i < problem.options.length; i++) {
const cellX = bottomAreaX + i * optionCellWidth;
const cellY = bottomAreaY;
// 绘制单元格边框(右边框,除了最后一个)
if (i < problem.options.length - 1) {
ctx.beginPath();
ctx.moveTo(cellX + optionCellWidth, cellY);
ctx.lineTo(cellX + optionCellWidth, cellY + bottomAreaHeight);
ctx.stroke();
}
// 绘制数字(居中)
const numberX = cellX + optionCellWidth / 2;
const numberY = cellY + bottomAreaHeight / 2;
ctx.fillStyle = getRandomNumberColor();
ctx.font = `bold ${28}px "Microsoft Yahei"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(problem.options[i]), numberX, numberY);
}
} else if (mode === 'fill') {
// 填一填模式:绘制括号 "( )"
const bracketFontSize = 28;
const bracketSpacing = 46; // 左右括号之间的间距(留空区域)
// 计算括号位置(居中)
const bracketY = bottomAreaY + bottomAreaHeight / 2;
const centerX = boxX + boxWidth / 2;
// 左括号
ctx.fillStyle = '#333';
ctx.font = `${bracketFontSize}px "Microsoft Yahei"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const leftBracketX = centerX - bracketSpacing / 2;
ctx.fillText('(', leftBracketX, bracketY);
// 右括号
const rightBracketX = centerX + bracketSpacing / 2;
ctx.fillText(')', rightBracketX, bracketY);
}
}
/**
* 在框内绘制多张图片
*/
async function 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 padding = 8; // 框内边距
const availableWidth = boxWidth - padding * 2;
const availableHeight = boxHeight - padding * 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/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 + padding + imageSpacing + col * (imageSize + imageSpacing);
const imageY =
boxY +
padding +
(rows > 1 ? rowSpacing : (availableHeight - imageSize) / 2) +
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);
}
}
}
@@ -0,0 +1,73 @@
import { BaseDrawService } from '../../../service/baseDraw';
import { drawCountingSelectContent } from './countingSelectContentDraw';
/**
* 数一数选一选/填一填绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
* 支持两种模式:'select'(选一选)和 'fill'(填一填)
*/
class CountingSelectDraw extends BaseDrawService {
countingData: {
problems: Array<{
count: number;
imageIndex: number;
imageType: 'fruits' | 'twelve-animals';
options?: number[];
correctIndex?: number;
}>;
} | null;
mode: 'select' | 'fill';
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.countingData = null;
this.mode = 'select'; // 默认选一选模式
}
async draw(
countingData: {
problems: Array<{
count: number;
imageIndex: number;
imageType: 'fruits' | 'twelve-animals';
options?: number[];
correctIndex?: number;
}>;
},
mode: 'select' | 'fill' = 'select',
) {
if (!countingData || !countingData.problems) {
return;
}
this.setPrintConfig();
this.countingData = countingData;
this.mode = mode;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域
this.drawDivider();
await drawCountingSelectContent({
canvas: this.canvas,
ctx: this.ctx,
countingData: this.countingData,
mode: this.mode,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
}
export default CountingSelectDraw;
@@ -0,0 +1,133 @@
interface DrawMissingNumberContentParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
missingNumberData: {
grids: Array<{
numbers: (number | null)[];
colors: (string | null)[];
}>;
maxNumber: number;
};
canvasWidth: number;
startY: number;
}
const gridMap = {
10: {
cellSize: 95,
colsPerGrid: 5,
fontSize: 50,
gridSpacing: 40,
},
20: {
cellSize: 80,
colsPerGrid: 5,
fontSize: 40,
gridSpacing: 30,
},
40: {
cellSize: 60,
colsPerGrid: 8,
fontSize: 35,
gridSpacing: 30,
},
50: {
cellSize: 52,
colsPerGrid: 10,
fontSize: 28,
gridSpacing: 40,
},
80: {
cellSize: 60,
colsPerGrid: 8,
fontSize: 30,
gridSpacing: 30,
},
100: {
cellSize: 52,
colsPerGrid: 10,
fontSize: 28,
gridSpacing: 20,
},
120: {
cellSize: 52,
colsPerGrid: 10,
fontSize: 28,
gridSpacing: 20,
},
};
/**
* 绘制填上缺少的数字内容区域
* 显示多个网格,每个网格包含一些数字和一些空白位置
*/
export async function drawMissingNumberContent({
ctx,
missingNumberData,
canvasWidth,
startY,
}: DrawMissingNumberContentParams): Promise<void> {
const { grids, maxNumber } = missingNumberData;
// 计算网格布局参数
const leftMargin = 40;
const rightMargin = 40;
const topMargin = 20;
const config = gridMap[maxNumber as keyof typeof gridMap];
const { cellSize, gridSpacing, colsPerGrid, fontSize } = config;
const gridWidth = colsPerGrid * cellSize;
// 计算网格的起始X位置(居中)
const gridStartX =
leftMargin + (canvasWidth - leftMargin - rightMargin - gridWidth) / 2;
let currentY = startY + topMargin;
// 绘制每个网格
for (let gridIndex = 0; gridIndex < grids.length; gridIndex++) {
const grid = grids[gridIndex];
const gridX = gridStartX;
// 根据实际数字数量计算需要的行数
const rowsPerGrid = Math.ceil(grid.numbers.length / colsPerGrid);
const gridHeight = rowsPerGrid * cellSize;
const gridY = currentY;
// 绘制每个单元格
for (let index = 0; index < grid.numbers.length; index++) {
const row = Math.floor(index / colsPerGrid);
const col = index % colsPerGrid;
const cellX = gridX + col * cellSize;
const cellY = gridY + row * cellSize;
// 绘制单元格边框
ctx.strokeStyle = '#333';
ctx.lineWidth = 1.5;
ctx.strokeRect(cellX, cellY, cellSize, cellSize);
// 绘制数字或空白
const number = grid.numbers[index];
const color = grid.colors[index];
if (number !== null && color !== null) {
// 绘制数字(带颜色)
ctx.fillStyle = color;
ctx.font = `bold ${fontSize}px "Microsoft Yahei"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
String(number),
cellX + cellSize / 2,
cellY + cellSize / 2,
);
}
// 如果为null,则留空(用户填写)
}
// 更新下一个网格的Y位置
currentY += gridHeight + gridSpacing;
}
}
@@ -0,0 +1,64 @@
import { BaseDrawService } from '../../../service/baseDraw';
import { drawMissingNumberContent } from './missingNumberContentDraw';
/**
* 填上缺少的数字绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class MissingNumberDraw extends BaseDrawService {
missingNumberData: {
grids: Array<{
numbers: (number | null)[];
colors: (string | null)[];
}>;
maxNumber: number;
} | null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.missingNumberData = null;
}
async draw(
missingNumberData: {
grids: Array<{
numbers: (number | null)[];
colors: (string | null)[];
}>;
maxNumber: number;
},
rangeType: string = 'within-10',
) {
if (!missingNumberData || !missingNumberData.grids) {
return;
}
this.setPrintConfig();
this.missingNumberData = missingNumberData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域(填上缺少的数字)
this.drawDivider();
await drawMissingNumberContent({
canvas: this.canvas,
ctx: this.ctx,
missingNumberData: this.missingNumberData,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
}
export default MissingNumberDraw;
@@ -0,0 +1,459 @@
import { getNumberColors } from '../../../constants/colors';
interface DrawNumberColorContentParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
colorData: {
numbers: number[];
};
canvasWidth: number;
startY: number;
patternType?: string; // 'circle' 或 'caterpillar'
}
/**
* 绘制圆角矩形(虚线边框)
*/
function drawRoundedRect(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
radius: number,
isDashed: boolean = true,
) {
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, 6]); // 虚线
} else {
ctx.setLineDash([]);
}
ctx.stroke();
ctx.setLineDash([]); // 重置为实线
}
/**
* 绘制毛毛虫头部
*/
function drawCaterpillarHead(
ctx: RenderingContext,
x: number,
y: number,
size: number,
color: string,
) {
const headRadius = 18; // 固定头部半径
const headCenterX = x + headRadius;
const headCenterY = y + size / 2;
// 绘制头部(圆形)
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(headCenterX, headCenterY, headRadius, 0, Math.PI * 2);
ctx.fill();
// 绘制头部圆圈实线边框
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.setLineDash([]); // 确保是实线
ctx.beginPath();
ctx.arc(headCenterX, headCenterY, headRadius, 0, Math.PI * 2);
ctx.stroke();
// 绘制触角(在头部圆圈的正上方,带弧度的弯曲)
const antennaLength = headRadius * 0.6;
const antennaStartY = headCenterY - headRadius; // 头部圆圈的正上方
const antennaOffset = headRadius * 0.3; // 触角左右偏移距离
// 左侧触角:从顶部左侧开始,先向上,然后向左弯曲(带弧度)
const leftAntennaStartX = headCenterX - antennaOffset;
const leftAntennaStartY = antennaStartY;
const leftAntennaMidY = leftAntennaStartY - antennaLength * 0.5; // 向上延伸的中点
const leftAntennaEndX = leftAntennaStartX - antennaLength * 0.4; // 向左弯曲
const leftAntennaEndY = leftAntennaMidY - antennaLength * 0.3; // 向上并向左
// 右侧触角:从顶部右侧开始,先向上,然后向右弯曲(带弧度)
const rightAntennaStartX = headCenterX + antennaOffset;
const rightAntennaStartY = antennaStartY;
const rightAntennaMidY = rightAntennaStartY - antennaLength * 0.5; // 向上延伸的中点
const rightAntennaEndX = rightAntennaStartX + antennaLength * 0.4; // 向右弯曲
const rightAntennaEndY = rightAntennaMidY - antennaLength * 0.3; // 向上并向右
ctx.strokeStyle = '#000';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round'; // 圆角端点
// 绘制左侧触角(使用二次贝塞尔曲线实现平滑弧度)
ctx.beginPath();
ctx.moveTo(leftAntennaStartX, leftAntennaStartY);
// 使用二次贝塞尔曲线:起点、控制点、终点
// 控制点在中间位置,使曲线平滑向左弯曲
const leftControlX = leftAntennaStartX - antennaLength * 0.2;
const leftControlY = leftAntennaMidY;
ctx.quadraticCurveTo(
leftControlX,
leftControlY,
leftAntennaEndX,
leftAntennaEndY,
);
ctx.stroke();
// 绘制右侧触角(使用二次贝塞尔曲线实现平滑弧度)
ctx.beginPath();
ctx.moveTo(rightAntennaStartX, rightAntennaStartY);
// 使用二次贝塞尔曲线:起点、控制点、终点
// 控制点在中间位置,使曲线平滑向右弯曲
const rightControlX = rightAntennaStartX + antennaLength * 0.2;
const rightControlY = rightAntennaMidY;
ctx.quadraticCurveTo(
rightControlX,
rightControlY,
rightAntennaEndX,
rightAntennaEndY,
);
ctx.stroke();
// 绘制触角末端的小圆点
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(leftAntennaEndX, leftAntennaEndY, 2, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(rightAntennaEndX, rightAntennaEndY, 2, 0, Math.PI * 2);
ctx.fill();
// 绘制眼睛
const eyeSize = headRadius * 0.15;
const eyeOffsetX = headRadius * 0.25;
const eyeOffsetY = headRadius * 0.22;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(
headCenterX - eyeOffsetX - 2,
headCenterY - eyeOffsetY,
eyeSize,
0,
Math.PI * 2,
);
ctx.fill();
ctx.beginPath();
ctx.arc(
headCenterX + eyeOffsetX - 2,
headCenterY - eyeOffsetY,
eyeSize,
0,
Math.PI * 2,
);
ctx.fill();
// 绘制眼珠
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(
headCenterX - eyeOffsetX - 2,
headCenterY - eyeOffsetY,
eyeSize * 0.6,
0,
Math.PI * 2,
);
ctx.fill();
ctx.beginPath();
ctx.arc(
headCenterX + eyeOffsetX - 2,
headCenterY - eyeOffsetY,
eyeSize * 0.6,
0,
Math.PI * 2,
);
ctx.fill();
// 绘制嘴巴(微笑)
ctx.strokeStyle = '#000';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(
headCenterX - 2,
headCenterY + headRadius * 0.22,
headRadius * 0.3,
0,
Math.PI,
);
ctx.stroke();
}
/**
* 绘制毛毛虫身体(带弧度的圆圈)
*/
function drawCaterpillarBody(
ctx: RenderingContext,
startX: number,
startY: number,
count: number,
bodyRadius: number,
color: string,
) {
const spacing = bodyRadius * 2; // 身体圆圈之间的间距(稍微重叠)
const waveAmplitude = bodyRadius * 0.4; // 波浪幅度
for (let i = 0; i < count; i++) {
// 计算每个身体圆圈的位置(带弧度,形成弯曲效果)
const baseX = startX + i * spacing;
// 使用正弦函数创建波浪效果,使身体有弧度
const waveOffset = Math.sin((i * Math.PI) / 2.5) * waveAmplitude;
const bodyX = baseX;
const bodyY = startY + waveOffset;
// 绘制身体圆圈
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(bodyX, bodyY, bodyRadius, 0, Math.PI * 2);
ctx.fill();
// 绘制边框(可选,根据设计需求)
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.stroke();
}
}
/**
* 绘制圆圈模式的内容
*/
function drawCirclePattern(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
number: number,
numberColor: string,
isFirstRow: boolean,
) {
const totalCircles = 10;
const circleRadius = 16; // 固定半径
const circleSpacing = 6; // 固定间距
// 计算所有圆圈的总宽度
const totalWidth =
totalCircles * circleRadius * 2 + (totalCircles - 1) * circleSpacing;
// 计算水平居中位置
const startX = x + (width - totalWidth) / 2 + circleRadius;
// 计算垂直居中位置
const centerY = y + height / 2;
// 绘制10个虚线圆圈(一行)
for (let i = 0; i < totalCircles; i++) {
const circleX = startX + i * (circleRadius * 2 + circleSpacing);
// 绘制虚线圆圈
ctx.strokeStyle = numberColor;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.arc(circleX, centerY, circleRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// 第一行:根据数字数量涂色
if (isFirstRow && i < number) {
ctx.fillStyle = numberColor;
ctx.beginPath();
ctx.arc(circleX, centerY, circleRadius - 1, 0, Math.PI * 2);
ctx.fill();
}
}
}
/**
* 绘制毛毛虫模式的内容
*/
function drawCaterpillarPattern(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
number: number,
numberColor: string,
isFirstRow: boolean,
) {
const padding = 15;
const headSize = 45; // 头部区域高度(用于垂直居中)
const headRadius = 18; // 固定头部半径
const bodyRadius = 16; // 固定身体半径
if (isFirstRow) {
// 第一行:绘制完整的毛毛虫(头部+身体)
const headX = x + padding;
const headY = y + (height - headSize) / 2;
// 绘制头部
drawCaterpillarHead(ctx, headX, headY, headSize, numberColor);
// 计算头部的实际宽度(头部半径 * 2)
const headWidth = headRadius * 2; // 头部的实际宽度 = 36
// 绘制身体(从头部右侧边缘开始,留一点间距)
const bodyStartX = headX + headWidth + 17; // 头部右边缘 + 5px间距
const bodyStartY = headY + headSize / 2; // 与头部中心Y对齐
drawCaterpillarBody(
ctx,
bodyStartX,
bodyStartY,
number,
bodyRadius,
numberColor,
);
} else {
// 其他行:只绘制头部
const headX = x + padding;
const headY = y + (height - headSize) / 2;
drawCaterpillarHead(ctx, headX, headY, headSize, numberColor);
}
}
/**
* 绘制按数字涂颜色内容区域
*/
export async function drawNumberColorContent({
canvas,
ctx,
colorData,
canvasWidth,
startY,
patternType = 'circle',
}: DrawNumberColorContentParams): Promise<void> {
const { numbers } = colorData;
const leftMargin = 24;
const rightMargin = 24;
const itemSpacing = 115; // 行间距
const leftBoxWidth = 100;
const rightBoxWidth = 420;
const boxHeight = 80;
const borderRadius = 12;
const startYPos = startY + 20;
// 获取所有可用颜色值
const availableColors = getNumberColors();
// 为每一行随机分配颜色,确保不重复
const shuffledColors = [...availableColors];
// Fisher-Yates 洗牌算法
for (let i = shuffledColors.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffledColors[i], shuffledColors[j]] = [
shuffledColors[j],
shuffledColors[i],
];
}
// 为每一行分配颜色(确保不重复)
// 如果行数超过颜色数量,从剩余颜色中随机选择,但确保相邻行不同
const rowColors: string[] = [];
for (let i = 0; i < numbers.length; i++) {
if (i < shuffledColors.length) {
// 前 N 行(N <= 颜色数量)使用不同的颜色
rowColors.push(shuffledColors[i]);
} else {
// 如果行数超过颜色数量,从剩余颜色中选择(确保与上一行不同)
const remainingColors = shuffledColors.filter(
(color) => color !== rowColors[i - 1],
);
const randomColor =
remainingColors[
Math.floor(Math.random() * remainingColors.length)
];
rowColors.push(randomColor);
}
}
// 计算左侧和右侧的起始X位置
const leftBoxX = leftMargin;
const rightBoxX = canvasWidth - rightMargin - rightBoxWidth;
// 绘制每一行
for (let i = 0; i < numbers.length; i++) {
const boxY = startYPos + i * itemSpacing;
const number = numbers[i];
const numberColor = rowColors[i]; // 使用随机分配的颜色
const isFirstRow = i === 0;
// 绘制左侧数字框(虚线)
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
drawRoundedRect(
ctx,
leftBoxX,
boxY,
leftBoxWidth,
boxHeight,
borderRadius,
true,
);
// 绘制数字(带颜色)
ctx.fillStyle = numberColor;
ctx.font = `bold ${56}px "Microsoft Yahei"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
String(number),
leftBoxX + leftBoxWidth / 2,
boxY + boxHeight / 2,
);
// 绘制右侧内容框(虚线)
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
drawRoundedRect(
ctx,
rightBoxX,
boxY,
rightBoxWidth,
boxHeight,
borderRadius,
true,
);
// 根据模式绘制右侧内容
if (patternType === 'circle') {
drawCirclePattern(
ctx,
rightBoxX,
boxY,
rightBoxWidth,
boxHeight,
number,
numberColor,
isFirstRow,
);
} else if (patternType === 'caterpillar') {
drawCaterpillarPattern(
ctx,
rightBoxX,
boxY,
rightBoxWidth,
boxHeight,
number,
numberColor,
isFirstRow,
);
}
}
}
@@ -0,0 +1,55 @@
import { BaseDrawService } from '../../../service/baseDraw';
import { drawNumberColorContent } from './numberColorContentDraw';
/**
* 按数字涂颜色绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class NumberColorDraw extends BaseDrawService {
colorData: {
numbers: number[];
} | null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.colorData = null;
}
async draw(
colorData: { numbers: number[] },
patternType: string = 'circle', // 'circle' 或 'caterpillar'
) {
if (!colorData || !colorData.numbers) {
return;
}
this.setPrintConfig();
this.colorData = colorData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域(按数字涂颜色)
this.drawDivider();
await drawNumberColorContent({
canvas: this.canvas,
ctx: this.ctx,
colorData: this.colorData,
canvasWidth: this.canvasWidth,
startY: this.currentY,
patternType,
});
}
}
export default NumberColorDraw;
@@ -0,0 +1,78 @@
/**
* 绘制内容区域的参数接口
*/
interface DrawNumberContentParams {
ctx: RenderingContext;
selectedNumber: number;
canvasWidth: number; // 逻辑像素宽度(已除以3
startY: number; // 起始Y坐标
}
/**
* 绘制数字涂色内容区域服务
* 随机绘制1-10的数字,尺寸除以3
*/
export function drawNumberContent({
ctx,
selectedNumber,
canvasWidth,
startY,
}: DrawNumberContentParams): void {
const rows = 6;
const radius = 26; // 约26.67
const fontSize = 24; // 24
const startYPos = startY + 10 + radius; // 约47.33
const startX1 = 100 + radius; // 约112.22
const startX2 = 60 + radius; // 约75.56
const spaceWidthFirst = (canvasWidth - startX1 * 2) / (5 - 1);
const spaceWidthSecond = (canvasWidth - startX2 * 2) / (6 - 1);
const spaceHeight = 18; // 18
ctx.moveTo(startX1, startYPos);
for (let i = 0; i < rows; i++) {
const cols = i % 2 === 0 ? 5 : 6;
for (let j = 0; j < cols; j++) {
const y = startYPos + i * (spaceHeight + radius * 2);
const x =
i % 2 === 0
? startX1 + j * spaceWidthFirst
: startX2 + j * spaceWidthSecond;
// 随机生成1-10的数字,但确保包含选中的数字
let number: number;
if (i === 0 && j === 0) {
// 第一个位置固定为选中的数字
number = selectedNumber;
} else {
// 其他位置随机,但增加选中数字出现的概率
const random = Math.random();
if (random < 0.3) {
// 30%概率是选中的数字
number = selectedNumber;
} else {
// 70%概率是其他随机数字
number = Math.floor(Math.random() * 10) + 1;
}
}
// 绘制圆形背景(线宽除以3
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 1; // 约1.33
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.closePath();
// 绘制数字(字体大小除以3
ctx.fillStyle = '#333';
ctx.font = `bold ${fontSize}px "Microsoft Yahei"`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText(String(number), x, y, radius * 2);
}
}
}
@@ -0,0 +1,504 @@
import { getImage } from '../../../utils/index';
import { getRandomNumberColor } from '../../../constants/colors';
interface DrawNumberDecomposeContentParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
decomposeData: {
problems: Array<{
whole: number | null;
part1: number | null;
part2: number | null;
imageIndex?: number;
imageType?: 'fruits' | 'twelve-animals';
}>;
mode: 'with-image' | 'decompose' | 'compose';
};
canvasWidth: number;
startY: number;
}
/**
* 绘制圆角矩形(虚线边框)
*/
function drawRoundedRect(
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 async function drawNumberDecomposeContent({
canvas,
ctx,
decomposeData,
canvasWidth,
startY,
}: DrawNumberDecomposeContentParams): Promise<void> {
const { problems, mode } = decomposeData;
if (mode === 'with-image') {
await drawWithImageMode(canvas, ctx, problems, canvasWidth, startY);
} else if (mode === 'decompose') {
await drawDecomposeOrComposeMode(
ctx,
problems,
canvasWidth,
startY,
false,
);
} else if (mode === 'compose') {
await drawDecomposeOrComposeMode(
ctx,
problems,
canvasWidth,
startY,
true,
);
}
}
/**
* 有图片模式:一行三列,总共三行(9个题目)
*/
async function 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 drawWithImageProblem(
ctx,
canvas,
problem,
boxX,
boxY,
boxWidth,
imageAreaHeight,
treeAreaHeight,
);
}
}
}
/**
* 分模式/组合模式:一行3个,总共5行(15个题目)
* @param isInverted true 为组合模式(倒置二叉树),false 为分模式(正常二叉树)
*/
async function 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;
// 绘制二叉树
drawTree(ctx, problem, boxX, boxY, boxWidth, boxHeight, isInverted);
}
}
}
/**
* 绘制有图片模式的单个题目
*/
async function 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;
drawRoundedRect(
ctx,
boxX,
boxY,
boxWidth,
imageAreaHeight,
borderRadius,
false,
);
// 图片区域(增加底部间距,避免与二叉树根节点重叠)
const imageBottomPadding = 20; // 图片区域底部额外间距
const effectiveImageHeight = imageAreaHeight - imageBottomPadding;
if (problem.imageIndex && problem.imageType && problem.whole) {
await drawImagesInBox(
ctx,
canvas,
problem.whole,
problem.imageIndex,
problem.imageType,
boxX,
boxY,
boxWidth,
effectiveImageHeight,
);
}
// 二叉树区域(增加与图片区域的间距)
const treeTopSpacing = -5; // 二叉树区域顶部间距
const treeY = boxY + imageAreaHeight + treeTopSpacing;
drawTree(
ctx,
problem,
boxX,
treeY,
boxWidth,
treeAreaHeight,
false, // 不是倒置
);
}
/**
* 绘制二叉树结构
* @param isInverted 是否倒置(组合模式为true)
*/
function 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;
// 左子节点
drawNode(ctx, leftNodeX, topY, nodeRadius, problem.part1);
// 右子节点
drawNode(ctx, rightNodeX, topY, nodeRadius, problem.part2);
// 绘制根节点(底部)
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;
// 绘制根节点(顶部)
drawNode(ctx, centerX, topY, nodeRadius, problem.whole);
// 绘制两个子节点(底部)
const leftNodeX = centerX - nodeSpacing / 2 - nodeRadius;
const rightNodeX = centerX + nodeSpacing / 2 + nodeRadius;
// 左子节点
drawNode(ctx, leftNodeX, bottomY, nodeRadius, problem.part1);
// 右子节点
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();
}
}
/**
* 绘制节点(圆圈和数字)
*/
function 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);
}
}
/**
* 在框内绘制多张图片
*/
async function 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/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);
}
}
}
@@ -0,0 +1,67 @@
import { BaseDrawService } from '../../../service/baseDraw';
import { drawNumberDecomposeContent } from './numberDecomposeContentDraw';
/**
* 10以内数的分与合绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class NumberDecomposeDraw extends BaseDrawService {
decomposeData: {
problems: Array<{
whole: number | null;
part1: number | null;
part2: number | null;
imageIndex?: number;
imageType?: 'fruits' | 'twelve-animals';
}>;
mode: 'with-image' | 'decompose' | 'compose';
} | null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.decomposeData = null;
}
async draw(decomposeData: {
problems: Array<{
whole: number | null;
part1: number | null;
part2: number | null;
imageIndex?: number;
imageType?: 'fruits' | 'twelve-animals';
}>;
mode: 'with-image' | 'decompose' | 'compose';
}) {
if (!decomposeData || !decomposeData.problems) {
return;
}
this.setPrintConfig();
this.decomposeData = decomposeData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域
this.drawDivider();
await drawNumberDecomposeContent({
canvas: this.canvas,
ctx: this.ctx,
decomposeData: this.decomposeData,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
}
export default NumberDecomposeDraw;
@@ -0,0 +1,76 @@
import { BaseDrawService } from '../../../service/baseDraw';
import { drawNumberPreview } from './numberPreviewDraw';
import { drawNumberContent } from './numberContentDraw';
import { drawNumberWriteContent } from './numberWriteDraw';
/**
* 数字涂色绘制服务
* 组合使用基础绘制服务、预览区域绘制服务和内容区域绘制服务
*/
class NumberFindDraw extends BaseDrawService {
selectedNumber: number;
functionId: string; // 功能ID,用于判断绘制类型
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.selectedNumber = 0;
this.functionId = options?.functionId || 'number-find';
}
async draw(selectedNumber: number) {
if (selectedNumber <= 0 || selectedNumber > 10) {
return;
}
this.setPrintConfig();
this.selectedNumber = selectedNumber;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制预览区域
this.drawDivider();
await drawNumberPreview({
canvas: this.canvas,
ctx: this.ctx,
selectedNumber: this.selectedNumber,
canvasWidth: this.canvasWidth,
startY: this.currentY,
onDrawn: (currentY) => {
this.currentY = currentY;
},
});
// 绘制内容区域(根据 functionId 选择不同的绘制方法)
this.drawDivider();
if (this.functionId === 'number-write') {
// 书写类型:绘制书写行
drawNumberWriteContent({
ctx: this.ctx,
selectedNumber: this.selectedNumber,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
} else {
// 默认类型(number-find):绘制数字涂色内容
drawNumberContent({
ctx: this.ctx,
selectedNumber: this.selectedNumber,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
}
}
export default NumberFindDraw;
@@ -0,0 +1,138 @@
import { getImage } from '../../../utils/index';
/**
* 数字到英文单词的映射
*/
const NUMBER_WORDS: Record<number, string> = {
1: 'one',
2: 'two',
3: 'three',
4: 'four',
5: 'five',
6: 'six',
7: 'seven',
8: 'eight',
9: 'nine',
10: 'ten',
};
const NUMBER_COLORS: Record<number, string> = {
1: '#9FC558',
2: '#CDB984',
3: '#B393B8',
4: '#ED7E97',
5: '#53B5B9',
6: '#B3B3B3',
7: '#EE8B7A',
8: '#F6C644',
9: '#99C1F4',
10: '#F5D25C',
};
/**
* 绘制预览区域的参数接口
*/
interface DrawNumberPreviewParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
selectedNumber: number;
canvasWidth: number; // 逻辑像素宽度(已除以3
startY: number; // 起始Y坐标
onDrawn?: (currentY: number) => void; // 绘制完成后的回调
}
/**
* 绘制数字预览区域服务
* 左侧:animal-number 图片
* 右侧:两个框,第一个框是finger图片,第二个框是英文单词
* 所有尺寸除以3
*/
export async function drawNumberPreview({
canvas,
ctx,
selectedNumber,
canvasWidth,
startY,
onDrawn,
}: DrawNumberPreviewParams): Promise<void> {
// 左侧 animal-number 图片位置
const animalImageWidth = 360;
const animalImageHeight = 236;
const leftMargin = 35;
const animalImageX = leftMargin;
const animalImageY = startY + 10;
// 加载并绘制 animal-number 图片
try {
const animalImagePath = `/mathPages/assets/animal-number/animal-number-${selectedNumber}.png`;
const animalImage = await getImage(canvas, animalImagePath);
ctx.drawImage(
animalImage,
animalImageX,
animalImageY,
animalImageWidth,
animalImageHeight,
);
} catch (error) {
console.error('加载animal-number图片失败:', error);
}
// 右侧两个框的位置(逻辑像素,除以3)
const boxWidth = 130;
const boxHeight = 100;
const rightMargin = 35;
const boxSpacing = 30;
const boxX = canvasWidth - rightMargin - boxWidth;
// 第一个框:finger 图片
const fingerBoxY = animalImageY;
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.strokeRect(boxX, fingerBoxY, boxWidth, boxHeight);
try {
const fingerImagePath = `/mathPages/assets/finger/finger-${selectedNumber}.png`;
const fingerImage = await getImage(canvas, fingerImagePath);
// 计算图片在框内的位置(居中显示)
const imagePadding = 10;
const imageWidth = boxWidth - imagePadding * 2;
const imageHeight = boxHeight - imagePadding * 2;
ctx.drawImage(
fingerImage,
boxX + imagePadding,
fingerBoxY + imagePadding,
imageWidth,
imageHeight,
);
} catch (error) {
console.error('加载finger图片失败:', error);
}
// 第二个框:英文单词
const wordBoxY = fingerBoxY + boxHeight + boxSpacing;
ctx.strokeRect(boxX, wordBoxY, boxWidth, boxHeight);
// 绘制英文单词(字体大小除以3
const word = NUMBER_WORDS[selectedNumber] || '';
const color = NUMBER_COLORS[selectedNumber] || '#000';
ctx.fillStyle = color;
// 推荐使用系统内常见的圆体或幼圆体字体,更适合儿童视觉风格
ctx.font =
'bold 32px "HarmonyOS Sans", "PingFang SC", "YouYuan", "Microsoft Yahei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
word,
boxX + boxWidth / 2,
wordBoxY + boxHeight / 2,
boxWidth - 20,
);
// 更新 currentY 为预览区域底部
const finalY =
Math.max(animalImageY + animalImageHeight, wordBoxY + boxHeight) + 10;
if (onDrawn) {
onDrawn(finalY);
}
}
@@ -0,0 +1,99 @@
/**
* 绘制书写行内容区域的参数接口
*/
interface DrawNumberWriteContentParams {
ctx: RenderingContext;
selectedNumber: number;
canvasWidth: number; // 逻辑像素宽度
startY: number; // 起始Y坐标
}
/**
* 绘制数字书写内容区域服务
* 绘制三条书写行,每条行有三条线(上下实线,中间虚线)
* 第一行和第二行有数字(第一个黑色实线,后续灰色虚线),第三行空白
*/
export function drawNumberWriteContent({
ctx,
selectedNumber,
canvasWidth,
startY,
}: DrawNumberWriteContentParams): void {
const lineSpacing = 30; // 行间距(三条线之间的间距)
const rowHeight = lineSpacing * 2; // 每行的高度(两条实线之间的距离)
const rowMargin = 24; // 行与行之间的间距
const leftMargin = 24; // 左边距
const rightMargin = 24; // 右边距
const numberIndent = selectedNumber === 10 ? 12 : 24; // 数字缩进(避免贴边)
const numberSpacing = 90; // 数字之间的间距
const numberCount = 6; // 每行数字的数量(第一个实线 + 6个虚线)
const fontSize = 76; // 数字字体大小(调整为行高的83%,避免超出上下边线)
// 计算每行的起始Y坐标
const rowStartY = startY + 20; // 顶部间距
// 绘制三条书写行
for (let rowIndex = 0; rowIndex < 5; rowIndex++) {
const rowY = rowStartY + rowIndex * (rowHeight + rowMargin);
const topLineY = rowY;
const middleLineY = rowY + lineSpacing;
const bottomLineY = rowY + rowHeight;
// 绘制上实线(蓝色)
ctx.strokeStyle = '#4A90E2'; // 蓝色
ctx.lineWidth = 1;
ctx.setLineDash([]); // 实线
ctx.beginPath();
ctx.moveTo(leftMargin, topLineY);
ctx.lineTo(canvasWidth - rightMargin, topLineY);
ctx.stroke();
// 绘制中间虚线(橙色)
ctx.strokeStyle = '#FF8C42'; // 橙色
ctx.lineWidth = 1;
ctx.setLineDash([12, 12]); // 虚线模式
ctx.beginPath();
ctx.moveTo(leftMargin, middleLineY);
ctx.lineTo(canvasWidth - rightMargin, middleLineY);
ctx.stroke();
ctx.setLineDash([]); // 恢复实线模式
// 绘制下实线(蓝色)
ctx.strokeStyle = '#4A90E2'; // 蓝色
ctx.lineWidth = 1;
ctx.setLineDash([]); // 实线
ctx.beginPath();
ctx.moveTo(leftMargin, bottomLineY);
ctx.lineTo(canvasWidth - rightMargin, bottomLineY);
ctx.stroke();
// 第一行和第二行绘制数字
if (rowIndex < 3) {
const centerY = rowY + lineSpacing; // 数字中心Y坐标(中间虚线位置)
for (let i = 0; i < numberCount; i++) {
const numberX = leftMargin + numberIndent + i * numberSpacing;
if (i === 0) {
// 第一个数字:黑色实线
ctx.fillStyle = '#000000';
ctx.font = `bold ${fontSize}px "Microsoft Yahei"`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillText(String(selectedNumber), numberX, centerY);
} else {
// 后续数字:灰色虚线(使用灰色填充配合透明度模拟虚线效果)
const savedAlpha = ctx.globalAlpha;
ctx.globalAlpha = 0.6; // 设置透明度模拟虚线效果
ctx.fillStyle = '#999999'; // 灰色
ctx.font = `${fontSize}px "Microsoft Yahei"`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillText(String(selectedNumber), numberX, centerY);
ctx.globalAlpha = savedAlpha; // 恢复透明度
}
}
}
// 第三行不绘制数字(空白行)
}
}