feat: 添加控笔页
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
/** 控笔图形:100×100 坐标系,Y 轴向下 */
|
||||
|
||||
export type PenControlCategory = 'line' | 'curve' | 'shape' | 'combo';
|
||||
|
||||
export const PEN_CONTROL_CATEGORY_LABEL: Record<PenControlCategory, string> = {
|
||||
line: '直线',
|
||||
curve: '曲线',
|
||||
shape: '形状',
|
||||
combo: '组合',
|
||||
};
|
||||
|
||||
export type PenControlRenderMode = 'stroke' | 'fill' | 'both';
|
||||
|
||||
export interface PenControlPattern {
|
||||
id: string;
|
||||
name: string;
|
||||
category: PenControlCategory;
|
||||
categoryLabel?: string;
|
||||
tags: string[];
|
||||
paths: string[];
|
||||
render: PenControlRenderMode;
|
||||
strokeScale?: number;
|
||||
}
|
||||
|
||||
const RAW_PEN_CONTROL_PATTERNS: PenControlPattern[] = [
|
||||
{
|
||||
id: 'horizontal-lines',
|
||||
name: '横线',
|
||||
category: 'line',
|
||||
tags: ['基础', '直线'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 14 26 L 86 26',
|
||||
'M 14 42 L 86 42',
|
||||
'M 14 58 L 86 58',
|
||||
'M 14 74 L 86 74',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vertical-lines',
|
||||
name: '竖线',
|
||||
category: 'line',
|
||||
tags: ['基础', '直线'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 26 14 L 26 86',
|
||||
'M 42 14 L 42 86',
|
||||
'M 58 14 L 58 86',
|
||||
'M 74 14 L 74 86',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'diagonal-lr',
|
||||
name: '左斜线',
|
||||
category: 'line',
|
||||
tags: ['斜线'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 14 14 L 86 86',
|
||||
'M 14 32 L 68 86',
|
||||
'M 32 14 L 86 68',
|
||||
'M 14 50 L 50 86',
|
||||
'M 50 14 L 86 50',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'diagonal-rl',
|
||||
name: '右斜线',
|
||||
category: 'line',
|
||||
tags: ['斜线'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 86 14 L 14 86',
|
||||
'M 86 32 L 32 86',
|
||||
'M 68 14 L 14 68',
|
||||
'M 86 50 L 50 86',
|
||||
'M 50 14 L 14 50',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'zigzag',
|
||||
name: '锯齿线',
|
||||
category: 'line',
|
||||
tags: ['折线'],
|
||||
render: 'stroke',
|
||||
paths: ['M 14 18 L 26 82 L 38 18 L 50 82 L 62 18 L 74 82 L 86 18'],
|
||||
},
|
||||
{
|
||||
id: 'loop-single',
|
||||
name: '回字圈',
|
||||
category: 'shape',
|
||||
tags: ['形状'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 14 14 L 86 14 L 86 86 L 14 86 Z',
|
||||
'M 28 28 L 72 28 L 72 72 L 28 72 Z',
|
||||
'M 42 42 L 58 42 L 58 58 L 42 58 Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'spiral',
|
||||
name: '蜗牛线',
|
||||
category: 'combo',
|
||||
tags: ['组合'],
|
||||
render: 'stroke',
|
||||
strokeScale: 0.95,
|
||||
paths: [
|
||||
'M 88 50 Q 88 86, 50 86 Q 14 86, 14 50 Q 14 18, 50 18 Q 80 18, 80 50 Q 80 74, 50 74 Q 26 74, 26 50 Q 26 30, 50 30 Q 68 30, 68 50 Q 68 62, 50 62 Q 38 62, 38 50 Q 38 44, 50 44 Q 56 44, 56 50',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mountain',
|
||||
name: '山峰线',
|
||||
category: 'line',
|
||||
tags: ['折线'],
|
||||
render: 'stroke',
|
||||
paths: ['M 14 76 L 32 28 L 50 64 L 68 28 L 86 76'],
|
||||
},
|
||||
{
|
||||
id: 'stair',
|
||||
name: '台阶线',
|
||||
category: 'line',
|
||||
tags: ['折线'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 14 58 L 14 43 L 38 43 L 38 28 L 62 28 L 62 13 L 86 13',
|
||||
'M 14 88 L 14 73 L 38 73 L 38 58 L 62 58 L 62 43 L 86 43',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'corner-turn',
|
||||
name: '转折线',
|
||||
category: 'line',
|
||||
tags: ['折线'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 86 86 L 14 86 L 14 14 L 86 14 L 86 74 L 26 74 L 26 26 L 74 26 L 74 62 L 38 62 L 38 38 L 62 38 L 62 50',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wave',
|
||||
name: '波浪线',
|
||||
category: 'curve',
|
||||
tags: ['曲线'],
|
||||
render: 'stroke',
|
||||
paths: ['M 14 50 Q 32 18, 50 50 Q 68 82, 86 50'],
|
||||
},
|
||||
{
|
||||
id: 'horizontal-curve',
|
||||
name: '横曲线',
|
||||
category: 'curve',
|
||||
tags: ['曲线'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 14 24 Q 20 8, 26 24 Q 32 40, 38 24 Q 44 8, 50 24 Q 56 40, 62 24 Q 68 8, 74 24 Q 80 40, 86 24',
|
||||
'M 14 50 Q 20 34, 26 50 Q 32 66, 38 50 Q 44 34, 50 50 Q 56 66, 62 50 Q 68 34, 74 50 Q 80 66, 86 50',
|
||||
'M 14 76 Q 20 60, 26 76 Q 32 92, 38 76 Q 44 60, 50 76 Q 56 92, 62 76 Q 68 60, 74 76 Q 80 92, 86 76',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vertical-curve',
|
||||
name: '竖曲线',
|
||||
category: 'curve',
|
||||
tags: ['曲线'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 24 14 Q 8 20, 24 26 Q 40 32, 24 38 Q 8 44, 24 50 Q 40 56, 24 62 Q 8 68, 24 74 Q 40 80, 24 86',
|
||||
'M 50 14 Q 34 20, 50 26 Q 66 32, 50 38 Q 34 44, 50 50 Q 66 56, 50 62 Q 34 68, 50 74 Q 66 80, 50 86',
|
||||
'M 76 14 Q 60 20, 76 26 Q 92 32, 76 38 Q 60 44, 76 50 Q 92 56, 76 62 Q 60 68, 76 74 Q 92 80, 76 86',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's-curve',
|
||||
name: 'S 弯',
|
||||
category: 'curve',
|
||||
tags: ['曲线'],
|
||||
render: 'stroke',
|
||||
strokeScale: 1.1,
|
||||
paths: ['M 80 16 Q 18 18, 50 50 Q 82 82, 20 84'],
|
||||
},
|
||||
{
|
||||
id: 'arc-up',
|
||||
name: '左弧线',
|
||||
category: 'curve',
|
||||
tags: ['弧线'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 52 13 Q 13 13, 13 52',
|
||||
'M 69 30 Q 30 30, 30 69',
|
||||
'M 86 47 Q 47 47, 47 86',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'arc-down',
|
||||
name: '右弧线',
|
||||
category: 'curve',
|
||||
tags: ['弧线'],
|
||||
render: 'stroke',
|
||||
paths: [
|
||||
'M 13 52 Q 52 52, 52 13',
|
||||
'M 30 69 Q 69 69, 69 30',
|
||||
'M 47 86 Q 86 86, 86 47',
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'figure-eight',
|
||||
name: '长8线',
|
||||
category: 'combo',
|
||||
tags: ['组合'],
|
||||
render: 'stroke',
|
||||
strokeScale: 0.95,
|
||||
paths: [
|
||||
'M 20 50 C 10 41, 13 20, 27 13 C 41 6, 42 28, 34 40 C 30 45, 25 49, 20 50 C 30 59, 27 80, 14 87 C 5 92, 5 72, 8 60 C 11 55, 15 51, 20 50',
|
||||
'M 50 50 C 40 41, 43 20, 57 13 C 71 6, 72 28, 64 40 C 60 45, 55 49, 50 50 C 60 59, 57 80, 43 87 C 32 92, 31 72, 36 60 C 40 55, 45 51, 50 50',
|
||||
'M 80 50 C 70 41, 73 20, 87 13 C 99 7, 100 28, 94 40 C 90 45, 85 49, 80 50 C 90 59, 87 80, 73 87 C 62 92, 61 72, 66 60 C 70 55, 75 51, 80 50',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cross-hatch',
|
||||
name: '小太阳',
|
||||
category: 'combo',
|
||||
tags: ['组合'],
|
||||
render: 'stroke',
|
||||
strokeScale: 0.9,
|
||||
paths: [
|
||||
'M 62 50 C 62 56.6, 56.6 62, 50 62 C 43.4 62, 38 56.6, 38 50 C 38 43.4, 43.4 38, 50 38 C 56.6 38, 62 43.4, 62 50 Z',
|
||||
'M 50 30 L 50 12',
|
||||
'M 50 70 L 50 88',
|
||||
'M 30 50 L 12 50',
|
||||
'M 70 50 L 88 50',
|
||||
'M 36 36 L 22 22',
|
||||
'M 64 36 L 78 22',
|
||||
'M 36 64 L 22 78',
|
||||
'M 64 64 L 78 78',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const PEN_CONTROL_PATTERNS: PenControlPattern[] =
|
||||
RAW_PEN_CONTROL_PATTERNS.map((p) => ({
|
||||
...p,
|
||||
categoryLabel: PEN_CONTROL_CATEGORY_LABEL[p.category],
|
||||
}));
|
||||
|
||||
const PATTERN_BY_ID = Object.fromEntries(
|
||||
PEN_CONTROL_PATTERNS.map((p) => [p.id, p]),
|
||||
) as Record<string, PenControlPattern>;
|
||||
|
||||
export function getPenControlPattern(
|
||||
id: string,
|
||||
): PenControlPattern | undefined {
|
||||
return PATTERN_BY_ID[id];
|
||||
}
|
||||
|
||||
export const PEN_CONTROL_PATTERN_IDS = PEN_CONTROL_PATTERNS.map((p) => p.id);
|
||||
|
||||
export function pickRandomPatternIds(
|
||||
count: number,
|
||||
exclude: string[] = [],
|
||||
): string[] {
|
||||
const pool = PEN_CONTROL_PATTERNS.map((p) => p.id).filter(
|
||||
(id) => !exclude.includes(id),
|
||||
);
|
||||
const n = Math.max(0, Math.min(count, pool.length));
|
||||
const shuffled = [...pool].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, n);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import { drawTianZiGrid } from '../../shared/drawUtils';
|
||||
import { getPenControlPattern } from '../data/penControlPatterns';
|
||||
import type { PenControlSheetData } from '../generators/penControlGenerator';
|
||||
import {
|
||||
drawPenControlInCell,
|
||||
type PenControlCellStyle,
|
||||
} from '../shared/penControlDrawUtils';
|
||||
|
||||
/** 控笔练习固定 8 列 × 10 行 */
|
||||
const LAYOUT = {
|
||||
topGap: 17,
|
||||
leftMargin: 40,
|
||||
rightMargin: 40,
|
||||
bottomMargin: 40,
|
||||
targetCols: 8,
|
||||
targetRows: 10,
|
||||
minColGap: 6,
|
||||
rowGap: 8,
|
||||
} as const;
|
||||
|
||||
export default class PenControlDrawService extends BaseDrawService {
|
||||
constructor(
|
||||
canvas: Canvas,
|
||||
ctx: RenderingContext,
|
||||
options?: Record<string, unknown>,
|
||||
) {
|
||||
super(canvas, ctx, {
|
||||
title: '控笔组合练习',
|
||||
subTitle: '每种图形占两行田字格',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
getMaxGridLayout(): { maxRow: number; maxCol: number } {
|
||||
return { maxRow: LAYOUT.targetRows, maxCol: LAYOUT.targetCols };
|
||||
}
|
||||
|
||||
computeSheetLayout(maxRow: number, maxCol: number) {
|
||||
const content = this.getContentRect();
|
||||
const { minColGap, rowGap } = LAYOUT;
|
||||
|
||||
const cellByWidth =
|
||||
(content.width - (maxCol - 1) * minColGap) / maxCol;
|
||||
const cellByHeight =
|
||||
(content.height - (maxRow - 1) * rowGap) / maxRow;
|
||||
const cellSize = Math.max(
|
||||
16,
|
||||
Math.floor(Math.min(cellByWidth, cellByHeight)),
|
||||
);
|
||||
|
||||
const actualColGap =
|
||||
maxCol > 1
|
||||
? (content.width - maxCol * cellSize) / (maxCol - 1)
|
||||
: 0;
|
||||
const actualRowGap =
|
||||
maxRow > 1
|
||||
? Math.min(
|
||||
rowGap,
|
||||
(content.height - maxRow * cellSize) / (maxRow - 1),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
maxRow,
|
||||
maxCol,
|
||||
cellSize,
|
||||
startX: content.left,
|
||||
startY: content.top,
|
||||
colGap: actualColGap,
|
||||
rowGap: actualRowGap,
|
||||
};
|
||||
}
|
||||
|
||||
async draw(data: PenControlSheetData) {
|
||||
this.prepareDraw();
|
||||
await this.drawHeaderAndDivider();
|
||||
const layout = this.computeSheetLayout(
|
||||
data.layout.maxRow,
|
||||
data.layout.maxCol,
|
||||
);
|
||||
this.drawSheet({ ...data, layout });
|
||||
this.drawPrintFooter();
|
||||
}
|
||||
|
||||
private getContentRect() {
|
||||
const topGap = LAYOUT.topGap;
|
||||
const leftMargin = LAYOUT.leftMargin;
|
||||
const rightMargin = LAYOUT.rightMargin;
|
||||
const bottomMargin = LAYOUT.bottomMargin;
|
||||
const contentTop = this.currentY + topGap;
|
||||
const contentWidth = this.canvasWidth - leftMargin - rightMargin;
|
||||
const contentHeight =
|
||||
this.canvasHeight - contentTop - bottomMargin;
|
||||
|
||||
return {
|
||||
top: contentTop,
|
||||
left: leftMargin,
|
||||
width: contentWidth,
|
||||
height: contentHeight,
|
||||
};
|
||||
}
|
||||
|
||||
private drawSheet(data: PenControlSheetData) {
|
||||
const { ctx } = this;
|
||||
const { layout, blocks } = data;
|
||||
const {
|
||||
startX,
|
||||
startY,
|
||||
cellSize,
|
||||
colGap,
|
||||
rowGap,
|
||||
maxCol,
|
||||
maxRow,
|
||||
} = layout;
|
||||
|
||||
for (let row = 0; row < maxRow; row++) {
|
||||
for (let col = 0; col < maxCol; col++) {
|
||||
const cx = startX + col * (cellSize + colGap) + cellSize / 2;
|
||||
const cy = startY + row * (cellSize + rowGap) + cellSize / 2;
|
||||
drawTianZiGrid({ ctx, cx, cy, size: cellSize });
|
||||
}
|
||||
}
|
||||
|
||||
let globalRow = 0;
|
||||
for (const block of blocks) {
|
||||
for (const rowSpec of block.rows) {
|
||||
if (globalRow >= maxRow) break;
|
||||
|
||||
const pattern = getPenControlPattern(block.patternId);
|
||||
if (pattern) {
|
||||
for (let col = 0; col < maxCol; col++) {
|
||||
const cx =
|
||||
startX + col * (cellSize + colGap) + cellSize / 2;
|
||||
const cy =
|
||||
startY +
|
||||
globalRow * (cellSize + rowGap) +
|
||||
cellSize / 2;
|
||||
const style: PenControlCellStyle =
|
||||
rowSpec.cells[col] ?? 'guide';
|
||||
const dashed =
|
||||
rowSpec.role === 'pair-second' && col >= 1;
|
||||
drawPenControlInCell(
|
||||
ctx,
|
||||
pattern,
|
||||
cx,
|
||||
cy,
|
||||
cellSize,
|
||||
style,
|
||||
{ dashed },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
globalRow++;
|
||||
}
|
||||
|
||||
if (globalRow >= maxRow) break;
|
||||
}
|
||||
}
|
||||
|
||||
/** 供页面在绘制前计算 layout */
|
||||
buildLayoutForGenerate(): ReturnType<
|
||||
PenControlDrawService['computeSheetLayout']
|
||||
> {
|
||||
const { maxRow, maxCol } = this.getMaxGridLayout();
|
||||
return this.computeSheetLayout(maxRow, maxCol);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
getPenControlPattern,
|
||||
PEN_CONTROL_PATTERN_IDS,
|
||||
} from '../data/penControlPatterns';
|
||||
import type { PenControlCellStyle } from '../shared/penControlDrawUtils';
|
||||
|
||||
export const PEN_CONTROL_MODE = 'pen-control-mix' as const;
|
||||
export type PenControlMode = typeof PEN_CONTROL_MODE;
|
||||
|
||||
export const ROWS_PER_PATTERN = 2;
|
||||
export const MIN_PATTERNS = 0;
|
||||
export const MAX_PATTERNS = 5;
|
||||
export const DEFAULT_PATTERN_COUNT = 5;
|
||||
|
||||
export interface PenControlRowSpec {
|
||||
role: 'pair-first' | 'pair-second';
|
||||
cells: PenControlCellStyle[];
|
||||
}
|
||||
|
||||
export interface PenControlPatternBlock {
|
||||
patternId: string;
|
||||
patternName: string;
|
||||
rows: [PenControlRowSpec, PenControlRowSpec];
|
||||
}
|
||||
|
||||
export interface PenControlSheetLayout {
|
||||
maxRow: number;
|
||||
maxCol: number;
|
||||
cellSize: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
colGap: number;
|
||||
rowGap: number;
|
||||
}
|
||||
|
||||
export interface PenControlSheetData {
|
||||
mode: PenControlMode;
|
||||
patternCount: number;
|
||||
blocks: PenControlPatternBlock[];
|
||||
layout: PenControlSheetLayout;
|
||||
}
|
||||
|
||||
export function clampPatternCount(
|
||||
requested: number,
|
||||
maxRow: number,
|
||||
): number {
|
||||
const capByRows = Math.floor(maxRow / ROWS_PER_PATTERN);
|
||||
const capped = Math.min(
|
||||
MAX_PATTERNS,
|
||||
Math.max(MIN_PATTERNS, requested),
|
||||
Math.max(MIN_PATTERNS, capByRows),
|
||||
);
|
||||
return capped;
|
||||
}
|
||||
|
||||
function buildRowCells(
|
||||
maxCol: number,
|
||||
_role: 'pair-first' | 'pair-second',
|
||||
): PenControlCellStyle[] {
|
||||
const cells: PenControlCellStyle[] = [];
|
||||
for (let c = 0; c < maxCol; c++) {
|
||||
cells.push(c === 0 ? 'reference' : 'guide');
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function normalizePatternIds(
|
||||
patternIds: string[],
|
||||
patternCount: number,
|
||||
): string[] {
|
||||
const valid = patternIds.filter((id) =>
|
||||
PEN_CONTROL_PATTERN_IDS.includes(id),
|
||||
);
|
||||
const unique: string[] = [];
|
||||
for (const id of valid) {
|
||||
if (!unique.includes(id)) unique.push(id);
|
||||
}
|
||||
|
||||
if (unique.length >= patternCount) {
|
||||
return unique.slice(0, patternCount);
|
||||
}
|
||||
|
||||
const rest = PEN_CONTROL_PATTERN_IDS.filter((id) => !unique.includes(id));
|
||||
const shuffled = [...rest].sort(() => Math.random() - 0.5);
|
||||
while (unique.length < patternCount && shuffled.length > 0) {
|
||||
unique.push(shuffled.shift()!);
|
||||
}
|
||||
return unique.slice(0, patternCount);
|
||||
}
|
||||
|
||||
export function generatePenControlMix(options: {
|
||||
patternIds: string[];
|
||||
patternCount: number;
|
||||
layout: PenControlSheetLayout;
|
||||
}): PenControlSheetData {
|
||||
const { layout } = options;
|
||||
const patternCount = clampPatternCount(
|
||||
options.patternCount,
|
||||
layout.maxRow,
|
||||
);
|
||||
const ids = normalizePatternIds(options.patternIds, patternCount);
|
||||
|
||||
const blocks: PenControlPatternBlock[] = ids.map((patternId) => {
|
||||
const pattern = getPenControlPattern(patternId);
|
||||
const pairFirst = buildRowCells(layout.maxCol, 'pair-first');
|
||||
const pairSecond = buildRowCells(layout.maxCol, 'pair-second');
|
||||
return {
|
||||
patternId,
|
||||
patternName: pattern?.name ?? patternId,
|
||||
rows: [
|
||||
{ role: 'pair-first', cells: pairFirst },
|
||||
{ role: 'pair-second', cells: pairSecond },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
mode: PEN_CONTROL_MODE,
|
||||
patternCount: blocks.length,
|
||||
blocks,
|
||||
layout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
||||
import { inferGradeFromAge } from '../../utils/debugPublish';
|
||||
import { PEN_CONTROL_MODE } from './generators/penControlGenerator';
|
||||
|
||||
interface PenControlWorksheetDefinition {
|
||||
id: typeof PEN_CONTROL_MODE;
|
||||
icon: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
ageMin: number;
|
||||
ageMax: number;
|
||||
difficulty: 1 | 2 | 3 | 4;
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export const PEN_CONTROL_WORKSHEET_DEFINITIONS = [
|
||||
{
|
||||
id: PEN_CONTROL_MODE,
|
||||
icon: 'edit',
|
||||
title: '控笔组合练习',
|
||||
subtitle: '3–6 种图形,每种占两行田字格',
|
||||
ageMin: 3,
|
||||
ageMax: 6,
|
||||
difficulty: 1,
|
||||
tags: ['控笔', '运笔', '田字格', '学前'],
|
||||
sortOrder: 40,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<PenControlWorksheetDefinition>;
|
||||
|
||||
type PenControlWorksheetRow = (typeof PEN_CONTROL_WORKSHEET_DEFINITIONS)[number];
|
||||
|
||||
const WORKSHEET_BY_ID = Object.fromEntries(
|
||||
PEN_CONTROL_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
|
||||
) as Record<string, PenControlWorksheetRow>;
|
||||
|
||||
export const PEN_CONTROL_WORKSHEET_ID = PEN_CONTROL_WORKSHEET_DEFINITIONS[0].id;
|
||||
|
||||
export function getModeInfo(id: string) {
|
||||
const m = WORKSHEET_BY_ID[id];
|
||||
return m ? { title: m.title, desc: m.subtitle } : undefined;
|
||||
}
|
||||
|
||||
export function isValidMode(id: string): boolean {
|
||||
return id in WORKSHEET_BY_ID;
|
||||
}
|
||||
|
||||
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
|
||||
const m = WORKSHEET_BY_ID[id];
|
||||
if (!m) return null;
|
||||
return {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
subtitle: m.subtitle,
|
||||
category: 'chinese',
|
||||
subcategory: 'pen-control',
|
||||
path: `/chinesePages/penControlSheet/penControlSheet?id=${m.id}`,
|
||||
ageMin: m.ageMin,
|
||||
ageMax: m.ageMax,
|
||||
grade: inferGradeFromAge(m.ageMin, m.ageMax),
|
||||
difficulty: m.difficulty,
|
||||
previewImg: '',
|
||||
tags: [...m.tags],
|
||||
isNew: true,
|
||||
isHot: false,
|
||||
sortOrder: m.sortOrder,
|
||||
status: 'draft',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "控笔组合练习",
|
||||
"navigationBarBackgroundColor": "#F8F0E0",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#FEF6E7",
|
||||
"enablePullDownRefresh": false,
|
||||
"usingComponents": {
|
||||
"nav-bar": "../../components3.0/nav-bar/nav-bar",
|
||||
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
|
||||
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
|
||||
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
|
||||
"toy-icon": "../../toy/icon/icon",
|
||||
"preview-card": "../../components3.0/preview-card/preview-card"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
@import '../../style/theme.less';
|
||||
|
||||
page {
|
||||
background-color: @bg-page;
|
||||
}
|
||||
|
||||
.pc-page {
|
||||
min-height: 100vh;
|
||||
padding: 0 @page-padding-x;
|
||||
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pc-main {
|
||||
padding-top: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.pc-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.pc-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pc-section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #6d3b00;
|
||||
padding-left: 8rpx;
|
||||
}
|
||||
|
||||
.pc-pattern-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.pc-pattern-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding: 28rpx 12rpx;
|
||||
border-radius: @radius-lg;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 4rpx solid rgba(179, 172, 159, 0.1);
|
||||
box-shadow: @shadow;
|
||||
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s,
|
||||
background 0.12s;
|
||||
}
|
||||
|
||||
.pc-pattern-card--active {
|
||||
background: #ffffff;
|
||||
border-color: @brand;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.pc-pattern-card--pressed {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.pc-pattern-card__name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: @text-title;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.pc-pattern-card__cat {
|
||||
font-size: 20rpx;
|
||||
color: @text-secondary;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.pc-pattern-card--active .pc-pattern-card__name {
|
||||
color: @text-title;
|
||||
}
|
||||
|
||||
.pc-pattern-card__check {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 18rpx;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #9cd343;
|
||||
}
|
||||
|
||||
.pc-shuffle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #f8f0e0;
|
||||
}
|
||||
|
||||
.pc-shuffle-btn--hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.pc-shuffle-btn__text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: @text-secondary;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import PenControlDrawService from './draw/penControlDrawService';
|
||||
import {
|
||||
PEN_CONTROL_PATTERNS,
|
||||
pickRandomPatternIds,
|
||||
} from './data/penControlPatterns';
|
||||
import {
|
||||
DEFAULT_PATTERN_COUNT,
|
||||
generatePenControlMix,
|
||||
MAX_PATTERNS,
|
||||
} from './generators/penControlGenerator';
|
||||
import {
|
||||
getModeInfo,
|
||||
getPublishMetaByMode,
|
||||
isValidMode,
|
||||
PEN_CONTROL_WORKSHEET_ID,
|
||||
} from './penControlSheet.config';
|
||||
import { createPage, type CanvasDataState } from '../../base/pageMixin';
|
||||
import { defaultShareConfig } from '../../config/config';
|
||||
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
||||
import {
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
batchCheckFavorited,
|
||||
} from '../../utils/favorites';
|
||||
|
||||
const pageInfoLookup = getModeInfo;
|
||||
|
||||
function pickRandomPatternSet(count: number, current: string[]): string[] {
|
||||
const next = pickRandomPatternIds(count, current);
|
||||
if (next.length >= count) return next.slice(0, count);
|
||||
return pickRandomPatternIds(count);
|
||||
}
|
||||
|
||||
function buildSelectedMap(ids: string[]): Record<string, boolean> {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const id of ids) map[id] = true;
|
||||
return map;
|
||||
}
|
||||
|
||||
type PageData = CanvasDataState & {
|
||||
worksheetId: string;
|
||||
selectedPatternIds: string[];
|
||||
selectedMap: Record<string, boolean>;
|
||||
patternList: typeof PEN_CONTROL_PATTERNS;
|
||||
isPreviewFavorite: boolean;
|
||||
isDevEnv: boolean;
|
||||
debugPublishVisible: boolean;
|
||||
debugPublishLoading: boolean;
|
||||
debugPublishMeta: DebugPublishMeta | null;
|
||||
};
|
||||
|
||||
createPage(
|
||||
{
|
||||
canvas: null as Canvas | null,
|
||||
ctx: null as RenderingContext | null,
|
||||
boxHeight: 0,
|
||||
boxWidth: 0,
|
||||
drawService: null as PenControlDrawService | null,
|
||||
_favoritedMap: {} as Record<string, boolean>,
|
||||
|
||||
data: {
|
||||
pageTitle: '控笔组合练习',
|
||||
functionId: PEN_CONTROL_WORKSHEET_ID,
|
||||
hasContent: false,
|
||||
showShareDialog: false,
|
||||
worksheetId: PEN_CONTROL_WORKSHEET_ID,
|
||||
selectedPatternIds: [] as string[],
|
||||
selectedMap: {} as Record<string, boolean>,
|
||||
patternList: PEN_CONTROL_PATTERNS,
|
||||
isPreviewFavorite: false,
|
||||
isDevEnv: false,
|
||||
debugPublishVisible: false,
|
||||
debugPublishLoading: false,
|
||||
debugPublishMeta: null,
|
||||
} as unknown as PageData,
|
||||
|
||||
onLoad(options: { id?: string }) {
|
||||
this.syncDebugPublishEnv();
|
||||
console.log('PEN_CONTROL_PATTERNS', PEN_CONTROL_PATTERNS);
|
||||
const worksheetId =
|
||||
options.id && isValidMode(options.id)
|
||||
? options.id
|
||||
: PEN_CONTROL_WORKSHEET_ID;
|
||||
|
||||
const initialPatterns = PEN_CONTROL_PATTERNS.slice(
|
||||
0,
|
||||
DEFAULT_PATTERN_COUNT,
|
||||
).map((p) => p.id);
|
||||
|
||||
console.log('initialPatterns', initialPatterns);
|
||||
|
||||
this.setData({
|
||||
worksheetId,
|
||||
functionId: worksheetId,
|
||||
selectedPatternIds: initialPatterns,
|
||||
selectedMap: buildSelectedMap(initialPatterns),
|
||||
});
|
||||
|
||||
this.initPageInfo(worksheetId, '控笔练习');
|
||||
this.loadFavoritedMap();
|
||||
},
|
||||
|
||||
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
|
||||
this.initCanvasFromComponent(e.detail, {
|
||||
createDrawService: (
|
||||
canvas: Canvas,
|
||||
ctx: RenderingContext,
|
||||
opts?: Record<string, unknown>,
|
||||
) => new PenControlDrawService(canvas, ctx, opts),
|
||||
drawServiceOptions: {
|
||||
title: this.data.pageTitle,
|
||||
},
|
||||
onCanvasReady: () => {
|
||||
this.drawCanvas();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async drawCanvas() {
|
||||
if (!this.drawService) return;
|
||||
|
||||
try {
|
||||
const layout = (
|
||||
this.drawService as PenControlDrawService
|
||||
).buildLayoutForGenerate();
|
||||
const data = generatePenControlMix({
|
||||
patternIds: this.data.selectedPatternIds,
|
||||
patternCount: this.data.selectedPatternIds.length,
|
||||
layout,
|
||||
});
|
||||
|
||||
const ids = data.blocks.map((b) => b.patternId);
|
||||
if (ids.join(',') !== this.data.selectedPatternIds.join(',')) {
|
||||
this.setData({
|
||||
selectedPatternIds: ids,
|
||||
selectedMap: buildSelectedMap(ids),
|
||||
});
|
||||
}
|
||||
|
||||
await (this.drawService as PenControlDrawService).draw(data);
|
||||
this.setData({ hasContent: true });
|
||||
} catch (e) {
|
||||
console.error('penControlSheet draw failed', e);
|
||||
this.setData({ hasContent: false });
|
||||
}
|
||||
},
|
||||
|
||||
onTogglePattern(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as string;
|
||||
if (!id) return;
|
||||
|
||||
let ids = [...this.data.selectedPatternIds];
|
||||
const idx = ids.indexOf(id);
|
||||
|
||||
if (idx >= 0) {
|
||||
ids.splice(idx, 1);
|
||||
} else {
|
||||
if (ids.length >= MAX_PATTERNS) {
|
||||
wx.showToast({
|
||||
title: `最多选择 ${MAX_PATTERNS} 个`,
|
||||
icon: 'none',
|
||||
});
|
||||
return;
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
|
||||
this.setData(
|
||||
{
|
||||
selectedPatternIds: ids,
|
||||
selectedMap: buildSelectedMap(ids),
|
||||
},
|
||||
() => this.drawCanvas(),
|
||||
);
|
||||
},
|
||||
|
||||
onPreviewRefresh() {
|
||||
const count =
|
||||
this.data.selectedPatternIds.length || DEFAULT_PATTERN_COUNT;
|
||||
const next = pickRandomPatternSet(
|
||||
count,
|
||||
this.data.selectedPatternIds,
|
||||
);
|
||||
this.setData(
|
||||
{
|
||||
selectedPatternIds: next,
|
||||
selectedMap: buildSelectedMap(next),
|
||||
},
|
||||
() => this.drawCanvas(),
|
||||
);
|
||||
},
|
||||
|
||||
onShufflePatterns() {
|
||||
this.onPreviewRefresh();
|
||||
},
|
||||
|
||||
async onPreviewFavorite() {
|
||||
const next = !this.data.isPreviewFavorite;
|
||||
this.setData({ isPreviewFavorite: next });
|
||||
const id = this.data.worksheetId;
|
||||
if (id) {
|
||||
this._favoritedMap[id] = next;
|
||||
if (next) {
|
||||
addFavorite(id);
|
||||
} else {
|
||||
removeFavorite(id);
|
||||
}
|
||||
}
|
||||
wx.showToast({
|
||||
title: next ? '收藏成功' : '已取消收藏',
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
|
||||
async loadFavoritedMap() {
|
||||
const ids = [PEN_CONTROL_WORKSHEET_ID];
|
||||
this._favoritedMap = await batchCheckFavorited(ids);
|
||||
if (this._favoritedMap[this.data.worksheetId]) {
|
||||
this.setData({ isPreviewFavorite: true });
|
||||
}
|
||||
},
|
||||
|
||||
getPublishMeta(): DebugPublishMeta {
|
||||
const meta = getPublishMetaByMode(this.data.worksheetId);
|
||||
if (!meta) {
|
||||
throw new Error('当前题型配置不存在');
|
||||
}
|
||||
return meta;
|
||||
},
|
||||
},
|
||||
{
|
||||
shareConfig: defaultShareConfig,
|
||||
pageInfoLookup,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
<nav-bar title="控笔组合练习" />
|
||||
|
||||
<view class="pc-page">
|
||||
<view class="pc-main">
|
||||
<preview-card
|
||||
id="previewCard"
|
||||
showRefresh="{{true}}"
|
||||
showFavorite="{{true}}"
|
||||
favorited="{{isPreviewFavorite}}"
|
||||
bind:canvas-ready="onCanvasReady"
|
||||
bind:refresh="onPreviewRefresh"
|
||||
bind:favorite="onPreviewFavorite" />
|
||||
|
||||
<view class="pc-section">
|
||||
<view class="pc-section-header">
|
||||
<text class="pc-section-title"
|
||||
>选择图形 ({{selectedPatternIds.length}}/5)</text
|
||||
>
|
||||
</view>
|
||||
<view class="pc-pattern-grid">
|
||||
<view
|
||||
wx:for="{{patternList}}"
|
||||
wx:key="id"
|
||||
class="pc-pattern-card {{selectedMap[item.id] ? 'pc-pattern-card--active' : ''}}"
|
||||
data-id="{{item.id}}"
|
||||
hover-class="pc-pattern-card--pressed"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="70"
|
||||
bindtap="onTogglePattern">
|
||||
<view
|
||||
wx:if="{{selectedMap[item.id]}}"
|
||||
class="pc-pattern-card__check">
|
||||
<toy-icon name="check" size="20rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="pc-pattern-card__name">{{item.name}}</text>
|
||||
<text class="pc-pattern-card__cat"
|
||||
>{{item.categoryLabel}}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="pc-shuffle-btn"
|
||||
hover-class="pc-shuffle-btn--hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="70"
|
||||
bindtap="onShufflePatterns">
|
||||
<toy-icon
|
||||
name="refresh"
|
||||
size="40rpx"
|
||||
color="#605b50"
|
||||
custom-class="pc-shuffle-btn__icon" />
|
||||
<text class="pc-shuffle-btn__text">换一批图形</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<preview-footer-actions
|
||||
disabled="{{!hasContent}}"
|
||||
bind:primary="exportToPrint"
|
||||
bind:secondary="onShare" />
|
||||
|
||||
<debug-publish-tools
|
||||
wx:if="{{isDevEnv && hasContent}}"
|
||||
id="debugPublishTools"
|
||||
visible="{{debugPublishVisible}}"
|
||||
loading="{{debugPublishLoading}}"
|
||||
meta="{{debugPublishMeta}}"
|
||||
bind:open="onOpenDebugPublish"
|
||||
bind:close="onCloseDebugPublish"
|
||||
bind:confirm="onConfirmDebugPublish" />
|
||||
|
||||
<share-guide-popup
|
||||
show="{{showShareDialog}}"
|
||||
bind:onClose="onCloseShareDialog"
|
||||
bind:onShareSuccess="onShareSuccess" />
|
||||
@@ -0,0 +1,103 @@
|
||||
import { TRACING_COLORS } from '../../../core/data/tracingStyles';
|
||||
import { drawSvgPath } from '../../shared/drawUtils';
|
||||
import type {
|
||||
PenControlPattern,
|
||||
PenControlRenderMode,
|
||||
} from '../data/penControlPatterns';
|
||||
|
||||
export const PEN_CONTROL_VIEW_SIZE = 100;
|
||||
|
||||
export interface PenControlTransform {
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function getPenControlTransform(
|
||||
width: number,
|
||||
height: number,
|
||||
padding: number,
|
||||
): PenControlTransform {
|
||||
const availableWidth = width - 2 * padding;
|
||||
const availableHeight = height - 2 * padding;
|
||||
const scale = Math.min(
|
||||
availableWidth / PEN_CONTROL_VIEW_SIZE,
|
||||
availableHeight / PEN_CONTROL_VIEW_SIZE,
|
||||
);
|
||||
const scaledW = PEN_CONTROL_VIEW_SIZE * scale;
|
||||
const scaledH = PEN_CONTROL_VIEW_SIZE * scale;
|
||||
const xOffset = padding + (availableWidth - scaledW) / 2;
|
||||
const yOffset = padding + (availableHeight - scaledH) / 2;
|
||||
return { xOffset, yOffset, scale };
|
||||
}
|
||||
|
||||
export type PenControlCellStyle = 'reference' | 'guide';
|
||||
|
||||
function colorForStyle(style: PenControlCellStyle): string {
|
||||
return style === 'reference' ? TRACING_COLORS.strong : TRACING_COLORS.guide;
|
||||
}
|
||||
|
||||
function drawPathWithMode(
|
||||
ctx: RenderingContext,
|
||||
pathD: string,
|
||||
render: PenControlRenderMode,
|
||||
color: string,
|
||||
lineWidth: number,
|
||||
dashed: boolean,
|
||||
) {
|
||||
ctx.beginPath();
|
||||
drawSvgPath(ctx, pathD);
|
||||
|
||||
if (render === 'fill' || render === 'both') {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}
|
||||
if (render === 'stroke' || render === 'both') {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = lineWidth;
|
||||
if (dashed) {
|
||||
// setLineDash 接收一个数组,表示虚线的样式。
|
||||
// 第一个参数 (lineWidth * 2.6):表示每段实线的长度
|
||||
// 第二个参数 (lineWidth * 2.4):表示每段虚线的间隙长度
|
||||
// 旧虚线效果不明显,调整为更清晰的虚线样式
|
||||
ctx.setLineDash([lineWidth * 1.8, lineWidth * 2.3]);
|
||||
} else {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在田字格中心绘制控笔图形
|
||||
*/
|
||||
export function drawPenControlInCell(
|
||||
ctx: RenderingContext,
|
||||
pattern: PenControlPattern,
|
||||
cx: number,
|
||||
cy: number,
|
||||
cellSize: number,
|
||||
style: PenControlCellStyle,
|
||||
options?: { dashed?: boolean },
|
||||
) {
|
||||
const color = colorForStyle(style);
|
||||
const padding = cellSize * 0.1;
|
||||
const transform = getPenControlTransform(cellSize, cellSize, padding);
|
||||
const lineWidth =
|
||||
(cellSize * 0.0236 * (pattern.strokeScale ?? 1)) / transform.scale;
|
||||
const dashed = options?.dashed === true;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.translate(cx - cellSize / 2, cy - cellSize / 2);
|
||||
ctx.translate(transform.xOffset, transform.yOffset);
|
||||
ctx.scale(transform.scale, transform.scale);
|
||||
|
||||
for (const pathD of pattern.paths) {
|
||||
drawPathWithMode(ctx, pathD, pattern.render, color, lineWidth, dashed);
|
||||
}
|
||||
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
}
|
||||
Reference in New Issue
Block a user