205 lines
8.8 KiB
JavaScript

/**
* Counting Worksheet Generator
* Generates randomized counting exercises with themed objects
*/
// Simple themed object SVG shapes
const themedObjects = {
star: `<polygon points='12,0 15,8 24,9 17,15 19,24 12,19 5,24 7,15 0,9 9,8' fill='none' stroke='#000000' stroke-width='2'/>`,
heart: `<path d='M12,21 L2,11 C-2,7 2,0 8,0 C10,0 12,2 12,4 C12,2 14,0 16,0 C22,0 26,7 22,11 Z' fill='none' stroke='#000000' stroke-width='2'/>`,
circle: `<circle cx='12' cy='12' r='10' fill='none' stroke='#000000' stroke-width='2'/>`,
apple: `<path d='M12,4 Q12,0 16,0 M8,6 Q0,6 0,14 Q0,24 12,24 Q24,24 24,14 Q24,6 16,6 Q12,2 8,6 Z' fill='none' stroke='#000000' stroke-width='2'/>`,
fish: `<path d='M0,12 Q6,0 18,6 L24,0 L24,24 L18,18 Q6,24 0,12 Z M16,10 A1,1 0 1,1 16,11' fill='none' stroke='#000000' stroke-width='2'/>`,
flower: `<circle cx='12' cy='12' r='4' fill='none' stroke='#000000' stroke-width='2'/><circle cx='12' cy='4' r='4' fill='none' stroke='#000000' stroke-width='1.5'/><circle cx='19' cy='8' r='4' fill='none' stroke='#000000' stroke-width='1.5'/><circle cx='19' cy='16' r='4' fill='none' stroke='#000000' stroke-width='1.5'/><circle cx='12' cy='20' r='4' fill='none' stroke='#000000' stroke-width='1.5'/><circle cx='5' cy='16' r='4' fill='none' stroke='#000000' stroke-width='1.5'/><circle cx='5' cy='8' r='4' fill='none' stroke='#000000' stroke-width='1.5'/>`,
butterfly: `<ellipse cx='6' cy='8' rx='6' ry='8' fill='none' stroke='#000000' stroke-width='2'/><ellipse cx='18' cy='8' rx='6' ry='8' fill='none' stroke='#000000' stroke-width='2'/><ellipse cx='6' cy='20' rx='5' ry='6' fill='none' stroke='#000000' stroke-width='2'/><ellipse cx='18' cy='20' rx='5' ry='6' fill='none' stroke='#000000' stroke-width='2'/><line x1='12' y1='2' x2='12' y2='26' stroke='#000000' stroke-width='2'/>`,
ball: `<circle cx='12' cy='12' r='11' fill='none' stroke='#000000' stroke-width='2'/><path d='M1,12 Q12,8 23,12' fill='none' stroke='#000000' stroke-width='1'/><path d='M1,12 Q12,16 23,12' fill='none' stroke='#000000' stroke-width='1'/>`,
moon: `<path d='M18,2 A14,14 0 1,0 18,22 A10,10 0 1,1 18,2' fill='none' stroke='#000000' stroke-width='2'/>`,
cloud: `<path d='M8,18 A6,6 0 0,1 8,6 A8,8 0 0,1 20,8 A5,5 0 0,1 20,18 Z' fill='none' stroke='#000000' stroke-width='2'/>`
};
const objectNames = Object.keys(themedObjects);
/**
* Generate random quantities that are all different
*/
function generateRandomQuantities(count, maxValue) {
const quantities = [];
const used = new Set();
while (quantities.length < count) {
const num = Math.floor(Math.random() * maxValue) + 1;
if (!used.has(num)) {
used.add(num);
quantities.push(num);
}
}
// Shuffle the array
return quantities.sort(() => Math.random() - 0.5);
}
/**
* Pick object type based on theme keywords with randomization
*/
function pickObjectForTheme(theme, llmSuggestion) {
// Try to use LLM suggestion first if valid
if (llmSuggestion && themedObjects[llmSuggestion]) {
return llmSuggestion;
}
if (!theme) {
return objectNames[Math.floor(Math.random() * objectNames.length)];
}
const themeLower = theme.toLowerCase();
// Direct matches
for (const name of objectNames) {
if (themeLower.includes(name)) {
return name;
}
}
// Build list of ALL matching shapes, then pick randomly
const associations = {
star: ['space', 'night', 'sky', 'twinkle', 'magic', 'galaxy', 'astronaut', 'king', 'crown', 'royal'],
heart: ['love', 'valentine', 'romance', 'caring', 'friendship', 'kind', 'help'],
circle: ['round', 'bubble', 'dot'],
apple: ['fruit', 'food', 'tree', 'garden', 'healthy', 'snack', 'farm', 'autumn', 'fall'],
fish: ['ocean', 'sea', 'water', 'aquarium', 'swim', 'underwater', 'beach', 'marine', 'animal'],
flower: ['garden', 'plant', 'nature', 'spring', 'rose', 'bloom', 'petal', 'beautiful'],
butterfly: ['insect', 'bug', 'garden', 'spring', 'colorful', 'wings', 'animal', 'fly'],
ball: ['sport', 'play', 'game', 'soccer', 'football', 'basketball', 'tennis', 'cricket', 'kick'],
moon: ['night', 'sleep', 'bedtime', 'dream', 'space', 'lunar', 'dark'],
cloud: ['sky', 'weather', 'rain', 'sunny', 'day', 'fluffy', 'soft']
};
// Collect ALL matching shapes
const matchingShapes = [];
for (const [objectType, keywords] of Object.entries(associations)) {
if (keywords.some(keyword => themeLower.includes(keyword))) {
matchingShapes.push(objectType);
}
}
// If we found matches, pick one randomly for variety
if (matchingShapes.length > 0) {
return matchingShapes[Math.floor(Math.random() * matchingShapes.length)];
}
// Random fallback
return objectNames[Math.floor(Math.random() * objectNames.length)];
}
/**
* Generate a group of objects at a position
*/
function generateObjectGroup(objectType, count, x, y, groupWidth) {
const objectSvg = themedObjects[objectType] || themedObjects.star;
const objectSize = 22;
const maxPerRow = 5;
let svg = '';
if (count <= maxPerRow) {
// Single row
const spacing = Math.min(28, (groupWidth - count * objectSize) / (count + 1));
let startX = x + spacing;
for (let i = 0; i < count; i++) {
svg += `<g transform='translate(${startX + i * (objectSize + spacing)}, ${y})'>${objectSvg}</g>`;
}
} else {
// Two rows for larger counts
const topRowCount = Math.ceil(count / 2);
const bottomRowCount = count - topRowCount;
const spacing = Math.min(26, (groupWidth - topRowCount * objectSize) / (topRowCount + 1));
// Top row
let startX = x + spacing;
for (let i = 0; i < topRowCount; i++) {
svg += `<g transform='translate(${startX + i * (objectSize + spacing)}, ${y - 12})'>${objectSvg}</g>`;
}
// Bottom row
startX = x + spacing + (topRowCount - bottomRowCount) * (objectSize + spacing) / 2;
for (let i = 0; i < bottomRowCount; i++) {
svg += `<g transform='translate(${startX + i * (objectSize + spacing)}, ${y + 14})'>${objectSvg}</g>`;
}
}
return svg;
}
/**
* Generate an empty answer box
*/
function generateAnswerBox(x, y, size = 40) {
return `<rect x='${x}' y='${y}' width='${size}' height='${size}' fill='#FFFFFF' stroke='#333333' stroke-width='2' rx='5'/>`;
}
/**
* Generate complete counting worksheet
* @param {Object} params
* @param {string} params.theme - Theme name (optional)
* @param {string} params.shape - Shape chosen by LLM (e.g. 'fish', 'star')
* @param {number[]} params.quantities - Array of quantities from LLM
* @param {number} params.age - Child's age
*/
export function generateCountingWorksheet({ theme, shape, quantities, age }) {
// Use hybrid approach: LLM suggestion + programmatic randomization from matching shapes
const objectType = pickObjectForTheme(theme, shape);
// ALWAYS generate random quantities for variety (LLM is deterministic)
const maxValue = age <= 4 ? 5 : 10;
const rowCount = 8;
const finalQuantities = generateRandomQuantities(rowCount, maxValue);
const rowHeight = 62; // 500 / 8 = 62.5, fill entire height
const startY = 0; // Start at absolute top
const objectAreaWidth = 320;
const answerBoxX = 440;
const answerBoxSize = 42;
let objectRows = '';
for (let i = 0; i < rowCount; i++) {
const rowY = startY + i * rowHeight;
const quantity = finalQuantities[i];
// Dotted separator line between rows (except first row)
if (i > 0) {
objectRows += `<line x1='15' y1='${rowY}' x2='485' y2='${rowY}' stroke='#CCCCCC' stroke-width='1' stroke-dasharray='5,5'/>`;
}
// Row number indicator - vertically centered in row
objectRows += `<text x='30' y='${rowY + 38}' font-family='Arial, sans-serif' font-size='14' font-weight='bold' fill='#AAAAAA'>${i + 1}.</text>`;
// Objects to count - positioned in middle of row
objectRows += generateObjectGroup(objectType, quantity, 50, rowY + 20, objectAreaWidth);
// Equals sign
objectRows += `<text x='405' y='${rowY + 40}' font-family='Arial, sans-serif' font-size='20' font-weight='bold' fill='#666666'>=</text>`;
// Answer box
objectRows += generateAnswerBox(answerBoxX, rowY + 10, answerBoxSize);
}
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>
<!-- Counting rows -->
${objectRows}
</svg>`;
return {
title: `Count the ${objectType.charAt(0).toUpperCase() + objectType.slice(1)}s!`,
subtitle: 'Count each group and write the number',
sections: [{
id: 'counting-main',
type: 'counting',
content_svg: svg
}]
};
}
export default { generateCountingWorksheet };