import { BaseDrawService } from '../../core/draw/baseDraw'; import ColorShapeMatchDraw from '../shared/service/colorShapeMatchDraw'; import ShapeSymbolDraw from '../shared/service/shapeSymbolDraw'; import PositionColoringDraw from '../shared/service/positionColoringDraw'; import ColorPatternDraw from '../shared/service/colorPatternDraw'; import MatchConnectDraw from '../shared/service/matchConnectDraw'; import LineRecognitionDraw from '../shared/service/lineRecognitionDraw'; import GridReasoningDraw, { type GridPosition, } from '../shared/service/gridReasoningDraw'; import CodeConnectDraw from '../shared/service/codeConnectDraw'; import DotConnectDraw from '../shared/service/dotConnectDraw'; import GridDraw, { type GridGroup } from '../shared/service/gridDraw'; import { getRandomUniqueNumberColors, getNumberColors, getRandomNumberColor, NUMBER_COLORS, WATER_COLORS, } from '../../constants/colors'; import { SHAPE_SYMBOL_SHAPES } from '../shared/shapes/shapeSymbolShapes'; import { generateCompletePattern } from '../shared/utils/gridUtils'; import { ALL_3X3_SHAPES } from '../shared/shapes/gridShapes3x3'; import { ALL_5X5_SHAPES } from '../shared/shapes/gridShapes5x5'; import { ALL_7X7_SHAPES } from '../shared/shapes/gridShapes7x7'; import type { ShapeTemplate, GridCell, GridConfig, } from '../shared/types/gridTypes'; // ─── Types ─── export interface FocusTypeAction { value: string; label: string; } export interface FocusTypeConfig { id: string; title: string; subTitle: string; icon: string; actionsTitle?: string; actions?: FocusTypeAction[]; defaultMode?: string; getTitle?: (mode: string) => string; getSubTitle?: (mode: string) => string; createDrawService: ( canvas: Canvas, ctx: RenderingContext, options?: Record, ) => BaseDrawService; generateData: (mode?: string) => any; } // ─── Helper: dotConnect ─── function generateNonBacktrackingSequence(length: number): number[] { const getPosition = (num: number): [number, number] => { const row = Math.floor((num - 1) / 3); const col = (num - 1) % 3; return [row, col]; }; const sequence: number[] = []; const used = new Set(); const startNum = Math.floor(Math.random() * 9) + 1; sequence.push(startNum); used.add(startNum); let prevPos = getPosition(startNum); let prevPrevPos: [number, number] | null = null; while (sequence.length < length) { const candidates: number[] = []; for (let num = 1; num <= 9; num++) { if (used.has(num)) continue; const currentPos = getPosition(num); if ( prevPrevPos && currentPos[0] === prevPrevPos[0] && currentPos[1] === prevPrevPos[1] ) { continue; } candidates.push(num); } const pool = candidates.length > 0 ? candidates : Array.from({ length: 9 }, (_, i) => i + 1).filter( (n) => !used.has(n), ); if (pool.length === 0) break; const nextNum = pool[Math.floor(Math.random() * pool.length)]; sequence.push(nextNum); used.add(nextNum); prevPrevPos = prevPos; prevPos = getPosition(nextNum); } return sequence; } // ─── Helpers: gridReasoning ─── function generateRandomPositions(count: number): GridPosition[] { const allPositions: GridPosition[] = []; for (let y = 0; y < 3; y++) { for (let x = 0; x < 3; x++) { allPositions.push({ x, y }); } } const shuffled = [...allPositions].sort(() => Math.random() - 0.5); return shuffled.slice(0, count); } function unionPositions( pos1: GridPosition[], pos2: GridPosition[], ): GridPosition[] { const set = new Set(); const result: GridPosition[] = []; for (const pos of pos1) { const key = `${pos.x},${pos.y}`; if (!set.has(key)) { set.add(key); result.push(pos); } } for (const pos of pos2) { const key = `${pos.x},${pos.y}`; if (!set.has(key)) { set.add(key); result.push(pos); } } return result; } function subtractPositions( pos1: GridPosition[], pos2: GridPosition[], ): GridPosition[] { const set2 = new Set(pos2.map((p) => `${p.x},${p.y}`)); return pos1.filter((p) => !set2.has(`${p.x},${p.y}`)); } // ─── Helpers: gridDrawing ─── function generateUniqueGroups( allShapes: ShapeTemplate[], count: number, config: GridConfig, ): GridGroup[] { const groups: GridGroup[] = []; const usedPatterns = new Set(); const availableShapes = [...allShapes]; for (let i = availableShapes.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [availableShapes[i], availableShapes[j]] = [ availableShapes[j], availableShapes[i], ]; } for (let i = 0; i < count; i++) { const shapeIndex = i % availableShapes.length; const shape = availableShapes[shapeIndex]; const cells = generateCompletePattern(shape, config); const sig = cells .map((c: GridCell) => `${c.x},${c.y},${c.color}`) .sort() .join('|'); if ( usedPatterns.has(sig) && usedPatterns.size < Math.min(count, allShapes.length) ) { let found = false; for (const nextShape of availableShapes) { const nextCells = generateCompletePattern(nextShape, config); const nextSig = nextCells .map((c: GridCell) => `${c.x},${c.y},${c.color}`) .sort() .join('|'); if (!usedPatterns.has(nextSig)) { groups.push({ filledCells: nextCells, emptyCells: [] }); usedPatterns.add(nextSig); found = true; break; } } if (!found) { groups.push({ filledCells: cells, emptyCells: [] }); } } else { groups.push({ filledCells: cells, emptyCells: [] }); usedPatterns.add(sig); } } return groups; } // ─── Data Generators ─── function generateColorShapeMatchData() { const shapes = [...SHAPE_SYMBOL_SHAPES].filter((s) => s.id !== 'cross'); const selectedShapes = shapes.sort(() => Math.random() - 0.5).slice(0, 4); const colors = getRandomUniqueNumberColors(4); const legendItems = selectedShapes.map((shape, i) => ({ shapeId: shape.id, color: colors[i], })); const allColors: (string | null)[] = new Array(25).fill(null); const positions = Array.from({ length: 25 }, (_, i) => i); const shuffledPos = [...positions].sort(() => Math.random() - 0.5); for (let i = 0; i < 4; i++) allColors[shuffledPos[i]] = colors[i]; for (let i = 4; i < 25; i++) allColors[shuffledPos[i]] = colors[Math.floor(Math.random() * colors.length)]; const practiceGrid: Array> = []; for (let row = 0; row < 5; row++) { practiceGrid.push( Array.from({ length: 5 }, (_, col) => allColors[row * 5 + col]), ); } return { legendItems, practiceGrid }; } function generateShapeSymbolData() { const shuffled = [...SHAPE_SYMBOL_SHAPES].sort(() => Math.random() - 0.5); const selectedShapes = shuffled.slice(0, 4); const symbols = ['+', '-', '×', '✓']; const shuffledSymbols = [...symbols].sort(() => Math.random() - 0.5); const shapeColorMap = new Map(); const usedColors = new Set(); const legendMapping: Array<{ shapeId: string; symbol: string; color: string; }> = []; for (let i = 0; i < 4; i++) { const shape = selectedShapes[i]; let color: string; if (shapeColorMap.has(shape.id)) { color = shapeColorMap.get(shape.id)!; } else { do { color = getRandomNumberColor(); } while (usedColors.has(color)); shapeColorMap.set(shape.id, color); usedColors.add(color); } legendMapping.push({ shapeId: shape.id, symbol: shuffledSymbols[i], color, }); } const practiceRows: Array< Array<{ shapeId: string | null; symbol: string | null }> > = []; for (let g = 0; g < 4; g++) { const shapeRow = Array.from({ length: 8 }, () => { const rs = selectedShapes[ Math.floor(Math.random() * selectedShapes.length) ]; return { shapeId: rs.id, symbol: null }; }); practiceRows.push(shapeRow); practiceRows.push( Array.from({ length: 8 }, () => ({ shapeId: null, symbol: null })), ); } return { legendMapping, practiceRows, shapeColorMap: Object.fromEntries(shapeColorMap), }; } function generatePositionColoringData() { const allImageIndices = Array.from({ length: 33 }, (_, i) => i + 1); const shuffled = [...allImageIndices].sort(() => Math.random() - 0.5); const selectedImages = shuffled.slice(0, 9); const referenceGrid: number[] = [...selectedImages]; const taskImageIndices = [...selectedImages].sort( () => Math.random() - 0.5, ); const tasks = taskImageIndices.map((imageIndex) => { const gridIndex = referenceGrid.indexOf(imageIndex); return { imageIndex, position: { x: gridIndex % 3, y: Math.floor(gridIndex / 3) }, }; }); return { referenceGrid, tasks }; } function generateColorPatternData() { const allShapes = [...SHAPE_SYMBOL_SHAPES].sort(() => Math.random() - 0.5); const selectedShapes = allShapes.slice(0, 8); const allColors = getNumberColors(); const groups: Array<{ shapeId: string; colors: Array }> = []; for (let gi = 0; gi < 8; gi++) { const shuffledColors = [...allColors].sort(() => Math.random() - 0.5); const selectedColors = shuffledColors.slice(0, 2); const patternColors: string[] = []; for (let i = 0; i < 4; i++) { patternColors.push( selectedColors[ Math.floor(Math.random() * selectedColors.length) ], ); } if (new Set(patternColors).size === 1) { const other = selectedColors.filter((c) => c !== patternColors[0]); if (other.length > 0) { patternColors[Math.floor(Math.random() * 4)] = other[Math.floor(Math.random() * other.length)]; } } groups.push({ shapeId: selectedShapes[gi].id, colors: [...patternColors, ...new Array(6).fill(null)], }); } return { groups }; } function generateMatchConnectData() { const allImageIndices = Array.from({ length: 33 }, (_, i) => i + 1); const shuffled = [...allImageIndices].sort(() => Math.random() - 0.5); const selectedImages = shuffled.slice(0, 5); const referenceSequence = [...selectedImages]; const boxes = Array.from({ length: 6 }, () => ({ imageIndices: [...selectedImages], })); return { referenceSequence, boxes }; } function generateLineRecognitionData() { const LINE_TYPES: Array<{ type: string; name: string }> = [ { type: 'straight', name: '直线' }, { type: 'dashed', name: '虚线' }, { type: 'wavy', name: '波浪线' }, { type: 'zigzag', name: '锯齿线' }, { type: 'spiral', name: '电话线' }, ]; const colorKeys = Object.keys(NUMBER_COLORS) .map(Number) .filter((k) => k !== 11 && k !== 12); const shuffledColors = [...colorKeys].sort(() => Math.random() - 0.5); const selectedColorKeys = shuffledColors.slice(0, 5); const shuffledLineTypes = [...LINE_TYPES].sort(() => Math.random() - 0.5); const referenceLines = shuffledLineTypes.map((lt, i) => ({ type: lt.type, name: lt.name, color: NUMBER_COLORS[selectedColorKeys[i]], })); const practiceBlocks: Array<{ color: string; lineType: string; x: number; y: number; }> = []; for (let row = 0; row < 8; row++) { for (let col = 0; col < 2; col++) { const sel = referenceLines[ Math.floor(Math.random() * referenceLines.length) ]; practiceBlocks.push({ color: sel.color, lineType: sel.type, x: (Math.random() - 0.5) * 40, y: (Math.random() - 0.5) * 16, }); } } return { referenceLines, practiceBlocks }; } function generateGridReasoningData(mode?: string) { const operatorType = mode || 'mixed'; const colorKeys = Object.keys(NUMBER_COLORS).map(Number); const selectedColor = NUMBER_COLORS[colorKeys[Math.floor(Math.random() * colorKeys.length)]]; const rows: Array<{ grid1: GridPosition[]; grid2: GridPosition[]; operator: '+' | '-'; result: GridPosition[]; }> = []; for (let i = 0; i < 5; i++) { let operator: '+' | '-'; if (operatorType === 'addition') operator = '+'; else if (operatorType === 'subtraction') operator = '-'; else operator = Math.random() > 0.5 ? '+' : '-'; const grid1Count = operator === '-' ? Math.floor(Math.random() * 8) + 2 : Math.floor(Math.random() * 9) + 1; const grid1 = generateRandomPositions(grid1Count); let grid2: GridPosition[]; let result: GridPosition[]; if (operator === '+') { grid2 = generateRandomPositions(Math.floor(Math.random() * 9) + 1); result = unionPositions(grid1, grid2); } else { const maxG2 = grid1Count - 1; grid2 = generateRandomPositions( Math.floor(Math.random() * maxG2) + 1, ); result = subtractPositions(grid1, grid2); } rows.push({ grid1, grid2, operator, result }); } const answerOptions = rows.map((r, i) => ({ positions: r.result, rowIndex: i, })); answerOptions.sort(() => Math.random() - 0.5); return { rows, answerOptions, color: selectedColor }; } function generateCodeConnectData() { const startOptions = [1, 2, 3]; const startNumber = startOptions[Math.floor(Math.random() * startOptions.length)]; const numbers = Array.from({ length: 8 }, (_, i) => startNumber + i); const colorIndices = Array.from( { length: WATER_COLORS.extended24.length }, (_, i) => i, ); const shuffledIdx = [...colorIndices] .sort(() => Math.random() - 0.5) .slice(0, 8); const colorMap = numbers.map((num, i) => ({ number: num, color: WATER_COLORS.extended24[shuffledIdx[i]].hex, })); const groups: Array<{ sequence: number[]; dots: Array<{ number: number; color: string; angle: number }>; }> = []; for (let i = 0; i < 4; i++) { const sequence = [...numbers] .sort(() => Math.random() - 0.5) .slice(0, 5); const dots = numbers.map((num, j) => { const angle = (j / 8) * Math.PI * 2 - Math.PI / 2; const info = colorMap.find((m) => m.number === num); return { number: num, color: info?.color || '#000', angle }; }); groups.push({ sequence, dots }); } return { colorMap, startNumber, groups }; } function generateDotConnectData() { const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9]; const colors = getRandomUniqueNumberColors(9); const colorMap = numbers.map((num, i) => ({ number: num, color: colors[i], })); const groups = Array.from({ length: 9 }, () => { const seqLen = Math.floor(Math.random() * 3) + 4; return { sequence: generateNonBacktrackingSequence(seqLen) }; }); return { colorMap, groups }; } function generateGridDrawingData(mode?: string) { const m = mode || '3x3'; let allShapes: ShapeTemplate[]; let config: GridConfig; let groupsPerRow: number; let totalRows: number; if (m === '5x5') { allShapes = ALL_5X5_SHAPES; config = { rows: 5, cols: 5 }; groupsPerRow = 2; totalRows = 4; } else if (m === '7x7') { allShapes = ALL_7X7_SHAPES; config = { rows: 7, cols: 7 }; groupsPerRow = 1; totalRows = 2; } else { allShapes = ALL_3X3_SHAPES; config = { rows: 3, cols: 3 }; groupsPerRow = 2; totalRows = 4; } const totalGroups = groupsPerRow * totalRows; const groups = generateUniqueGroups(allShapes, totalGroups, config); return { config, groups, groupsPerRow, totalRows }; } // ─── Registry ─── const GRID_DRAWING_SUBTITLES: Record = { '3x3': '简单有趣,培养专注力', '5x5': '创意挑战,提升观察力', '7x7': '大师挑战,锻炼耐心', }; export const FOCUS_TYPE_CONFIGS: FocusTypeConfig[] = [ { id: 'color-shape-match', title: '根据颜色画图形', subTitle: '根据颜色画出对应的图形', icon: '🎯', createDrawService: (canvas, ctx, opts) => new ColorShapeMatchDraw(canvas, ctx, opts), generateData: generateColorShapeMatchData, }, { id: 'shape-symbol', title: '图形符号配对', subTitle: '根据图形画对应的符号', icon: '🔗', createDrawService: (canvas, ctx, opts) => new ShapeSymbolDraw(canvas, ctx, opts), generateData: generateShapeSymbolData, }, { id: 'position-coloring', title: '方位涂涂乐', subTitle: '观察卡片位置,在对应的方格中涂上颜色', icon: '📍', createDrawService: (canvas, ctx, opts) => new PositionColoringDraw(canvas, ctx, opts), generateData: generatePositionColoringData, }, { id: 'color-pattern', title: '颜色找规律', subTitle: '观察颜色规律,在空白图形中涂上颜色', icon: '🎨', createDrawService: (canvas, ctx, opts) => new ColorPatternDraw(canvas, ctx, opts), generateData: generateColorPatternData, }, { id: 'match-connect', title: '连连看', subTitle: '快来根据物品连一连吧!', icon: '🔗', createDrawService: (canvas, ctx, opts) => new MatchConnectDraw(canvas, ctx, opts), generateData: generateMatchConnectData, }, { id: 'line-recognition', title: '线条识别', subTitle: '认识不同线条,画出颜色对应的线条', icon: '📏', createDrawService: (canvas, ctx, opts) => new LineRecognitionDraw(canvas, ctx, opts), generateData: generateLineRecognitionData, }, { id: 'grid-reasoning', title: '方格推理', subTitle: '仔细观察,推理出合并方格并连线', icon: '🧩', actionsTitle: '选择运算', actions: [ { value: 'addition', label: '加法运算' }, { value: 'subtraction', label: '减法运算' }, { value: 'mixed', label: '混合运算' }, ], defaultMode: 'mixed', createDrawService: (canvas, ctx, opts) => new GridReasoningDraw(canvas, ctx, opts), generateData: generateGridReasoningData, }, { id: 'code-connect', title: '译码连线', subTitle: '按照数字顺序,将数字对应的颜色连起来', icon: '🔢', createDrawService: (canvas, ctx, opts) => new CodeConnectDraw(canvas, ctx, opts), generateData: generateCodeConnectData, }, { id: 'dot-connect', title: '数字点连线', subTitle: '按数字顺序连点成图', icon: '🔗', createDrawService: (canvas, ctx, opts) => new DotConnectDraw(canvas, ctx, opts), generateData: generateDotConnectData, }, { id: 'grid-drawing', title: '格子仿画', subTitle: '在网格中填充颜色,形成各种形状', icon: '🎨', actionsTitle: '选择难度', actions: [ { value: '3x3', label: '3×3' }, { value: '5x5', label: '5×5' }, { value: '7x7', label: '7×7' }, ], defaultMode: '3x3', getTitle: (mode: string) => { const labels: Record = { '3x3': '3×3', '5x5': '5×5', '7x7': '7×7', }; return `格子仿画 ${labels[mode] || '3×3'}`; }, getSubTitle: (mode: string) => GRID_DRAWING_SUBTITLES[mode] || GRID_DRAWING_SUBTITLES['3x3'], createDrawService: (canvas, ctx, opts) => new GridDraw(canvas, ctx, opts), generateData: generateGridDrawingData, }, ]; /** * 通过路由 ID 查找类型配置 * 处理 grid-drawing-3x3 → grid-drawing + mode=3x3 等别名 */ export function findTypeByRouteId( routeId: string, ): { typeConfig: FocusTypeConfig; mode?: string } | null { const direct = FOCUS_TYPE_CONFIGS.find((t) => t.id === routeId); if (direct) return { typeConfig: direct }; if (routeId.startsWith('grid-drawing-')) { const mode = routeId.replace('grid-drawing-', ''); const cfg = FOCUS_TYPE_CONFIGS.find((t) => t.id === 'grid-drawing'); if (cfg) return { typeConfig: cfg, mode }; } return { typeConfig: FOCUS_TYPE_CONFIGS[0] }; }