feat: Introduce new worksheet generators for counting, coloring, maze, and writing activities.
This commit is contained in:
parent
c35de02a8f
commit
ed9c118fef
File diff suppressed because one or more lines are too long
216
server/generators/coloring.js
Normal file
216
server/generators/coloring.js
Normal file
@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Coloring Page Generator
|
||||
* Creates themed coloring pages with pre-defined SVG shapes
|
||||
*/
|
||||
|
||||
// Library of coloring illustrations (outlines only)
|
||||
const illustrations = {
|
||||
// Animals
|
||||
cat: `<g transform='translate(150, 100) scale(3)'>
|
||||
<ellipse cx='25' cy='35' rx='20' ry='25' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='25' cy='15' rx='18' ry='15' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<polygon points='8,5 12,18 3,15' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<polygon points='42,5 38,18 47,15' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<circle cx='18' cy='12' r='3' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<circle cx='32' cy='12' r='3' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<ellipse cx='25' cy='20' rx='4' ry='3' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<path d='M25,23 L25,28' stroke='#000' stroke-width='1.5'/>
|
||||
<path d='M21,26 Q25,30 29,26' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<path d='M8,15 L-5,12' stroke='#000' stroke-width='1'/>
|
||||
<path d='M8,18 L-5,18' stroke='#000' stroke-width='1'/>
|
||||
<path d='M42,15 L55,12' stroke='#000' stroke-width='1'/>
|
||||
<path d='M42,18 L55,18' stroke='#000' stroke-width='1'/>
|
||||
</g>`,
|
||||
|
||||
dog: `<g transform='translate(150, 100) scale(3)'>
|
||||
<ellipse cx='25' cy='40' rx='22' ry='28' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='25' cy='12' rx='18' ry='14' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='8' cy='8' rx='8' ry='12' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='42' cy='8' rx='8' ry='12' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<circle cx='18' cy='10' r='3' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<circle cx='32' cy='10' r='3' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<ellipse cx='25' cy='18' rx='6' ry='4' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<path d='M22,24 Q25,28 28,24' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<path d='M47,55 Q55,65 48,70' fill='none' stroke='#000' stroke-width='2'/>
|
||||
</g>`,
|
||||
|
||||
butterfly: `<g transform='translate(120, 80) scale(3.5)'>
|
||||
<ellipse cx='25' cy='25' rx='20' ry='18' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='55' cy='25' rx='20' ry='18' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='25' cy='50' rx='15' ry='12' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='55' cy='50' rx='15' ry='12' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='40' cy='35' rx='4' ry='25' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M38,10 Q30,0 25,5' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<path d='M42,10 Q50,0 55,5' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<circle cx='25' cy='25' r='5' fill='none' stroke='#000' stroke-width='1'/>
|
||||
<circle cx='55' cy='25' r='5' fill='none' stroke='#000' stroke-width='1'/>
|
||||
</g>`,
|
||||
|
||||
fish: `<g transform='translate(100, 120) scale(4)'>
|
||||
<ellipse cx='40' cy='25' rx='35' ry='20' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<polygon points='5,25 -15,10 -15,40' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<circle cx='55' cy='20' r='4' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<path d='M40,5 Q50,0 55,8' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M40,45 Q50,50 55,42' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M25,15 Q30,25 25,35' fill='none' stroke='#000' stroke-width='1'/>
|
||||
<path d='M35,13 Q40,25 35,37' fill='none' stroke='#000' stroke-width='1'/>
|
||||
</g>`,
|
||||
|
||||
flower: `<g transform='translate(150, 80) scale(3.5)'>
|
||||
<circle cx='25' cy='30' r='12' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='25' cy='10' rx='10' ry='12' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='45' cy='22' rx='10' ry='12' transform='rotate(60, 45, 22)' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='40' cy='45' rx='10' ry='12' transform='rotate(120, 40, 45)' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='10' cy='45' rx='10' ry='12' transform='rotate(-120, 10, 45)' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='5' cy='22' rx='10' ry='12' transform='rotate(-60, 5, 22)' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M25,42 L25,80' stroke='#000' stroke-width='3'/>
|
||||
<path d='M25,55 Q35,50 40,55' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M25,65 Q15,60 10,65' fill='none' stroke='#000' stroke-width='2'/>
|
||||
</g>`,
|
||||
|
||||
star: `<g transform='translate(125, 80) scale(5)'>
|
||||
<polygon points='25,0 31,18 50,18 35,30 41,50 25,38 9,50 15,30 0,18 19,18' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<circle cx='20' cy='22' r='2' fill='none' stroke='#000' stroke-width='1'/>
|
||||
<circle cx='30' cy='22' r='2' fill='none' stroke='#000' stroke-width='1'/>
|
||||
<path d='M22,28 Q25,32 28,28' fill='none' stroke='#000' stroke-width='1'/>
|
||||
</g>`,
|
||||
|
||||
rocket: `<g transform='translate(150, 60) scale(3)'>
|
||||
<path d='M25,0 Q35,20 35,50 L35,70 L15,70 L15,50 Q15,20 25,0' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<circle cx='25' cy='30' r='8' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M15,55 L5,70 L15,65' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M35,55 L45,70 L35,65' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M18,70 Q25,90 32,70' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M20,75 Q25,85 30,75' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
</g>`,
|
||||
|
||||
sun: `<g transform='translate(125, 80) scale(4)'>
|
||||
<circle cx='30' cy='30' r='20' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<line x1='30' y1='0' x2='30' y2='-10' stroke='#000' stroke-width='2'/>
|
||||
<line x1='30' y1='60' x2='30' y2='70' stroke='#000' stroke-width='2'/>
|
||||
<line x1='0' y1='30' x2='-10' y2='30' stroke='#000' stroke-width='2'/>
|
||||
<line x1='60' y1='30' x2='70' y2='30' stroke='#000' stroke-width='2'/>
|
||||
<line x1='9' y1='9' x2='2' y2='2' stroke='#000' stroke-width='2'/>
|
||||
<line x1='51' y1='9' x2='58' y2='2' stroke='#000' stroke-width='2'/>
|
||||
<line x1='9' y1='51' x2='2' y2='58' stroke='#000' stroke-width='2'/>
|
||||
<line x1='51' y1='51' x2='58' y2='58' stroke='#000' stroke-width='2'/>
|
||||
<circle cx='22' cy='25' r='3' fill='none' stroke='#000' stroke-width='1'/>
|
||||
<circle cx='38' cy='25' r='3' fill='none' stroke='#000' stroke-width='1'/>
|
||||
<path d='M22,38 Q30,45 38,38' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
</g>`,
|
||||
|
||||
house: `<g transform='translate(100, 100) scale(3)'>
|
||||
<rect x='15' y='40' width='70' height='55' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<polygon points='50,5 10,40 90,40' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<rect x='40' y='65' width='20' height='30' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<circle cx='55' cy='80' r='2' fill='none' stroke='#000' stroke-width='1'/>
|
||||
<rect x='22' y='50' width='15' height='15' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<line x1='29.5' y1='50' x2='29.5' y2='65' stroke='#000' stroke-width='1'/>
|
||||
<line x1='22' y1='57.5' x2='37' y2='57.5' stroke='#000' stroke-width='1'/>
|
||||
<rect x='63' y='50' width='15' height='15' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<line x1='70.5' y1='50' x2='70.5' y2='65' stroke='#000' stroke-width='1'/>
|
||||
<line x1='63' y1='57.5' x2='78' y2='57.5' stroke='#000' stroke-width='1'/>
|
||||
</g>`,
|
||||
|
||||
unicorn: `<g transform='translate(100, 80) scale(3)'>
|
||||
<ellipse cx='50' cy='50' rx='35' ry='25' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<ellipse cx='20' cy='35' rx='15' ry='20' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<polygon points='20,15 25,0 15,0' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M8,25 Q0,20 5,30' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<circle cx='15' cy='30' r='2' fill='none' stroke='#000' stroke-width='1'/>
|
||||
<path d='M10,40 Q15,45 20,42' fill='none' stroke='#000' stroke-width='1'/>
|
||||
<path d='M25,75 L25,95' stroke='#000' stroke-width='3'/>
|
||||
<path d='M40' y1='75' L40,95' stroke='#000' stroke-width='3'/>
|
||||
<path d='M60,75 L60,95' stroke='#000' stroke-width='3'/>
|
||||
<path d='M75,75 L75,95' stroke='#000' stroke-width='3'/>
|
||||
<path d='M85,50 Q100,45 95,55 Q105,60 90,65' fill='none' stroke='#000' stroke-width='2'/>
|
||||
<path d='M5,20 Q-5,15 0,25' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
<path d='M35,25 Q45,20 38,30' fill='none' stroke='#000' stroke-width='1.5'/>
|
||||
</g>`
|
||||
};
|
||||
|
||||
const illustrationNames = Object.keys(illustrations);
|
||||
|
||||
/**
|
||||
* Pick an illustration based on theme keywords with randomization
|
||||
*/
|
||||
function pickIllustration(theme, llmSuggestion, age) {
|
||||
// Try LLM suggestion first if valid
|
||||
if (llmSuggestion && illustrations[llmSuggestion]) {
|
||||
return llmSuggestion;
|
||||
}
|
||||
|
||||
if (!theme) {
|
||||
return illustrationNames[Math.floor(Math.random() * illustrationNames.length)];
|
||||
}
|
||||
|
||||
const themeLower = theme.toLowerCase();
|
||||
|
||||
// Direct matches
|
||||
for (const name of illustrationNames) {
|
||||
if (themeLower.includes(name)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyword associations
|
||||
const associations = {
|
||||
cat: ['kitten', 'kitty', 'meow', 'feline', 'pet', 'animal'],
|
||||
dog: ['puppy', 'doggy', 'woof', 'canine', 'pet', 'animal'],
|
||||
butterfly: ['insect', 'bug', 'garden', 'spring', 'animal'],
|
||||
fish: ['ocean', 'sea', 'water', 'aquarium', 'swim', 'animal'],
|
||||
flower: ['garden', 'plant', 'nature', 'spring', 'rose'],
|
||||
star: ['night', 'sky', 'space', 'twinkle', 'magic'],
|
||||
rocket: ['space', 'astronaut', 'moon', 'planet', 'fly'],
|
||||
sun: ['summer', 'sunny', 'bright', 'happy', 'day'],
|
||||
house: ['home', 'building', 'family', 'cozy'],
|
||||
unicorn: ['magic', 'fairy', 'princess', 'rainbow', 'fantasy', 'horse', 'animal']
|
||||
};
|
||||
|
||||
// Collect ALL matching illustrations
|
||||
const matchingIllustrations = [];
|
||||
for (const [illustration, keywords] of Object.entries(associations)) {
|
||||
if (keywords.some(keyword => themeLower.includes(keyword))) {
|
||||
matchingIllustrations.push(illustration);
|
||||
}
|
||||
}
|
||||
|
||||
// If we found matches, pick one randomly for variety
|
||||
if (matchingIllustrations.length > 0) {
|
||||
return matchingIllustrations[Math.floor(Math.random() * matchingIllustrations.length)];
|
||||
}
|
||||
|
||||
// Random fallback
|
||||
return illustrationNames[Math.floor(Math.random() * illustrationNames.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete coloring page
|
||||
* @param {Object} params
|
||||
* @param {string} params.theme - Theme for the coloring page
|
||||
* @param {string} params.illustration - LLM-suggested illustration (optional)
|
||||
* @param {number} params.age - Child's age (optional)
|
||||
*/
|
||||
export function generateColoringPage({ theme, illustration, age }) {
|
||||
const selectedIllustration = pickIllustration(theme, illustration, age);
|
||||
const illustrationSvg = illustrations[selectedIllustration] || illustrations.cat;
|
||||
|
||||
const displayName = selectedIllustration.charAt(0).toUpperCase() + selectedIllustration.slice(1);
|
||||
|
||||
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>
|
||||
<!-- Main illustration -->
|
||||
${illustrationSvg}
|
||||
</svg>`;
|
||||
|
||||
return {
|
||||
title: `${displayName} Coloring Page`,
|
||||
subtitle: theme ? `${theme} themed coloring fun!` : 'Color and have fun!',
|
||||
sections: [{
|
||||
id: 'coloring-main',
|
||||
type: 'coloring',
|
||||
content_svg: svg
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
export default { generateColoringPage };
|
||||
204
server/generators/counting.js
Normal file
204
server/generators/counting.js
Normal file
@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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 };
|
||||
9
server/generators/index.js
Normal file
9
server/generators/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Worksheet Generators Index
|
||||
* Exports all programmatic SVG generators
|
||||
*/
|
||||
|
||||
export { generateWritingWorksheet } from './writing.js';
|
||||
export { generateCountingWorksheet } from './counting.js';
|
||||
export { generateMazeWorksheet } from './maze.js';
|
||||
export { generateColoringPage } from './coloring.js';
|
||||
241
server/generators/maze.js
Normal file
241
server/generators/maze.js
Normal file
@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Maze Generator
|
||||
* Creates algorithmically generated, guaranteed-solvable mazes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a maze using recursive backtracking algorithm
|
||||
* Returns a 2D grid where true = wall, false = path
|
||||
*/
|
||||
function generateMazeGrid(rows, cols, startEdge, endEdge) {
|
||||
// Initialize grid with all walls
|
||||
const grid = Array(rows * 2 + 1).fill(null).map(() =>
|
||||
Array(cols * 2 + 1).fill(true)
|
||||
);
|
||||
|
||||
// Recursive backtracking to carve paths
|
||||
const visited = Array(rows).fill(null).map(() => Array(cols).fill(false));
|
||||
|
||||
function carve(row, col) {
|
||||
visited[row][col] = true;
|
||||
grid[row * 2 + 1][col * 2 + 1] = false; // Carve cell
|
||||
|
||||
// Get neighbors in random order
|
||||
const directions = [
|
||||
[-1, 0], [1, 0], [0, -1], [0, 1]
|
||||
].sort(() => Math.random() - 0.5);
|
||||
|
||||
for (const [dr, dc] of directions) {
|
||||
const newRow = row + dr;
|
||||
const newCol = col + dc;
|
||||
|
||||
if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols && !visited[newRow][newCol]) {
|
||||
// Carve wall between cells
|
||||
grid[row * 2 + 1 + dr][col * 2 + 1 + dc] = false;
|
||||
carve(newRow, newCol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine starting cell for carving based on entrance position
|
||||
let startRow = 0, startCol = 0;
|
||||
switch (startEdge) {
|
||||
case 'top':
|
||||
startRow = 0; startCol = 0;
|
||||
break;
|
||||
case 'bottom':
|
||||
startRow = rows - 1; startCol = rows - 1;
|
||||
break;
|
||||
case 'left':
|
||||
startRow = 0; startCol = 0;
|
||||
break;
|
||||
case 'right':
|
||||
startRow = 0; startCol = cols - 1;
|
||||
break;
|
||||
}
|
||||
|
||||
// Start carving from position near entrance
|
||||
carve(startRow, startCol);
|
||||
|
||||
// Track entrance and exit positions for marker placement
|
||||
let entranceGridPos = { row: 0, col: 0 };
|
||||
let exitGridPos = { row: 0, col: 0 };
|
||||
|
||||
// Create entrance based on startEdge - opening in the outer wall
|
||||
// Place in first third of edge for variety
|
||||
switch (startEdge) {
|
||||
case 'top':
|
||||
const topEntranceCol = 1 + Math.floor(Math.random() * Math.max(1, cols / 3)) * 2;
|
||||
grid[0][topEntranceCol] = false;
|
||||
entranceGridPos = { row: 0, col: topEntranceCol };
|
||||
break;
|
||||
case 'bottom':
|
||||
const bottomEntranceCol = 1 + Math.floor(Math.random() * Math.max(1, cols / 3)) * 2;
|
||||
grid[rows * 2][bottomEntranceCol] = false;
|
||||
entranceGridPos = { row: rows * 2, col: bottomEntranceCol };
|
||||
break;
|
||||
case 'left':
|
||||
const leftEntranceRow = 1 + Math.floor(Math.random() * Math.max(1, rows / 3)) * 2;
|
||||
grid[leftEntranceRow][0] = false;
|
||||
entranceGridPos = { row: leftEntranceRow, col: 0 };
|
||||
break;
|
||||
case 'right':
|
||||
const rightEntranceRow = 1 + Math.floor(Math.random() * Math.max(1, rows / 3)) * 2;
|
||||
grid[rightEntranceRow][cols * 2] = false;
|
||||
entranceGridPos = { row: rightEntranceRow, col: cols * 2 };
|
||||
break;
|
||||
}
|
||||
|
||||
// Create exit based on endEdge - opening in the outer wall
|
||||
// Place in last third of edge to be far from entrance
|
||||
switch (endEdge) {
|
||||
case 'top':
|
||||
const topExitCol = cols * 2 - 1 - Math.floor(Math.random() * Math.max(1, cols / 3)) * 2;
|
||||
grid[0][topExitCol] = false;
|
||||
exitGridPos = { row: 0, col: topExitCol };
|
||||
break;
|
||||
case 'bottom':
|
||||
const bottomExitCol = cols * 2 - 1 - Math.floor(Math.random() * Math.max(1, cols / 3)) * 2;
|
||||
grid[rows * 2][bottomExitCol] = false;
|
||||
exitGridPos = { row: rows * 2, col: bottomExitCol };
|
||||
break;
|
||||
case 'left':
|
||||
const leftExitRow = rows * 2 - 1 - Math.floor(Math.random() * Math.max(1, rows / 3)) * 2;
|
||||
grid[leftExitRow][0] = false;
|
||||
exitGridPos = { row: leftExitRow, col: 0 };
|
||||
break;
|
||||
case 'right':
|
||||
const rightExitRow = rows * 2 - 1 - Math.floor(Math.random() * Math.max(1, rows / 3)) * 2;
|
||||
grid[rightExitRow][cols * 2] = false;
|
||||
exitGridPos = { row: rightExitRow, col: cols * 2 };
|
||||
break;
|
||||
}
|
||||
|
||||
return { grid, entranceGridPos, exitGridPos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert maze grid to SVG
|
||||
*/
|
||||
function mazeGridToSVG(grid, startX, startY, cellSize) {
|
||||
let svg = '';
|
||||
const wallColor = '#333333';
|
||||
|
||||
for (let row = 0; row < grid.length; row++) {
|
||||
for (let col = 0; col < grid[row].length; col++) {
|
||||
if (grid[row][col]) {
|
||||
const x = startX + col * cellSize;
|
||||
const y = startY + row * cellSize;
|
||||
svg += `<rect x='${x}' y='${y}' width='${cellSize}' height='${cellSize}' fill='${wallColor}'/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate START and GOAL markers
|
||||
*/
|
||||
function generateMarkers(startX, startY, endX, endY, theme, cellSize) {
|
||||
// Smaller icon size to fit in path cells
|
||||
const iconSize = Math.min(cellSize * 0.6, 20);
|
||||
const radius = iconSize / 2;
|
||||
|
||||
return `
|
||||
<!-- START marker: Black circle in white path -->
|
||||
<circle cx="${startX}" cy="${startY}" r="${radius}" fill="#000000" stroke="#FFFFFF" stroke-width="2"/>
|
||||
<text x="${startX}" y="${startY + radius + 12}" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="#000000">START</text>
|
||||
|
||||
<!-- GOAL marker: White circle with flag in white path -->
|
||||
<circle cx="${endX}" cy="${endY}" r="${radius}" fill="#FFFFFF" stroke="#000000" stroke-width="2"/>
|
||||
<text x="${endX}" y="${endY + radius + 12}" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="#000000">GOAL</text>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete maze worksheet
|
||||
* @param {Object} params
|
||||
* @param {string} params.theme - Theme for decorations
|
||||
* @param {number} params.difficulty - 1-3 (affects grid size)
|
||||
* @param {number} params.age - Child's age
|
||||
*/
|
||||
export function generateMazeWorksheet({ theme, difficulty = 1, age }) {
|
||||
// Grid size based on difficulty and age
|
||||
let rows, cols;
|
||||
if (age <= 4 || difficulty === 1) {
|
||||
rows = 4;
|
||||
cols = 4;
|
||||
} else if (age <= 5 || difficulty === 2) {
|
||||
rows = 5;
|
||||
cols = 5;
|
||||
} else {
|
||||
rows = 6;
|
||||
cols = 6;
|
||||
}
|
||||
|
||||
// Randomly select start and end edges - favor opposite edges for better gameplay
|
||||
const edges = ['top', 'bottom', 'left', 'right'];
|
||||
const startEdge = edges[Math.floor(Math.random() * edges.length)];
|
||||
|
||||
// 80% chance to pick opposite edge, 20% chance for any different edge
|
||||
let endEdge;
|
||||
const oppositeEdges = {
|
||||
'top': 'bottom',
|
||||
'bottom': 'top',
|
||||
'left': 'right',
|
||||
'right': 'left'
|
||||
};
|
||||
|
||||
if (Math.random() < 0.8) {
|
||||
// Pick opposite edge
|
||||
endEdge = oppositeEdges[startEdge];
|
||||
} else {
|
||||
// Pick any different edge
|
||||
const remainingEdges = edges.filter(e => e !== startEdge);
|
||||
endEdge = remainingEdges[Math.floor(Math.random() * remainingEdges.length)];
|
||||
}
|
||||
|
||||
console.log(`Maze edges: START=${startEdge}, END=${endEdge}`);
|
||||
|
||||
const { grid, entranceGridPos, exitGridPos } = generateMazeGrid(rows, cols, startEdge, endEdge);
|
||||
|
||||
// Calculate positioning to fill the space
|
||||
const gridWidth = cols * 2 + 1;
|
||||
const gridHeight = rows * 2 + 1;
|
||||
const cellSize = Math.min(480 / gridWidth, 480 / gridHeight);
|
||||
const mazeWidth = gridWidth * cellSize;
|
||||
const mazeHeight = gridHeight * cellSize;
|
||||
const startX = (500 - mazeWidth) / 2;
|
||||
const startY = (500 - mazeHeight) / 2;
|
||||
|
||||
// Calculate marker positions based on actual entrance/exit grid positions
|
||||
// Add half cellSize to center in the path cell (not on the edge)
|
||||
const mazeStartX = startX + entranceGridPos.col * cellSize + cellSize / 2;
|
||||
const mazeStartY = startY + entranceGridPos.row * cellSize + cellSize / 2;
|
||||
const mazeEndX = startX + exitGridPos.col * cellSize + cellSize / 2;
|
||||
const mazeEndY = startY + exitGridPos.row * cellSize + cellSize / 2;
|
||||
|
||||
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>
|
||||
<!-- Maze background -->
|
||||
<rect x='${startX - 3}' y='${startY - 3}' width='${mazeWidth + 6}' height='${mazeHeight + 6}' fill='#FFFFFF' stroke='#333333' stroke-width='2' rx='3'/>
|
||||
|
||||
<!-- Maze walls -->
|
||||
${mazeGridToSVG(grid, startX, startY, cellSize)}
|
||||
|
||||
<!-- Start/End markers -->
|
||||
${generateMarkers(mazeStartX, mazeStartY, mazeEndX, mazeEndY, theme, cellSize)}
|
||||
</svg>`;
|
||||
|
||||
return {
|
||||
title: theme ? `${theme} Maze Adventure` : 'Maze Adventure',
|
||||
subtitle: 'Find the path from start to finish!',
|
||||
sections: [{
|
||||
id: 'find-the-way-main',
|
||||
type: 'find-the-way',
|
||||
content_svg: svg
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
export default { generateMazeWorksheet };
|
||||
142
server/generators/writing.js
Normal file
142
server/generators/writing.js
Normal file
@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Writing/Tracing Worksheet Generator
|
||||
* Generates consistent dotted letters and numbers for tracing practice
|
||||
*/
|
||||
|
||||
// Letter path definitions (school-style uppercase)
|
||||
const letterPaths = {
|
||||
'A': 'M 0 80 L 25 0 L 50 80 M 12 50 L 38 50',
|
||||
'B': 'M 0 0 L 0 80 L 35 80 Q 50 80 50 60 Q 50 40 35 40 L 0 40 M 0 0 L 35 0 Q 50 0 50 20 Q 50 40 35 40',
|
||||
'C': 'M 50 15 Q 50 0 25 0 Q 0 0 0 40 Q 0 80 25 80 Q 50 80 50 65',
|
||||
'D': 'M 0 0 L 0 80 L 25 80 Q 50 80 50 40 Q 50 0 25 0 L 0 0',
|
||||
'E': 'M 50 0 L 0 0 L 0 80 L 50 80 M 0 40 L 35 40',
|
||||
'F': 'M 50 0 L 0 0 L 0 80 M 0 40 L 35 40',
|
||||
'G': 'M 50 15 Q 50 0 25 0 Q 0 0 0 40 Q 0 80 25 80 Q 50 80 50 50 L 30 50',
|
||||
'H': 'M 0 0 L 0 80 M 50 0 L 50 80 M 0 40 L 50 40',
|
||||
'I': 'M 10 0 L 40 0 M 25 0 L 25 80 M 10 80 L 40 80',
|
||||
'J': 'M 10 0 L 40 0 M 30 0 L 30 60 Q 30 80 15 80 Q 0 80 0 65',
|
||||
'K': 'M 0 0 L 0 80 M 50 0 L 0 45 L 50 80',
|
||||
'L': 'M 0 0 L 0 80 L 50 80',
|
||||
'M': 'M 0 80 L 0 0 L 25 40 L 50 0 L 50 80',
|
||||
'N': 'M 0 80 L 0 0 L 50 80 L 50 0',
|
||||
'O': 'M 25 0 Q 0 0 0 40 Q 0 80 25 80 Q 50 80 50 40 Q 50 0 25 0',
|
||||
'P': 'M 0 80 L 0 0 L 35 0 Q 50 0 50 20 Q 50 40 35 40 L 0 40',
|
||||
'Q': 'M 25 0 Q 0 0 0 40 Q 0 80 25 80 Q 50 80 50 40 Q 50 0 25 0 M 35 60 L 55 85',
|
||||
'R': 'M 0 80 L 0 0 L 35 0 Q 50 0 50 20 Q 50 40 35 40 L 0 40 M 30 40 L 50 80',
|
||||
'S': 'M 50 15 Q 50 0 25 0 Q 0 0 0 20 Q 0 40 25 40 Q 50 40 50 60 Q 50 80 25 80 Q 0 80 0 65',
|
||||
'T': 'M 0 0 L 50 0 M 25 0 L 25 80',
|
||||
'U': 'M 0 0 L 0 60 Q 0 80 25 80 Q 50 80 50 60 L 50 0',
|
||||
'V': 'M 0 0 L 25 80 L 50 0',
|
||||
'W': 'M 0 0 L 12 80 L 25 30 L 38 80 L 50 0',
|
||||
'X': 'M 0 0 L 50 80 M 50 0 L 0 80',
|
||||
'Y': 'M 0 0 L 25 40 L 50 0 M 25 40 L 25 80',
|
||||
'Z': 'M 0 0 L 50 0 L 0 80 L 50 80'
|
||||
};
|
||||
|
||||
// Number path definitions
|
||||
const numberPaths = {
|
||||
'1': 'M 15 15 L 25 0 L 25 80 M 10 80 L 40 80',
|
||||
'2': 'M 0 20 Q 0 0 25 0 Q 50 0 50 20 Q 50 40 0 80 L 50 80',
|
||||
'3': 'M 0 10 Q 0 0 25 0 Q 50 0 50 20 Q 50 40 25 40 Q 50 40 50 60 Q 50 80 25 80 Q 0 80 0 70',
|
||||
'4': 'M 40 80 L 40 0 L 0 55 L 50 55',
|
||||
'5': 'M 50 0 L 0 0 L 0 35 Q 0 35 25 35 Q 50 35 50 57 Q 50 80 25 80 Q 0 80 0 65',
|
||||
'6': 'M 45 10 Q 45 0 25 0 Q 0 0 0 40 L 0 55 Q 0 80 25 80 Q 50 80 50 57 Q 50 35 25 35 Q 0 35 0 55',
|
||||
'7': 'M 0 0 L 50 0 L 20 80',
|
||||
'8': 'M 25 40 Q 0 40 0 20 Q 0 0 25 0 Q 50 0 50 20 Q 50 40 25 40 Q 0 40 0 60 Q 0 80 25 80 Q 50 80 50 60 Q 50 40 25 40',
|
||||
'9': 'M 5 70 Q 5 80 25 80 Q 50 80 50 40 L 50 25 Q 50 0 25 0 Q 0 0 0 23 Q 0 45 25 45 Q 50 45 50 25'
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a dotted path for a single character
|
||||
*/
|
||||
function generateDottedChar(char, x, y, scale = 1, color = '#000000') {
|
||||
const path = letterPaths[char.toUpperCase()] || numberPaths[char];
|
||||
if (!path) return '';
|
||||
|
||||
// Transform the path to the correct position and scale
|
||||
const transformedPath = path.replace(/(\d+)/g, (match) => {
|
||||
return match; // Keep original values, we'll use transform
|
||||
});
|
||||
|
||||
return `<path d='${path}' transform='translate(${x}, ${y}) scale(${scale})' fill='none' stroke='${color}' stroke-width='4' stroke-dasharray='8,8' stroke-linecap='round'/>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a row of dotted characters centered on the page with dynamic scaling
|
||||
*/
|
||||
function generateCharacterRow(text, y, baseScale = 1, color = '#000000') {
|
||||
const baseCharWidth = 50;
|
||||
const baseSpacing = 20;
|
||||
const maxWidth = 460; // Leave 20px margin on each side
|
||||
|
||||
// Calculate required width with base scale
|
||||
const requiredWidth = text.length * (baseCharWidth + baseSpacing) * baseScale - baseSpacing * baseScale;
|
||||
|
||||
// Scale down if content is too wide
|
||||
let finalScale = baseScale;
|
||||
if (requiredWidth > maxWidth) {
|
||||
finalScale = maxWidth / (text.length * (baseCharWidth + baseSpacing) - baseSpacing);
|
||||
}
|
||||
|
||||
const charWidth = baseCharWidth * finalScale;
|
||||
const spacing = baseSpacing * finalScale;
|
||||
const totalWidth = text.length * charWidth + (text.length - 1) * spacing;
|
||||
let startX = (500 - totalWidth) / 2;
|
||||
|
||||
let svg = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
svg += generateDottedChar(char, startX + i * (charWidth + spacing), y, finalScale, color);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate baseline guide
|
||||
*/
|
||||
function generateBaseline(y, width = 400) {
|
||||
const startX = (500 - width) / 2;
|
||||
return `<line x1='${startX}' y1='${y}' x2='${startX + width}' y2='${y}' stroke='#CCCCCC' stroke-width='1'/>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete writing worksheet
|
||||
* @param {Object} params
|
||||
* @param {string} params.word - Word to trace (if tracing letters)
|
||||
* @param {number[]} params.numbers - Numbers to trace (if tracing numbers)
|
||||
* @param {boolean} params.isNumbers - Whether to trace numbers
|
||||
* @param {number} params.age - Child's age (affects complexity)
|
||||
*/
|
||||
export function generateWritingWorksheet({ word, numbers, isNumbers, age }) {
|
||||
const content = isNumbers ? numbers.join('') : word.toUpperCase();
|
||||
const title = isNumbers ? 'Trace the Numbers' : `Trace: ${word.toUpperCase()}`;
|
||||
|
||||
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>
|
||||
<!-- Row 1: Full dotted characters -->
|
||||
${generateBaseline(130)}
|
||||
${generateCharacterRow(content, 50, 1, '#000000')}
|
||||
|
||||
<!-- Row 2: Lighter dotted characters -->
|
||||
${generateBaseline(280)}
|
||||
${generateCharacterRow(content, 200, 1, '#888888')}
|
||||
|
||||
<!-- Row 3: Very light guide -->
|
||||
${generateBaseline(430)}
|
||||
${generateCharacterRow(content, 350, 1, '#CCCCCC')}
|
||||
|
||||
<!-- Row 4: Empty practice line -->
|
||||
${generateBaseline(495)}
|
||||
</svg>`;
|
||||
|
||||
return {
|
||||
title: title,
|
||||
subtitle: isNumbers ? 'Practice writing your numbers!' : 'Practice writing letters!',
|
||||
sections: [{
|
||||
id: 'writing-main',
|
||||
type: 'writing',
|
||||
content_svg: svg
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
export default { generateWritingWorksheet };
|
||||
240
server/index.js
240
server/index.js
@ -5,7 +5,14 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { getPromptForActivity } from './prompts.js';
|
||||
|
||||
// Import programmatic generators
|
||||
import {
|
||||
generateWritingWorksheet,
|
||||
generateCountingWorksheet,
|
||||
generateMazeWorksheet,
|
||||
generateColoringPage
|
||||
} from './generators/index.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@ -27,7 +34,6 @@ const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || '');
|
||||
const GALLERY_FILE = path.join(__dirname, 'gallery.json');
|
||||
const MAX_GALLERY_SIZE = 6;
|
||||
|
||||
// Load gallery from file
|
||||
function loadGallery() {
|
||||
try {
|
||||
if (fs.existsSync(GALLERY_FILE)) {
|
||||
@ -40,7 +46,6 @@ function loadGallery() {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Save gallery to file
|
||||
function saveGallery(gallery) {
|
||||
try {
|
||||
fs.writeFileSync(GALLERY_FILE, JSON.stringify(gallery, null, 2));
|
||||
@ -49,31 +54,113 @@ function saveGallery(gallery) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add item to gallery (keeps only recent 6)
|
||||
function addToGallery(item) {
|
||||
const gallery = loadGallery();
|
||||
|
||||
// Add new item at the beginning
|
||||
gallery.unshift({
|
||||
...item,
|
||||
id: Date.now(),
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Keep only the most recent 6
|
||||
while (gallery.length > MAX_GALLERY_SIZE) {
|
||||
gallery.pop();
|
||||
}
|
||||
|
||||
saveGallery(gallery);
|
||||
return gallery;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM PROMPTS - Now just returns parameters
|
||||
// ============================================
|
||||
|
||||
const parameterPrompts = {
|
||||
writing: (theme, age) => {
|
||||
const randomSeed = Math.floor(Math.random() * 1000);
|
||||
const wordLength = `${age} letters`;
|
||||
const numberRange = age <= 4 ? '1-5' : '1-9';
|
||||
const numberCount = age <= 4 ? '3-4' : '4-5';
|
||||
|
||||
return `
|
||||
Create a children's tracing worksheet for theme "${theme}".
|
||||
Age: ${age} years old
|
||||
Random seed: ${randomSeed}
|
||||
|
||||
Return ONLY a JSON object with ONE of these options:
|
||||
Option A - Trace a word: {"word": "CAT", "isNumbers": false}
|
||||
Option B - Trace numbers: {"numbers": [1,2,3,4,5], "isNumbers": true}
|
||||
|
||||
Rules:
|
||||
- For words: Pick a simple ${wordLength} word related to "${theme}" (UPPERCASE). Use seed ${randomSeed} to vary your choice.
|
||||
- For numbers: Pick ${numberCount} numbers from ${numberRange}
|
||||
- Randomly choose between words (50%) or numbers (50%)
|
||||
|
||||
Return ONLY valid JSON, no explanation.
|
||||
`;
|
||||
},
|
||||
|
||||
counting: (theme, age) => {
|
||||
const randomSeed = Math.floor(Math.random() * 1000);
|
||||
return `
|
||||
Create a children's counting worksheet for theme "${theme}".
|
||||
Random seed: ${randomSeed}
|
||||
|
||||
AVAILABLE SHAPES: star, heart, circle, apple, fish, flower, butterfly, ball, moon, cloud
|
||||
|
||||
CRITICAL: You MUST return JSON with BOTH "shape" AND "quantities":
|
||||
{"shape": "star", "quantities": [3, 1, 5, 2, 7, 4, 8, 6]}
|
||||
|
||||
- "shape": Choose ONE shape that matches "${theme}". Use the random seed ${randomSeed} to vary your choice - don't always pick the same shape for this theme.
|
||||
- "quantities": Array of exactly 8 different numbers (1-${age <= 4 ? 5 : 10})
|
||||
|
||||
Return ONLY valid JSON. No explanation.
|
||||
`;
|
||||
},
|
||||
|
||||
'find-way': (theme, age) => `
|
||||
You are helping create a children's maze worksheet.
|
||||
Theme: ${theme}
|
||||
Age: ${age} years old
|
||||
|
||||
Return ONLY a JSON object:
|
||||
{ "difficulty": 1 }
|
||||
|
||||
Rules:
|
||||
- difficulty 1 = easy (4x4 grid) for age 3-4
|
||||
- difficulty 2 = medium (5x5 grid) for age 5-6
|
||||
- difficulty 3 = hard (6x6 grid) for age 7+
|
||||
|
||||
Return ONLY valid JSON, no explanation.
|
||||
`,
|
||||
|
||||
coloring: (theme, age) => {
|
||||
const randomSeed = Math.floor(Math.random() * 1000);
|
||||
const simpleIllustrations = 'star, sun, flower, ball (circle)';
|
||||
const complexIllustrations = 'unicorn, butterfly, rocket, house, cat, dog, fish';
|
||||
const ageGuidance = age <= 4
|
||||
? `Prefer SIMPLER illustrations (${simpleIllustrations}) for young children.`
|
||||
: `You can choose from ALL illustrations including complex ones (${complexIllustrations}).`;
|
||||
|
||||
return `
|
||||
You are helping create a children's coloring page.
|
||||
Theme: ${theme}
|
||||
Age: ${age} years old
|
||||
Random seed: ${randomSeed}
|
||||
|
||||
Return ONLY a JSON object:
|
||||
{ "illustration": "cat" }
|
||||
|
||||
Available illustrations: cat, dog, butterfly, fish, flower, star, rocket, sun, house, unicorn
|
||||
|
||||
${ageGuidance}
|
||||
Pick the ONE illustration that best matches the theme. Use seed ${randomSeed} to vary your choice.
|
||||
Return ONLY valid JSON, no explanation.
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
// Get recent gallery items
|
||||
app.get('/api/gallery', (req, res) => {
|
||||
try {
|
||||
const gallery = loadGallery();
|
||||
@ -86,14 +173,29 @@ app.get('/api/gallery', (req, res) => {
|
||||
|
||||
app.post('/api/surprise', async (req, res) => {
|
||||
try {
|
||||
if (!process.env.GEMINI_API_KEY) {
|
||||
return res.status(500).json({ error: 'Missing API Key' });
|
||||
}
|
||||
const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" });
|
||||
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
|
||||
|
||||
// Add timestamp to force different responses
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Always randomize theme, age, AND activity type
|
||||
const prompt = `
|
||||
Current timestamp: ${timestamp}
|
||||
|
||||
Generate ONE creative, fun, specific theme for a children's worksheet (e.g. "Space-Rex Coding", "Underwater Hamster Tea Party").
|
||||
Pick a random age between 3 and 7.
|
||||
Pick a random activity type from: coloring, numbers, writing, find-way, counting.
|
||||
Pick a RANDOM age between 3 and 7.
|
||||
Pick a RANDOM activity type with EQUAL PROBABILITY from these 4 options:
|
||||
|
||||
1. "coloring" - coloring pages
|
||||
2. "writing" - tracing letters or numbers
|
||||
3. "find-way" - maze puzzles
|
||||
4. "counting" - counting exercises
|
||||
|
||||
IMPORTANT: Select EXACTLY one of these values for "section": coloring, writing, find-way, counting
|
||||
ALL FOUR OPTIONS MUST HAVE EQUAL 25% CHANCE. Do not favor any particular activity type.
|
||||
|
||||
USE THE TIMESTAMP ${timestamp} as a random seed to ensure variation.
|
||||
DO NOT always pick the same age or activity - vary your choices each time.
|
||||
|
||||
Return JSON ONLY:
|
||||
{
|
||||
@ -105,8 +207,18 @@ app.post('/api/surprise', async (req, res) => {
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
const text = result.response.text();
|
||||
console.log('Surprise LLM response:', text);
|
||||
const jsonStr = text.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||
res.json(JSON.parse(jsonStr));
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
console.log('Surprise parsed data:', data);
|
||||
|
||||
// Ensure sections is an array
|
||||
res.json({
|
||||
theme: data.theme,
|
||||
age: data.age,
|
||||
sections: [data.section]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Surprise error:', error);
|
||||
res.status(500).json({ error: 'Failed' });
|
||||
@ -116,43 +228,84 @@ app.post('/api/surprise', async (req, res) => {
|
||||
app.post('/api/generate', async (req, res) => {
|
||||
try {
|
||||
const { theme, age, sections } = req.body;
|
||||
console.log('Received generation request:', { theme, age, sections });
|
||||
const activityType = sections[0] || 'writing';
|
||||
|
||||
console.log('Generating:', { theme, age, activityType });
|
||||
|
||||
if (!process.env.GEMINI_API_KEY) {
|
||||
console.warn('Missing GEMINI_API_KEY');
|
||||
return res.status(500).json({ error: 'Server configuration error: Missing API Key' });
|
||||
return res.status(500).json({ error: 'Missing API Key' });
|
||||
}
|
||||
|
||||
const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" });
|
||||
// Step 1: Get parameters from LLM (fast, small response)
|
||||
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
|
||||
const paramPrompt = parameterPrompts[activityType] || parameterPrompts.writing;
|
||||
|
||||
// Get the activity-specific prompt for the first section
|
||||
const activityType = sections[0] || 'coloring';
|
||||
const prompt = getPromptForActivity(activityType, theme, age);
|
||||
|
||||
console.log(`Using specialized prompt for activity: ${activityType}`);
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
const response = await result.response;
|
||||
const text = response.text();
|
||||
|
||||
// Cleanup markdown code blocks if present
|
||||
console.log('Getting parameters from LLM...');
|
||||
const result = await model.generateContent(paramPrompt(theme, age));
|
||||
const text = result.response.text();
|
||||
const jsonStr = text.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||
const data = JSON.parse(jsonStr);
|
||||
const params = JSON.parse(jsonStr);
|
||||
|
||||
if (!data.title || !data.sections || !Array.isArray(data.sections)) {
|
||||
throw new Error('Invalid generation format');
|
||||
console.log('LLM returned params:', params);
|
||||
|
||||
// Step 2: Generate worksheet using programmatic generator
|
||||
let worksheet;
|
||||
|
||||
switch (activityType) {
|
||||
case 'writing':
|
||||
worksheet = generateWritingWorksheet({
|
||||
word: params.word || 'CAT',
|
||||
numbers: params.numbers || [1, 2, 3, 4, 5],
|
||||
isNumbers: params.isNumbers || false,
|
||||
age
|
||||
});
|
||||
break;
|
||||
|
||||
case 'counting':
|
||||
worksheet = generateCountingWorksheet({
|
||||
theme,
|
||||
shape: params.shape,
|
||||
quantities: params.quantities || [2, 4, 5, 7, 3, 6, 8, 1],
|
||||
age
|
||||
});
|
||||
break;
|
||||
|
||||
case 'find-way':
|
||||
worksheet = generateMazeWorksheet({
|
||||
theme,
|
||||
difficulty: params.difficulty || 1,
|
||||
age
|
||||
});
|
||||
break;
|
||||
|
||||
case 'coloring':
|
||||
// Coloring uses direct LLM SVG generation, not programmatic
|
||||
console.log('Using LLM for full coloring SVG generation...');
|
||||
const { getPromptForActivity: getPrompt } = await import('./prompts.js');
|
||||
const coloringPrompt = getPrompt('coloring', theme, age);
|
||||
const coloringResult = await model.generateContent(coloringPrompt);
|
||||
const coloringText = coloringResult.response.text();
|
||||
const coloringJsonStr = coloringText.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||
worksheet = JSON.parse(coloringJsonStr);
|
||||
break;
|
||||
|
||||
default:
|
||||
worksheet = generateWritingWorksheet({
|
||||
word: 'FUN',
|
||||
isNumbers: false,
|
||||
age
|
||||
});
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
worksheet.age = age;
|
||||
worksheet.activityType = activityType;
|
||||
|
||||
// Save to gallery
|
||||
const galleryItem = {
|
||||
...data,
|
||||
age,
|
||||
activityType
|
||||
};
|
||||
addToGallery(galleryItem);
|
||||
console.log('Added to gallery, current size:', loadGallery().length);
|
||||
addToGallery(worksheet);
|
||||
console.log('Generated worksheet:', worksheet.title);
|
||||
|
||||
res.json(data);
|
||||
res.json(worksheet);
|
||||
} catch (error) {
|
||||
console.error('Generation error:', error);
|
||||
res.status(500).json({ error: 'Failed to generate content' });
|
||||
@ -161,4 +314,5 @@ app.post('/api/generate', async (req, res) => {
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running at http://localhost:${port}`);
|
||||
console.log('Using hybrid generation: LLM for parameters, code for SVGs');
|
||||
});
|
||||
|
||||
@ -43,37 +43,103 @@ CRITICAL: content_svg MUST be a string with single quotes in SVG attributes. Pat
|
||||
|
||||
export const activityPrompts = {
|
||||
coloring: (theme, age) => `
|
||||
You are a professional illustrator of high-quality children's coloring books. Create a BEAUTIFUL, ENGAGING coloring page.
|
||||
You are a professional SVG illustrator creating REALISTIC, RECOGNIZABLE children's coloring pages.
|
||||
|
||||
Theme: ${theme}
|
||||
Age: ${age} years old
|
||||
|
||||
YOUR MISSION: Create ONE stunning coloring page featuring the theme in a way that makes children excited to color it.
|
||||
🎯 YOUR GOAL: Create a coloring page with ONE clear subject that a 3-year-old can immediately identify as "${theme}".
|
||||
|
||||
⚠️ STRICT ACTIVITY RESTRICTION — THIS IS A COLORING PAGE ONLY:
|
||||
- DO NOT include any numbers, digits, or numerals of any kind
|
||||
- DO NOT include any letters, words, text, or alphabet characters
|
||||
- DO NOT include any counting exercises or number recognition
|
||||
- DO NOT include any mazes, paths to follow, or puzzles
|
||||
- DO NOT include any tracing lines, dotted lines, or writing practice
|
||||
- DO NOT include any educational exercises — ONLY pure illustration for coloring
|
||||
- The ONLY activity is coloring inside the outlined shapes
|
||||
🚨 ABSOLUTE SVG REQUIREMENTS (CRITICAL - NON-NEGOTIABLE):
|
||||
- EVERY single path MUST have fill="none"
|
||||
- EVERY single path MUST have stroke="black" or stroke="#000000"
|
||||
- Use stroke-width="2" or "3" for all outlines
|
||||
- NO filled shapes allowed - children need empty white areas to color
|
||||
- Example: <path d="M 100 100 L 200 100" fill="none" stroke="black" stroke-width="2"/>
|
||||
|
||||
COLORING PAGE SPECIFIC RULES:
|
||||
- Create ONE main cute subject (or 2-3 closely related elements) as the centerpiece
|
||||
- Design 8-20 large, clearly defined CLOSED areas perfect for coloring
|
||||
- Each colorable region should be satisfyingly large — kids love filling big spaces
|
||||
- Add cute details that enhance the theme: accessories, background elements, decorations
|
||||
- Characters should have friendly expressions: big round eyes, gentle smiles
|
||||
- Include a simple ground line or setting context (grass, clouds, water) if appropriate
|
||||
- Balance white space — not too crowded, not too empty
|
||||
- Make outlines bold and confident so young hands can color inside easily
|
||||
📐 STEP-BY-STEP SVG CONSTRUCTION METHOD:
|
||||
|
||||
COMPOSITION TIPS:
|
||||
- Center the main subject or use rule-of-thirds for dynamic layouts
|
||||
- Vary the sizes of colorable regions for visual interest
|
||||
- Add 2-4 small decorative elements (stars, hearts, flowers) around the main subject
|
||||
- Ensure all shapes are CLOSED and ready to be filled with color
|
||||
For ANIMALS (cat, dog, panda, unicorn, etc.):
|
||||
1. HEAD: Draw a large circle or oval for head (100-150px diameter), centered around x=250, y=200
|
||||
2. EARS: Add 2 rounded ears on top of head (circles, triangles, or ovals)
|
||||
3. EYES: 2 circles (30-40px diameter) positioned symmetrically
|
||||
4. NOSE: Small circle or triangle in center below eyes
|
||||
5. MOUTH: Simple curved line (smile) below nose
|
||||
6. BODY: Large oval or rounded rectangle below head (150-200px wide, 180-220px tall)
|
||||
7. LEGS: 4 rounded rectangles or ovals extending down from body
|
||||
8. TAIL: Curved path on one side
|
||||
9. DETAILS: Add characteristic features (whiskers, spots, horn, etc.)
|
||||
|
||||
For OBJECTS (car, rocket, house, etc.):
|
||||
1. MAIN BODY: Start with basic shape (rectangle for car/house, elongated triangle for rocket)
|
||||
2. MAJOR COMPONENTS: Add 2-4 large recognizable parts (wheels, windows, door, roof)
|
||||
3. DETAILS: Add smaller features that make it identifiable
|
||||
|
||||
For PLANTS (flower, tree, etc.):
|
||||
1. CENTER/TRUNK: Main central element
|
||||
2. PETALS/LEAVES: 5-8 symmetrical rounded shapes around center
|
||||
3. STEM: Vertical line or path
|
||||
4. BASE: Ground line or pot
|
||||
|
||||
🎨 SPECIFIC THEME EXAMPLES:
|
||||
|
||||
UNICORN:
|
||||
- Circle head (cx="250" cy="180", r="70")
|
||||
- 2 triangle ears
|
||||
- HORN: Triangle pointing up from forehead (30-40px tall)
|
||||
- 2 circle eyes
|
||||
- Small nose circle
|
||||
- Curved smile
|
||||
- Oval body below head
|
||||
- 4 rounded rectangle legs
|
||||
- Flowing curved tail on right side
|
||||
- Mane: 3-4 curved strokes on neck
|
||||
|
||||
PANDA:
|
||||
- Large circle head (r="80")
|
||||
- 2 small circle ears on top
|
||||
- 2 LARGE circle eye patches (r="25", OUTLINED not filled)
|
||||
- 2 smaller circle eyes inside patches
|
||||
- Small circle nose
|
||||
- Smile below nose
|
||||
- Large oval body
|
||||
- 4 thick rounded legs
|
||||
- Optional: bamboo stick to the side (2 parallel lines)
|
||||
|
||||
CAT:
|
||||
- Circle head (r="60")
|
||||
- 2 triangle ears pointing up
|
||||
- 2 circle eyes
|
||||
- Small triangle nose
|
||||
- Whiskers (3 lines each side)
|
||||
- Curved smile
|
||||
- Oval body
|
||||
- 4 legs
|
||||
- Curved S-shaped tail
|
||||
|
||||
FLOWER:
|
||||
- Center circle (r="40")
|
||||
- 6-8 rounded petal paths around center (use ellipses or bezier curves)
|
||||
- Straight stem line down from center
|
||||
- 2 leaf shapes on stem (ellipses at angle)
|
||||
- Simple ground line
|
||||
|
||||
🔍 QUALITY CHECKLIST - YOUR SVG MUST:
|
||||
✓ Have 8-15 large closed shapes ready for coloring
|
||||
✓ Be IMMEDIATELY recognizable as "${theme}"
|
||||
✓ Use ONLY stroke outlines (fill="none" on ALL paths)
|
||||
✓ Have bold stroke-width="2" or "3"
|
||||
✓ Be 500x500 viewBox with subject centered
|
||||
✓ Include characteristic features (horn for unicorn, eye patches for panda, etc.)
|
||||
✓ Have simple, rounded, child-friendly shapes
|
||||
✓ Look like it came from a store-bought coloring book
|
||||
|
||||
❌ AVOID:
|
||||
- Abstract or geometric art
|
||||
- Filled/colored areas
|
||||
- Tiny details (too hard for kids)
|
||||
- Complex overlapping shapes
|
||||
- Unrecognizable forms
|
||||
|
||||
${baseRules}
|
||||
${ageRules(age)}
|
||||
|
||||
@ -15,7 +15,6 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
|
||||
// Sync state when a sheet is restored
|
||||
React.useEffect(() => {
|
||||
if (generatedSheet) {
|
||||
setTheme(generatedSheet.title || '');
|
||||
if (generatedSheet.age) setAge(generatedSheet.age);
|
||||
// If sections exist, restore the first one's type as selected
|
||||
if (generatedSheet.sections && generatedSheet.sections.length > 0) {
|
||||
@ -68,24 +67,32 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
|
||||
// since we don't have a separate loading state, we can reuse isGenerating logic or add one.
|
||||
// For now, we'll just set the values when they arrive.
|
||||
|
||||
const res = await fetch('/api/surprise', { method: 'POST' });
|
||||
const res = await fetch('/api/surprise', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!res.ok) throw new Error('Surprise failed');
|
||||
|
||||
const idea = await res.json();
|
||||
|
||||
console.log('Surprise response from backend:', idea);
|
||||
|
||||
// Validate section ID exists in our map, else fallback
|
||||
const validSection = sections.find(s => s.id === idea.section) ? idea.section : 'coloring';
|
||||
const validSections = ['coloring', 'writing', 'find-way', 'counting'];
|
||||
const validSection = validSections.includes(idea.sections[0]) ? idea.sections[0] : 'coloring';
|
||||
|
||||
console.log('Section from backend:', idea.sections[0], '-> Valid section:', validSection);
|
||||
|
||||
setTheme(idea.theme);
|
||||
setAge(idea.age);
|
||||
setSelectedSection(validSection);
|
||||
|
||||
// Auto-generate for surprise
|
||||
// Auto-generate for surprise - use validSection directly, not state
|
||||
if (onGenerate) {
|
||||
onGenerate({
|
||||
theme: idea.theme,
|
||||
age: idea.age,
|
||||
sections: [validSection]
|
||||
sections: [validSection] // Use validSection, not selectedSection from state
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@ -263,7 +270,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
|
||||
<div className="relative hidden lg:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-primary-100 to-indigo-50 rounded-full blur-3xl opacity-60 transform -translate-y-12 translate-x-12" />
|
||||
|
||||
<div id="sheet-wrapper" className="relative bg-white rounded-[2rem] shadow-2xl shadow-slate-200/50 border-8 border-white p-4 rotate-3 hover:rotate-0 transition-transform duration-500 ease-out z-10">
|
||||
<div id="sheet-wrapper" className="relative bg-white rounded-[2rem] shadow-2xl shadow-slate-200/50 border-8 border-white p-4 z-10">
|
||||
<div id="sheet-preview" className={`aspect-[1/1.4] bg-white rounded-xl overflow-hidden relative border flex flex-col ${generatedSheet?.sections?.length === 1 ? 'p-4' : 'p-6'}`}>
|
||||
<AnimatePresence>
|
||||
{isGenerating && (
|
||||
@ -280,12 +287,12 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
|
||||
className="h-full bg-gradient-to-r from-violet-500 via-primary-500 to-indigo-500 rounded-full"
|
||||
initial={{ width: "0%" }}
|
||||
animate={{ width: "95%" }}
|
||||
transition={{ duration: 30, ease: "easeOut" }}
|
||||
transition={{ duration: selectedSection === 'coloring' ? 15 : 5, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-slate-400">
|
||||
<span>Generating...</span>
|
||||
<span>~30 seconds</span>
|
||||
<span>~{selectedSection === 'coloring' ? '15' : '5'} seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -308,7 +315,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
|
||||
key="msg1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 1, 0] }}
|
||||
transition={{ duration: 7, times: [0, 0.1, 0.9, 1], delay: 0 }}
|
||||
transition={{ duration: 2, times: [0, 0.1, 0.9, 1], delay: 0 }}
|
||||
className="text-sm text-slate-500 absolute left-0 right-0 text-center"
|
||||
>
|
||||
✨ Adding some magic...
|
||||
@ -317,7 +324,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
|
||||
key="msg2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 1, 0] }}
|
||||
transition={{ duration: 7, times: [0, 0.1, 0.9, 1], delay: 7 }}
|
||||
transition={{ duration: 2, times: [0, 0.1, 0.9, 1], delay: 2 }}
|
||||
className="text-sm text-slate-500 absolute left-0 right-0 text-center"
|
||||
>
|
||||
🎨 Drawing cute illustrations...
|
||||
@ -326,7 +333,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
|
||||
key="msg3"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 1, 0] }}
|
||||
transition={{ duration: 7, times: [0, 0.1, 0.9, 1], delay: 14 }}
|
||||
transition={{ duration: 2, times: [0, 0.1, 0.9, 1], delay: 4 }}
|
||||
className="text-sm text-slate-500 absolute left-0 right-0 text-center"
|
||||
>
|
||||
📝 Almost ready...
|
||||
@ -335,7 +342,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
|
||||
key="msg4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 21 }}
|
||||
transition={{ delay: 4 }}
|
||||
className="text-sm text-slate-500 absolute left-0 right-0 text-center"
|
||||
>
|
||||
🚀 Finishing up...
|
||||
@ -347,7 +354,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
|
||||
|
||||
<div className="flex-1 flex flex-col items-center text-center bg-white h-full">
|
||||
<h3 className="font-outfit font-bold text-2xl mb-1 text-slate-800">{previewTheme}</h3>
|
||||
<p className="text-slate-500 uppercase tracking-widest text-[10px] font-bold mb-2">{previewSubtitle}</p>
|
||||
<p className="text-slate-500 uppercase tracking-widest text-[10px] font-bold mb-2 text-center w-full">{previewSubtitle}</p>
|
||||
|
||||
{generatedSheet ? (
|
||||
<div className={`w-full flex-1 flex flex-col gap-2 ${generatedSheet.sections?.length === 1 ? 'justify-center' : ''}`}>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user