Merge branch 'feature-2.5.6' into feature-2.6.0

This commit is contained in:
2025-12-22 20:29:46 +08:00
96 changed files with 3448 additions and 3276 deletions
+2 -1
View File
@@ -17,7 +17,8 @@
"missingNumber/missingNumber",
"compare/compare",
"countingSelect/countingSelect",
"numberDecompose/numberDecompose"
"numberDecompose/numberDecompose",
"numberSort/numberSort"
],
"independent": false
},
+7
View File
@@ -75,6 +75,13 @@ export const MATH_FUNCTION_TYPES: MathFunctionType[] = [
icon: '❓',
img: '/assets/mathEntrance/missing-number.png',
},
{
id: 'number-sort',
page: 'numberSort',
title: '数字排序',
desc: '数字牌被打乱了,把正确的数字按顺序写下来吧~',
icon: '🔢',
},
{
id: 'number-decompose',
page: 'numberDecompose',
@@ -1,38 +1,6 @@
<view class="page-container">
<view class="wrapper">
<text class="wrapper-title">预览打印效果</text>
<!-- 模版不能跨包引用 -->
<import src="../shared/templates/canvas-page-template.wxml" />
<!-- 预览打印效果 -->
<view id="canvasWrapper" class="canvas-wrapper">
<canvas
type="2d"
id="canvasContent"
class="canvas-content"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
<!-- 随机生成按钮 -->
<view class="random-button-area">
<toy-button
class="random-button"
type="primary"
bind:click="onRandom"
width="100%"
height="80rpx"
icon="refresh"
icon-class-prefix="toy-icon">
随机生成
</toy-button>
</view>
<draw-ad type="focusDraw"></draw-ad>
</view>
<view class="empty"></view>
</view>
<math-bottom-buttons
disabled="{{!hasContent}}"
bind:share="onShareAppMessage"
bind:export="exportToPrint" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
<template
is="canvasPageTemplate"
data="{{boxWidth: boxWidth, boxHeight: boxHeight, hasTypeSelector: false, adType: 'focusDraw', disabled: !hasContent, showShareDialog: showShareDialog}}" />
@@ -1,38 +1,6 @@
<view class="page-container">
<view class="wrapper">
<text class="wrapper-title">预览打印效果</text>
<!-- 模版不能跨包引用 -->
<import src="../shared/templates/canvas-page-template.wxml" />
<!-- 预览打印效果 -->
<view id="canvasWrapper" class="canvas-wrapper">
<canvas
type="2d"
id="canvasContent"
class="canvas-content"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
<!-- 随机生成按钮 -->
<view class="random-button-area">
<toy-button
class="random-button"
type="primary"
bind:click="onRandom"
width="100%"
height="80rpx"
icon="refresh"
icon-class-prefix="toy-icon">
随机生成
</toy-button>
</view>
<draw-ad type="focusDraw"></draw-ad>
</view>
<view class="empty"></view>
</view>
<math-bottom-buttons
disabled="{{!hasContent}}"
bind:share="onShareAppMessage"
bind:export="exportToPrint" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
<template
is="canvasPageTemplate"
data="{{boxWidth: boxWidth, boxHeight: boxHeight, hasTypeSelector: false, adType: 'focusDraw', disabled: !hasContent, showShareDialog: showShareDialog}}" />
@@ -1,32 +1,15 @@
<view class="page-container">
<view class="wrapper">
<text class="wrapper-title">预览打印效果</text>
<!-- 模版不能跨包引用 -->
<import src="../shared/templates/canvas-page-template.wxml" />
<!-- 预览打印效果 -->
<view id="canvasWrapper" class="canvas-wrapper">
<canvas
type="2d"
id="canvasContent"
class="canvas-content"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
<!-- 类型选择器和随机生成按钮 -->
<math-type-selector
current-type-name="{{currentModeName}}"
type-actions="{{typeActions}}"
bind:select="onSelectType"
bind:random="onRandom" />
<draw-ad type="focusDraw"></draw-ad>
</view>
<view class="empty"></view>
</view>
<math-bottom-buttons
disabled="{{!hasContent}}"
bind:share="onShareAppMessage"
bind:export="exportToPrint" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
<template
is="canvasPageTemplate"
data="{{
boxWidth: boxWidth,
boxHeight: boxHeight,
hasTypeSelector: true,
currentModeName: currentModeName,
typeActions: typeActions,
adType: 'focusDraw',
disabled: !hasContent,
showShareDialog: showShareDialog
}}" />
@@ -1,31 +1,15 @@
<view class="page-container">
<view class="wrapper">
<text class="wrapper-title">预览打印效果</text>
<!-- 模版不能跨包引用 -->
<import src="../shared/templates/canvas-page-template.wxml" />
<!-- 预览打印效果 -->
<view id="canvasWrapper" class="canvas-wrapper">
<canvas
type="2d"
id="canvasContent"
class="canvas-content"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
<!-- 类型选择器和随机生成按钮 -->
<math-type-selector
current-type-name="{{currentOperatorTypeName}}"
type-actions="{{operatorTypeActions}}"
bind:select="onSelectType"
bind:random="onRandom" />
<draw-ad type="focusDraw"></draw-ad>
</view>
<view class="empty"></view>
</view>
<math-bottom-buttons
disabled="{{!hasContent}}"
bind:share="onShareAppMessage"
bind:export="exportToPrint" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
<template
is="canvasPageTemplate"
data="{{
boxWidth: boxWidth,
boxHeight: boxHeight,
hasTypeSelector: true,
currentModeName: currentOperatorTypeName,
typeActions: operatorTypeActions,
adType: 'focusDraw',
disabled: !hasContent,
showShareDialog: showShareDialog
}}" />
@@ -1,38 +1,6 @@
<view class="page-container">
<view class="wrapper">
<text class="wrapper-title">预览打印效果</text>
<!-- 模版不能跨包引用 -->
<import src="../shared/templates/canvas-page-template.wxml" />
<!-- 预览打印效果 -->
<view id="canvasWrapper" class="canvas-wrapper">
<canvas
type="2d"
id="canvasContent"
class="canvas-content"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
<!-- 随机生成按钮 -->
<view class="random-button-area">
<toy-button
class="random-button"
type="primary"
bind:click="onRandom"
width="100%"
height="80rpx"
icon="refresh"
icon-class-prefix="toy-icon">
随机生成
</toy-button>
</view>
<draw-ad type="focusDraw"></draw-ad>
</view>
<view class="empty"></view>
</view>
<math-bottom-buttons
disabled="{{!hasContent}}"
bind:share="onShareAppMessage"
bind:export="exportToPrint" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
<template
is="canvasPageTemplate"
data="{{boxWidth: boxWidth, boxHeight: boxHeight, hasTypeSelector: false, adType: 'focusDraw', disabled: !hasContent, showShareDialog: showShareDialog}}" />
@@ -1,38 +1,6 @@
<view class="page-container">
<view class="wrapper">
<text class="wrapper-title">预览打印效果</text>
<!-- 模版不能跨包引用 -->
<import src="../shared/templates/canvas-page-template.wxml" />
<!-- 预览打印效果 -->
<view id="canvasWrapper" class="canvas-wrapper">
<canvas
type="2d"
id="canvasContent"
class="canvas-content"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
<!-- 随机生成按钮 -->
<view class="random-button-area">
<toy-button
class="random-button"
type="primary"
bind:click="onRandom"
width="100%"
height="80rpx"
icon="refresh"
icon-class-prefix="toy-icon">
随机生成
</toy-button>
</view>
<draw-ad type="focusDraw"></draw-ad>
</view>
<view class="empty"></view>
</view>
<math-bottom-buttons
disabled="{{!hasContent}}"
bind:share="onShareAppMessage"
bind:export="exportToPrint" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
<template
is="canvasPageTemplate"
data="{{boxWidth: boxWidth, boxHeight: boxHeight, hasTypeSelector: false, adType: 'focusDraw', disabled: !hasContent, showShareDialog: showShareDialog}}" />
@@ -1,38 +1,6 @@
<view class="page-container">
<view class="wrapper">
<text class="wrapper-title">预览打印效果</text>
<!-- 模版不能跨包引用 -->
<import src="../shared/templates/canvas-page-template.wxml" />
<!-- 预览打印效果 -->
<view id="canvasWrapper" class="canvas-wrapper">
<canvas
type="2d"
id="canvasContent"
class="canvas-content"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
<!-- 随机生成按钮 -->
<view class="random-button-area">
<toy-button
class="random-button"
type="primary"
bind:click="onRandom"
width="100%"
height="80rpx"
icon="refresh"
icon-class-prefix="toy-icon">
随机生成
</toy-button>
</view>
<draw-ad type="focusDraw"></draw-ad>
</view>
<view class="empty"></view>
</view>
<math-bottom-buttons
disabled="{{!hasContent}}"
bind:share="onShareAppMessage"
bind:export="exportToPrint" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
<template
is="canvasPageTemplate"
data="{{boxWidth: boxWidth, boxHeight: boxHeight, hasTypeSelector: false, adType: 'focusDraw', disabled: !hasContent, showShareDialog: showShareDialog}}" />
@@ -50,20 +50,9 @@ class CodeConnectDraw extends BaseDrawService {
return;
}
this.setPrintConfig();
this.codeData = codeData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域
this.drawDivider();
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
@@ -41,20 +41,9 @@ class ColorPatternDraw extends BaseDrawService {
return;
}
this.setPrintConfig();
this.gridData = gridData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域
this.drawDivider();
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawContent({
ctx: this.ctx,
gridData: this.gridData,
@@ -51,20 +51,9 @@ class GridDraw extends BaseDrawService {
return;
}
this.setPrintConfig();
this.gridData = gridData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域(网格和填充)
this.drawDivider();
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawGridContent({
canvas: this.canvas,
ctx: this.ctx,
@@ -60,20 +60,9 @@ class GridReasoningDraw extends BaseDrawService {
return;
}
this.setPrintConfig();
this.gridData = gridData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域
this.drawDivider();
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
@@ -83,20 +83,9 @@ class LineRecognitionDraw extends BaseDrawService {
await this.loadSpiralLineImage();
}
this.setPrintConfig();
this.lineData = lineData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域
this.drawDivider();
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
@@ -39,20 +39,9 @@ class MatchConnectDraw extends BaseDrawService {
return;
}
this.setPrintConfig();
this.matchData = matchData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域
this.drawDivider();
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
@@ -40,20 +40,9 @@ class PositionColoringDraw extends BaseDrawService {
return;
}
this.setPrintConfig();
this.gridData = gridData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域
this.drawDivider();
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
@@ -45,20 +45,9 @@ class ShapeSymbolDraw extends BaseDrawService {
return;
}
this.setPrintConfig();
this.gridData = gridData;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域
this.drawDivider();
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawContent({
ctx: this.ctx,
gridData: this.gridData,
@@ -0,0 +1,78 @@
<!--
Canvas 页面模板
用于统一管理 Canvas 绘制页面的通用结构
使用方式:
<import src="../../templates/canvas-page-template.wxml" />
<template
is="canvasPageTemplate"
data="{{boxWidth: boxWidth, boxHeight: boxHeight, hasTypeSelector: false, adType: 'focusDraw', disabled: !hasContent, showShareDialog: showShareDialog}}" />
参数说明:
- boxWidth: Canvas 宽度
- boxHeight: Canvas 高度
- hasTypeSelector: 是否显示类型选择器(布尔值)
- showTypeSelector: 条件显示类型选择器(可选,用于 countMatch、missingNumber 等页面)
- currentModeName: 当前模式名称(类型选择器使用)
- typeActions: 类型选项数组(类型选择器使用)
- adType: 广告位类型('focusDraw' 或 'mathDraw'
- disabled: 底部按钮是否禁用
- showShareDialog: 是否显示分享弹窗
-->
<template name="canvasPageTemplate">
<view class="page-container">
<view class="wrapper">
<text class="wrapper-title">预览打印效果</text>
<!-- 预览打印效果 -->
<view id="canvasWrapper" class="canvas-wrapper">
<canvas
type="2d"
id="canvasContent"
class="canvas-content"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
<!-- 类型选择器和随机生成按钮 -->
<!-- 支持两种方式:hasTypeSelector(布尔值)或 showTypeSelector(条件显示) -->
<math-type-selector
wx:if="{{hasTypeSelector || showTypeSelector}}"
current-type-name="{{currentModeName}}"
type-actions="{{typeActions}}"
bind:select="onSelectType"
bind:random="onRandom" />
<!-- 随机生成按钮(当没有类型选择器时显示) -->
<view
class="random-button-area"
wx:if="{{!hasTypeSelector && !showTypeSelector}}">
<toy-button
class="random-button"
type="primary"
bind:click="onRandom"
width="100%"
height="80rpx"
icon="refresh"
icon-class-prefix="toy-icon">
随机生成
</toy-button>
</view>
<!-- 广告位 -->
<draw-ad type="{{adType}}"></draw-ad>
</view>
<view class="empty"></view>
</view>
<!-- 底部按钮 -->
<math-bottom-buttons
disabled="{{disabled}}"
bind:share="onShareAppMessage"
bind:export="exportToPrint" />
<!-- 分享引导弹窗 -->
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
</template>
@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "数字排序",
"navigationBarBackgroundColor": "#FFD719",
"homeButton": true,
"backgroundColor": "#F6F6F6",
"enablePullDownRefresh": false,
"usingComponents": {
"toy-button": "../../ui/button/button",
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"math-bottom-buttons": "../../components/math-bottom-buttons/math-bottom-buttons",
"draw-ad": "../../components/draw-ad/draw-ad"
}
}
@@ -0,0 +1 @@
@import '../../base/baseDrawPage.wxss';
@@ -0,0 +1,132 @@
import NumberSortDraw from '../shared/service/numberSortDraw';
import {
createMathPage,
CanvasDataState,
} from '../shared/common/mathPageMixin';
import { NumberSortData } from '../shared/service/numberSortDraw';
import { NUMBER_COLORS, getNumberColors } from '../../constants/colors';
createMathPage({
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as NumberSortDraw | null,
sortData: null as NumberSortData | null,
data: {
pageTitle: '数字排序',
functionId: '',
hasContent: false,
showShareDialog: false,
} as CanvasDataState,
onLoad(options: { id?: string }) {
const functionId = options.id || 'number-sort';
this.setData({
functionId,
});
this.initPageInfo(functionId, '数字排序');
},
onReady() {
this.initCanvas({
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) => {
return new NumberSortDraw(canvas, ctx, options);
},
drawServiceOptions: {
title: this.data.pageTitle,
subTitle: '数字牌被打乱了,把正确的数字按顺序写下来吧~',
functionId: this.data.functionId,
},
onCanvasReady: () => {
// 初始随机生成
this.onRandom();
},
});
},
/**
* 绘制Canvas内容
*/
async drawCanvas() {
if (!this.ctx || !this.drawService || !this.sortData) {
return;
}
try {
await this.drawService.draw(this.sortData);
this.setData({ hasContent: true });
} catch (error) {
console.error('绘制失败:', error);
this.setData({ hasContent: false });
}
},
/**
* 随机生成
*/
onRandom() {
const groups: NumberSortData['groups'] = [];
const availableColors = getNumberColors();
// 生成6组题目
for (let i = 0; i < 6; i++) {
// 随机选择4个不同的数字(范围1-9)
const availableNumbers = Array.from({ length: 9 }, (_, i) => i + 1);
const selectedNumbers: number[] = [];
// 随机选择4个数字
for (let j = 0; j < 4; j++) {
const randomIndex = Math.floor(
Math.random() * availableNumbers.length,
);
selectedNumbers.push(
availableNumbers.splice(randomIndex, 1)[0],
);
}
// 打乱顺序
const shuffledNumbers = [...selectedNumbers];
for (let j = shuffledNumbers.length - 1; j > 0; j--) {
const k = Math.floor(Math.random() * (j + 1));
[shuffledNumbers[j], shuffledNumbers[k]] = [
shuffledNumbers[k],
shuffledNumbers[j],
];
}
// 为每个数字分配颜色和倾斜角度
const numbersWithColors = shuffledNumbers.map((number) => {
// 使用数字对应的颜色,如果数字超出范围则随机选择
const color =
NUMBER_COLORS[number as keyof typeof NUMBER_COLORS] ||
availableColors[
Math.floor(Math.random() * availableColors.length)
];
// 生成随机倾斜角度(-30度到+30度,转换为弧度)
const angleDegrees = (Math.random() - 0.5) * 60; // -30到+30度
const angle = (angleDegrees * Math.PI) / 180; // 转换为弧度
return {
number,
color,
angle,
};
});
groups.push({
numbers: numbersWithColors,
});
}
this.sortData = {
groups,
};
this.drawCanvas();
},
});
@@ -0,0 +1,38 @@
<view class="page-container">
<view class="wrapper">
<text class="wrapper-title">预览打印效果</text>
<!-- 预览打印效果 -->
<view id="canvasWrapper" class="canvas-wrapper">
<canvas
type="2d"
id="canvasContent"
class="canvas-content"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
<!-- 随机生成按钮 -->
<view class="random-button-area">
<toy-button
class="random-button"
type="primary"
bind:click="onRandom"
width="100%"
height="80rpx"
icon="refresh"
icon-class-prefix="toy-icon">
随机生成
</toy-button>
</view>
<draw-ad type="mathDraw"></draw-ad>
</view>
<view class="empty"></view>
</view>
<math-bottom-buttons
disabled="{{!hasContent}}"
bind:share="onShareAppMessage"
bind:export="exportToPrint" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

@@ -1,576 +0,0 @@
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,
});
}
}
@@ -1,19 +1,27 @@
/**
* 加减法计算绘制服务
*/
import { BaseDrawService } from '../../../service/baseDraw';
import { drawAdditionContent } from './additionContentDraw';
import { getImage } from '../../../utils/index';
/**
* 加减法计算数据
*/
export interface AdditionData {
problems: Array<{
type: 'addition' | 'subtraction';
left: number;
right: number;
result: number;
}>;
}
/**
* 加减法计算绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class AdditionDraw extends BaseDrawService {
calculationData: {
problems: Array<{
type: 'addition' | 'subtraction';
left: number;
right: number;
result: number;
}>;
} | null;
calculationData: AdditionData | null = null;
constructor(
canvas: Canvas,
@@ -24,15 +32,11 @@ class AdditionDraw extends BaseDrawService {
this.calculationData = null;
}
/**
* 绘制加减法计算内容
*/
async draw(
calculationData: {
problems: Array<{
type: 'addition' | 'subtraction';
left: number;
right: number;
result: number;
}>;
},
calculationData: AdditionData,
_calculationType: string = 'addition-5', // 保留参数以保持接口兼容性
_imageType?: string, // 可选,不再使用,每个运算会随机选择图片类型
) {
@@ -40,21 +44,10 @@ class AdditionDraw extends BaseDrawService {
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({
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
problems: this.calculationData.problems,
@@ -62,6 +55,613 @@ class AdditionDraw extends BaseDrawService {
startY: this.currentY,
});
}
/**
* 绘制内容区域
*/
private async drawContent(params: {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
problems: Array<{
type: 'addition' | 'subtraction';
left: number;
right: number;
result: number;
}>;
canvasWidth: number;
startY: number;
}): Promise<void> {
const { canvas, ctx, problems, canvasWidth, startY } = params;
// 根据图片类型确定图片目录和最大索引
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 = this.getImageSize(imageConfigItem.type);
// 加载图片
let image: any = null;
try {
const imagePath = `/mathPages/shared/assets/${config.folder}/${imageConfigItem.imageIndex}.png`;
image = await getImage(canvas, imagePath);
} catch (error) {
console.error('图片加载失败:', error);
}
// ========== 绘制左侧图片框 ==========
ctx.strokeStyle = '#999';
ctx.lineWidth = 1;
this.drawRoundedRectInternal(
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.stroke();
ctx.setLineDash([]); // 重置为实线,防止影响后续绘制
}
}
}
}
// ========== 绘制右侧答案框 ==========
this.drawAnswerBoxArea(ctx, {
rightBoxX,
boxY,
rightBoxWidth,
borderRadius,
boxHeight,
padding,
problemType: problem.type,
});
}
}
/**
* 绘制圆角矩形(虚线或实线边框)
*/
private drawRoundedRectInternal(
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([]); // 重置为实线
}
/**
* 绘制实线圆角方框(用于填写答案)
*/
private 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();
}
/**
* 绘制右侧答案框
*/
private 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;
this.drawRoundedRectInternal(
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;
// 绘制第一个方框(圆角)
this.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();
// 绘制第二个方框(圆角)
this.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();
// 绘制第三个方框(答案,圆角)
this.drawAnswerBox(
ctx,
box3X,
answerStartY,
answerBoxWidth,
answerBoxHeight,
);
}
/**
* 根据图片类型设置图片大小
*/
private getImageSize(imageType: 'twelve-animals' | 'fruits'): number {
return imageType === 'twelve-animals' ? 40 : 36;
}
}
export default AdditionDraw;
@@ -1,344 +0,0 @@
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);
}
}
}
@@ -1,20 +1,28 @@
/**
* 数一数比大小绘制服务
*/
import { BaseDrawService } from '../../../service/baseDraw';
import { drawCompareContent } from './compareContentDraw';
import { getImage } from '../../../utils/index';
/**
* 数一数比大小数据
*/
export interface CompareData {
problems: Array<{
leftCount: number;
rightCount: number;
leftImageIndex: number;
rightImageIndex: number;
imageType: 'fruits' | 'twelve-animals';
}>;
}
/**
* 数一数比大小绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class CompareDraw extends BaseDrawService {
compareData: {
problems: Array<{
leftCount: number;
rightCount: number;
leftImageIndex: number;
rightImageIndex: number;
imageType: 'fruits' | 'twelve-animals';
}>;
} | null;
compareData: CompareData | null = null;
constructor(
canvas: Canvas,
@@ -25,34 +33,18 @@ class CompareDraw extends BaseDrawService {
this.compareData = null;
}
async draw(compareData: {
problems: Array<{
leftCount: number;
rightCount: number;
leftImageIndex: number;
rightImageIndex: number;
imageType: 'fruits' | 'twelve-animals';
}>;
}) {
/**
* 绘制数一数比大小内容
*/
async draw(compareData: CompareData) {
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({
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
compareData: this.compareData,
@@ -60,6 +52,334 @@ class CompareDraw extends BaseDrawService {
startY: this.currentY,
});
}
/**
* 绘制内容区域
*/
private async drawContent(params: {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
compareData: CompareData;
canvasWidth: number;
startY: number;
}): Promise<void> {
const { canvas, ctx, compareData, canvasWidth, startY } = params;
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;
// 固定绘制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 this.drawCompareProblem(
ctx,
canvas,
problems[leftProblemIndex],
leftColumnStartX + columnPadding,
rowY,
boxWidth,
boxHeight,
borderRadius,
circleRadius,
boxToCircleSpacing,
);
}
// 绘制右侧列
const rightProblemIndex = rowIndex * 2 + 1;
if (rightProblemIndex < problems.length) {
await this.drawCompareProblem(
ctx,
canvas,
problems[rightProblemIndex],
rightColumnStartX + columnPadding,
rowY,
boxWidth,
boxHeight,
borderRadius,
circleRadius,
boxToCircleSpacing,
);
}
}
}
/**
* 绘制一个比较问题(左框 | 圆圈 | 右框)
*/
private async 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;
this.drawRoundedRectInternal(
ctx,
leftBoxX,
leftBoxY,
boxWidth,
boxHeight,
borderRadius,
true,
);
// 绘制左框内的图片
await this.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;
this.drawRoundedRectInternal(
ctx,
rightBoxX,
rightBoxY,
boxWidth,
boxHeight,
borderRadius,
true,
);
// 绘制右框内的图片
await this.drawImagesInBox(
ctx,
canvas,
rightCount,
rightImageIndex,
config.folder,
rightBoxX,
rightBoxY,
boxWidth,
boxHeight,
);
}
/**
* 在框内绘制多张图片
*/
private async 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/shared/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,
);
}
}
}
/**
* 绘制圆角矩形(虚线边框)
*/
private drawRoundedRectInternal(
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 default CompareDraw;
@@ -1,294 +0,0 @@
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();
}
}
@@ -1,15 +1,24 @@
/**
* 数一数连一连绘制服务
*/
import { BaseDrawService } from '../../../service/baseDraw';
import { drawCountMatchContent } from './countMatchContentDraw';
import { getImage } from '../../../utils/index';
import { getRandomNumberColor } from '../../../constants/colors';
/**
* 数一数连一连数据
*/
export interface CountMatchData {
leftNumbers: number[];
rightNumbers: number[]; // 打乱顺序后的数字数组
}
/**
* 数一数连一连绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class CountMatchDraw extends BaseDrawService {
matchData: {
leftNumbers: number[];
rightNumbers: number[]; // 打乱顺序后的数字数组
} | null;
matchData: CountMatchData | null = null;
constructor(
canvas: Canvas,
@@ -20,29 +29,21 @@ class CountMatchDraw extends BaseDrawService {
this.matchData = null;
}
/**
* 绘制数一数连一连内容
*/
async draw(
matchData: { leftNumbers: number[]; rightNumbers: number[] },
matchData: CountMatchData,
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({
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
matchData: this.matchData,
@@ -51,6 +52,305 @@ class CountMatchDraw extends BaseDrawService {
imageType,
});
}
/**
* 绘制内容区域
*/
private async drawContent(params: {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
matchData: CountMatchData;
canvasWidth: number;
startY: number;
imageType?: string; // 'twelve-animals' 或 'fruits'
}): Promise<void> {
const {
canvas,
ctx,
matchData,
canvasWidth,
startY,
imageType = 'twelve-animals',
} = params;
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;
this.drawRoundedRectInternal(
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;
this.drawRoundedRectInternal(
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/shared/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();
}
}
/**
* 绘制圆角矩形(虚线边框)
*/
private drawRoundedRectInternal(
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 default CountMatchDraw;
@@ -1,312 +0,0 @@
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);
}
}
}
@@ -1,21 +1,30 @@
/**
* 数一数选一选/填一填绘制服务
* 支持两种模式:'select'(选一选)和 'fill'(填一填)
*/
import { BaseDrawService } from '../../../service/baseDraw';
import { drawCountingSelectContent } from './countingSelectContentDraw';
import { getImage } from '../../../utils/index';
import { getRandomNumberColor } from '../../../constants/colors';
/**
* 数一数选一选/填一填数据
*/
export interface CountingSelectData {
problems: Array<{
count: number;
imageIndex: number;
imageType: 'fruits' | 'twelve-animals';
options?: number[]; // 选一选模式需要,填一填模式不需要
correctIndex?: number; // 选一选模式需要
}>;
}
/**
* 数一数选一选/填一填绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
* 支持两种模式:'select'(选一选)和 'fill'(填一填)
*/
class CountingSelectDraw extends BaseDrawService {
countingData: {
problems: Array<{
count: number;
imageIndex: number;
imageType: 'fruits' | 'twelve-animals';
options?: number[];
correctIndex?: number;
}>;
} | null;
countingData: CountingSelectData | null = null;
mode: 'select' | 'fill';
constructor(
@@ -28,38 +37,22 @@ class CountingSelectDraw extends BaseDrawService {
this.mode = 'select'; // 默认选一选模式
}
/**
* 绘制数一数选一选/填一填内容
*/
async draw(
countingData: {
problems: Array<{
count: number;
imageIndex: number;
imageType: 'fruits' | 'twelve-animals';
options?: number[];
correctIndex?: number;
}>;
},
countingData: CountingSelectData,
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({
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
countingData: this.countingData,
@@ -68,6 +61,326 @@ class CountingSelectDraw extends BaseDrawService {
startY: this.currentY,
});
}
/**
* 绘制内容区域
*/
private async drawContent(params: {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
countingData: CountingSelectData;
mode: 'select' | 'fill';
canvasWidth: number;
startY: number;
}): Promise<void> {
const { canvas, ctx, countingData, mode, canvasWidth, startY } = params;
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 this.drawProblemBox(
ctx,
canvas,
problem,
boxX,
boxY,
boxWidth,
boxHeight,
imageAreaHeight,
bottomAreaHeight,
optionCellWidth,
mode,
);
}
}
}
/**
* 绘制一个题目框(图片 + 选项表格 或 括号)
*/
private async 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;
this.drawRoundedRectInternal(
ctx,
boxX,
boxY,
boxWidth,
boxHeight,
borderRadius,
false,
);
// 图片区域
const imageAreaX = boxX;
const imageAreaY = boxY;
// 绘制图片
await this.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);
}
}
/**
* 在框内绘制多张图片
*/
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 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/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 +
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,
);
}
}
}
/**
* 绘制圆角矩形(虚线边框)
*/
private drawRoundedRectInternal(
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 default CountingSelectDraw;
@@ -1,133 +0,0 @@
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;
}
}
@@ -1,18 +1,25 @@
/**
* 填上缺少的数字绘制服务
*/
import { BaseDrawService } from '../../../service/baseDraw';
import { drawMissingNumberContent } from './missingNumberContentDraw';
/**
* 填上缺少的数字数据
*/
export interface MissingNumberData {
grids: Array<{
numbers: (number | null)[];
colors: (string | null)[];
}>;
maxNumber: number;
}
/**
* 填上缺少的数字绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class MissingNumberDraw extends BaseDrawService {
missingNumberData: {
grids: Array<{
numbers: (number | null)[];
colors: (string | null)[];
}>;
maxNumber: number;
} | null;
missingNumberData: MissingNumberData | null = null;
constructor(
canvas: Canvas,
@@ -23,35 +30,21 @@ class MissingNumberDraw extends BaseDrawService {
this.missingNumberData = null;
}
/**
* 绘制填上缺少的数字内容
*/
async draw(
missingNumberData: {
grids: Array<{
numbers: (number | null)[];
colors: (string | null)[];
}>;
maxNumber: number;
},
missingNumberData: MissingNumberData,
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({
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
missingNumberData: this.missingNumberData,
@@ -59,6 +52,128 @@ class MissingNumberDraw extends BaseDrawService {
startY: this.currentY,
});
}
/**
* 绘制内容区域
*/
private async drawContent(params: {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
missingNumberData: MissingNumberData;
canvasWidth: number;
startY: number;
}): Promise<void> {
const { ctx, missingNumberData, canvasWidth, startY } = params;
const { grids, maxNumber } = missingNumberData;
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,
},
};
// 计算网格布局参数
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;
}
}
}
export default MissingNumberDraw;
@@ -1,459 +0,0 @@
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,
);
}
}
}
@@ -1,14 +1,22 @@
/**
* 按数字涂颜色绘制服务
*/
import { BaseDrawService } from '../../../service/baseDraw';
import { drawNumberColorContent } from './numberColorContentDraw';
import { getNumberColors } from '../../../constants/colors';
/**
* 按数字涂颜色数据
*/
export interface NumberColorData {
numbers: number[];
}
/**
* 按数字涂颜色绘制服务
* 组合使用基础绘制服务和内容区域绘制服务
*/
class NumberColorDraw extends BaseDrawService {
colorData: {
numbers: number[];
} | null;
colorData: NumberColorData | null = null;
constructor(
canvas: Canvas,
@@ -19,29 +27,21 @@ class NumberColorDraw extends BaseDrawService {
this.colorData = null;
}
/**
* 绘制按数字涂颜色内容
*/
async draw(
colorData: { numbers: number[] },
colorData: NumberColorData,
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({
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
colorData: this.colorData,
@@ -50,6 +50,466 @@ class NumberColorDraw extends BaseDrawService {
patternType,
});
}
/**
* 绘制内容区域
*/
private async drawContent(params: {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
colorData: NumberColorData;
canvasWidth: number;
startY: number;
patternType?: string; // 'circle' 或 'caterpillar'
}): Promise<void> {
const {
ctx,
colorData,
canvasWidth,
startY,
patternType = 'circle',
} = params;
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;
this.drawRoundedRectInternal(
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;
this.drawRoundedRectInternal(
ctx,
rightBoxX,
boxY,
rightBoxWidth,
boxHeight,
borderRadius,
true,
);
// 根据模式绘制右侧内容
if (patternType === 'circle') {
this.drawCirclePattern(
ctx,
rightBoxX,
boxY,
rightBoxWidth,
boxHeight,
number,
numberColor,
isFirstRow,
);
} else if (patternType === 'caterpillar') {
this.drawCaterpillarPattern(
ctx,
rightBoxX,
boxY,
rightBoxWidth,
boxHeight,
number,
numberColor,
isFirstRow,
);
}
}
}
/**
* 绘制圆圈模式的内容
*/
private 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();
}
}
}
/**
* 绘制毛毛虫模式的内容
*/
private 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;
// 绘制头部
this.drawCaterpillarHead(ctx, headX, headY, headSize, numberColor);
// 计算头部的实际宽度(头部半径 * 2)
const headWidth = headRadius * 2; // 头部的实际宽度 = 36
// 绘制身体(从头部右侧边缘开始,留一点间距)
const bodyStartX = headX + headWidth + 17; // 头部右边缘 + 5px间距
const bodyStartY = headY + headSize / 2; // 与头部中心Y对齐
this.drawCaterpillarBody(
ctx,
bodyStartX,
bodyStartY,
number,
bodyRadius,
numberColor,
);
} else {
// 其他行:只绘制头部
const headX = x + padding;
const headY = y + (height - headSize) / 2;
this.drawCaterpillarHead(ctx, headX, headY, headSize, numberColor);
}
}
/**
* 绘制毛毛虫头部
*/
private 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();
}
/**
* 绘制毛毛虫身体(带弧度的圆圈)
*/
private 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();
}
}
/**
* 绘制圆角矩形(虚线边框)
*/
private drawRoundedRectInternal(
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([]); // 重置为实线
}
}
export default NumberColorDraw;
@@ -1,78 +0,0 @@
/**
* 绘制内容区域的参数接口
*/
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);
}
}
}
@@ -1,504 +0,0 @@
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);
}
}
}
@@ -1,21 +1,30 @@
/**
* 10以内数的分与合绘制服务
*/
import { BaseDrawService } from '../../../service/baseDraw';
import { drawNumberDecomposeContent } from './numberDecomposeContentDraw';
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: {
problems: Array<{
whole: number | null;
part1: number | null;
part2: number | null;
imageIndex?: number;
imageType?: 'fruits' | 'twelve-animals';
}>;
mode: 'with-image' | 'decompose' | 'compose';
} | null;
decomposeData: NumberDecomposeData | null = null;
constructor(
canvas: Canvas,
@@ -26,35 +35,18 @@ class NumberDecomposeDraw extends BaseDrawService {
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';
}) {
/**
* 绘制10以内数的分与合内容
*/
async draw(decomposeData: NumberDecomposeData) {
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({
this.prepareDraw();
await this.drawHeaderAndDivider();
await this.drawContent({
canvas: this.canvas,
ctx: this.ctx,
decomposeData: this.decomposeData,
@@ -62,6 +54,519 @@ class NumberDecomposeDraw extends BaseDrawService {
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;
@@ -1,6 +1,5 @@
import { BaseDrawService } from '../../../service/baseDraw';
import { drawNumberPreview } from './numberPreviewDraw';
import { drawNumberContent } from './numberContentDraw';
import { drawNumberWriteContent } from './numberWriteDraw';
/**
@@ -26,20 +25,11 @@ class NumberFindDraw extends BaseDrawService {
return;
}
this.setPrintConfig();
this.selectedNumber = selectedNumber;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
this.prepareDraw();
await this.drawHeaderAndDivider();
// 绘制预览区域
this.drawDivider();
await drawNumberPreview({
canvas: this.canvas,
ctx: this.ctx,
@@ -63,7 +53,7 @@ class NumberFindDraw extends BaseDrawService {
});
} else {
// 默认类型(number-find):绘制数字涂色内容
drawNumberContent({
this.drawNumberContent({
ctx: this.ctx,
selectedNumber: this.selectedNumber,
canvasWidth: this.canvasWidth,
@@ -71,6 +61,75 @@ class NumberFindDraw extends BaseDrawService {
});
}
}
/**
* 绘制数字涂色内容区域
*/
private drawNumberContent(params: {
ctx: RenderingContext;
selectedNumber: number;
canvasWidth: number;
startY: number;
}): void {
const { ctx, selectedNumber, canvasWidth, startY } = params;
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);
}
}
}
}
export default NumberFindDraw;
@@ -52,7 +52,7 @@ export async function drawNumberPreview({
// 加载并绘制 animal-number 图片
try {
const animalImagePath = `/mathPages/assets/animal-number/animal-number-${selectedNumber}.png`;
const animalImagePath = `/mathPages/shared/assets/animal-number/animal-number-${selectedNumber}.png`;
const animalImage = await getImage(canvas, animalImagePath);
ctx.drawImage(
animalImage,
@@ -79,7 +79,7 @@ export async function drawNumberPreview({
ctx.strokeRect(boxX, fingerBoxY, boxWidth, boxHeight);
try {
const fingerImagePath = `/mathPages/assets/finger/finger-${selectedNumber}.png`;
const fingerImagePath = `/mathPages/shared/assets/finger/finger-${selectedNumber}.png`;
const fingerImage = await getImage(canvas, fingerImagePath);
// 计算图片在框内的位置(居中显示)
const imagePadding = 10;
@@ -0,0 +1,177 @@
/**
* 数字排序绘制服务
*/
import { BaseDrawService } from '../../../service/baseDraw';
/**
* 数字排序数据
*/
export interface NumberSortData {
/** 6组题目数据 */
groups: Array<{
/** 4个乱序的数字及其颜色 */
numbers: Array<{
number: number;
color: string;
angle?: number; // 倾斜角度(弧度),-30度到+30度
}>;
}>;
}
/**
* 数字排序绘制服务
*/
class NumberSortDraw extends BaseDrawService {
sortData: NumberSortData | null = null;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
}
/**
* 绘制数字排序内容
*/
async draw(sortData: NumberSortData) {
if (!sortData || !sortData.groups) {
return;
}
this.sortData = sortData;
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawContent({
ctx: this.ctx,
sortData: this.sortData,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
/**
* 绘制内容区域
*/
private drawContent(params: {
ctx: RenderingContext;
sortData: NumberSortData;
canvasWidth: number;
startY: number;
}): void {
const { ctx, sortData, canvasWidth } = params;
let { startY } = params;
startY = startY + 25;
const margin = 40; // 左右边距
const rowSpacing = 60; // 行间距
const colSpacing = 40; // 列间距
const boxSpacing = 10; // 格子之间的间距
// 数字区域参数
const numberSize = 120; // 数字大小(字体大小)
const numberAreaHeight = 120; // 数字区域高度(增大以容纳更大的数字)
const numberOverlapSpacing = 35; // 数字之间的间距(用于重叠效果)
// 格子参数
const boxWidth = 40; // 格子宽度
const boxHeight = 50; // 格子高度(高比宽长)
const boxBorderWidth = 1; // 边框宽度
const boxBorderColor = '#999'; // 边框颜色
// 4个格子的总宽度
const boxesTotalWidth = 4 * boxWidth + 3 * boxSpacing;
// 每组的总宽度(以格子的总宽度为准)
const groupWidth = boxesTotalWidth;
// 计算总宽度(两列 + 一列间距)
const totalWidth = 2 * groupWidth + colSpacing;
const groupsStartX =
margin + (canvasWidth - margin * 2 - totalWidth) / 2;
// 每组的总高度(数字区域 + 格子区域)
const groupHeight = numberAreaHeight + boxHeight;
// 绘制6个组(三行两列)
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 2; col++) {
const groupIndex = row * 2 + col;
const group = sortData.groups[groupIndex];
if (!group) continue;
const groupX = groupsStartX + col * (groupWidth + colSpacing);
const groupY = startY + row * (groupHeight + rowSpacing);
// 格子的起始X坐标(居中)
const boxesStartX = groupX;
const boxesY = groupY + numberAreaHeight;
// 计算数字组的中心位置(与格子中心对齐)
const boxesCenterX = boxesStartX + boxesTotalWidth / 2;
// 计算数字组的总宽度(从第一个数字中心到最后一个数字中心的距离)
const numbersGroupWidth = 3 * numberOverlapSpacing;
// 第一个数字的中心位置(数字组居中对齐)
const firstNumberCenterX = boxesCenterX - numbersGroupWidth / 2;
// 绘制4个数字(上边),有重叠效果
for (let i = 0; i < group.numbers.length; i++) {
const numberItem = group.numbers[i];
// 数字中心位置(有重叠)
const numberX =
firstNumberCenterX + i * numberOverlapSpacing;
const numberY = groupY + numberAreaHeight / 2;
// 获取倾斜角度(如果存在,否则使用0)
const angle = numberItem.angle || 0;
// 保存上下文状态
ctx.save();
// 移动到数字位置
ctx.translate(numberX, numberY);
// 旋转(角度转换为弧度)
ctx.rotate(angle);
// 绘制数字(带边线)
const text = numberItem.number.toString();
ctx.font = `bold ${numberSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 先绘制边线
ctx.strokeStyle = '#141414';
ctx.lineWidth = 3;
ctx.strokeText(text, 0, 0);
// 再绘制填充
ctx.fillStyle = numberItem.color;
ctx.fillText(text, 0, 0);
// 恢复上下文状态
ctx.restore();
}
// 绘制4个长方形格子(下边)
for (let i = 0; i < 4; i++) {
const boxX = boxesStartX + i * (boxWidth + boxSpacing);
// 绘制格子边框
ctx.strokeStyle = boxBorderColor;
ctx.lineWidth = boxBorderWidth;
ctx.beginPath();
ctx.rect(boxX, boxesY, boxWidth, boxHeight);
ctx.stroke();
}
}
}
}
}
export default NumberSortDraw;
+27
View File
@@ -397,4 +397,31 @@ export class BaseDrawService {
ctx.stroke();
}
}
/**
* 准备绘制(公共初始化逻辑)
* 执行:setPrintConfig -> clear -> setPaper
* 子类可以在调用此方法前后执行自定义逻辑(如数据验证、异步资源加载等)
*/
prepareDraw() {
this.setPrintConfig();
this.clear();
this.setPaper();
}
/**
* 绘制 Header 和 Divider(公共绘制逻辑)
* 根据 headerType 自动选择绘制完整 Header 或迷你 Header,然后绘制分割线
*/
async drawHeaderAndDivider() {
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制内容区域分割线
this.drawDivider();
}
}
+9 -2
View File
@@ -23,12 +23,19 @@
"condition": {
"miniprogram": {
"list": [
{
"name": "mathPages/numberSort/numberSort",
"pathName": "mathPages/numberSort/numberSort",
"query": "id=number-sort",
"scene": null,
"launchMode": "default"
},
{
"name": "focusPages/codeConnect/codeConnect",
"pathName": "focusPages/codeConnect/codeConnect",
"query": "id=code-connect",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "focusPages/gridReasoning/gridReasoning",