Compare commits
70 Commits
abacus-rea
...
abacus-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d0c341a8 | ||
|
|
ece2ffb40f | ||
|
|
a80431608d | ||
|
|
9ba1824226 | ||
|
|
17970f6e9a | ||
|
|
770cfc3aca | ||
|
|
2f086ebb82 | ||
|
|
19b9d7a74f | ||
|
|
bf0a0bf01b | ||
|
|
379698fea3 | ||
|
|
ffae9c1bdb | ||
|
|
16ccaf2c8b | ||
|
|
23ae1b0c6f | ||
|
|
e852afddc5 | ||
|
|
645140648a | ||
|
|
be7d4c4713 | ||
|
|
88c0baaad9 | ||
|
|
20ab40b2df | ||
|
|
06f68cc74c | ||
|
|
599a758471 | ||
|
|
e5ba772fde | ||
|
|
293390ae35 | ||
|
|
f880cbe4bf | ||
|
|
14a5de0dfa | ||
|
|
867c7ee172 | ||
|
|
3a20b46185 | ||
|
|
4f93c7d996 | ||
|
|
5956217979 | ||
|
|
00a8bc3e5e | ||
|
|
42016acec1 | ||
|
|
9f1715f085 | ||
|
|
33eb90e316 | ||
|
|
f9cbee8fcd | ||
|
|
8aaec90e11 | ||
|
|
448f93c1e2 | ||
|
|
8ce8038bae | ||
|
|
c93409fc8c | ||
|
|
b277a89415 | ||
|
|
203f110b65 | ||
|
|
98cd019d4a | ||
|
|
858a1b4976 | ||
|
|
08c6a419e2 | ||
|
|
a9664bdcb4 | ||
|
|
329e623212 | ||
|
|
8439727b15 | ||
|
|
7ce1287525 | ||
|
|
903dea2584 | ||
|
|
72a4c2b80c | ||
|
|
ed69f6b917 | ||
|
|
9d322301ef | ||
|
|
0641eb719e | ||
|
|
3588d5acde | ||
|
|
74f2d97434 | ||
|
|
4f9dc4666d | ||
|
|
3b8e864cfa | ||
|
|
7418adb959 | ||
|
|
7228bbc2eb | ||
|
|
ff1d60a233 | ||
|
|
9f7f001d74 | ||
|
|
35d8734a3a | ||
|
|
6a1cec06a7 | ||
|
|
ce4e44d630 | ||
|
|
35bbcecb9e | ||
|
|
cf1f950c7c | ||
|
|
de038d2afc | ||
|
|
e65541c100 | ||
|
|
f4ec0689ff | ||
|
|
af0552ccd9 | ||
|
|
90421cfc38 | ||
|
|
2b06aae394 |
3252
CHANGELOG.md
3252
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,7 @@ RUN ARCH=$(uname -m) && \
|
||||
else \
|
||||
echo "Unsupported architecture: $ARCH" && exit 1; \
|
||||
fi && \
|
||||
TYPST_VERSION="v0.11.1" && \
|
||||
TYPST_VERSION="v0.13.0" && \
|
||||
wget -q "https://github.com/typst/typst/releases/download/${TYPST_VERSION}/typst-${TYPST_ARCH}.tar.xz" && \
|
||||
tar -xf "typst-${TYPST_ARCH}.tar.xz" && \
|
||||
mv "typst-${TYPST_ARCH}/typst" /usr/local/bin/typst && \
|
||||
@@ -156,6 +156,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/packages/core ./packages/core
|
||||
# Copy templates package (needed for Typst templates)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/templates ./packages/templates
|
||||
|
||||
# Copy abacus-react package (needed for calendar generation scripts)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/abacus-react ./packages/abacus-react
|
||||
|
||||
# Install Python dependencies for flashcard generation
|
||||
RUN pip3 install --no-cache-dir --break-system-packages -r packages/core/requirements.txt
|
||||
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
# Claude Code Instructions for apps/web
|
||||
|
||||
## CRITICAL: Production Dependencies
|
||||
|
||||
**NEVER add TypeScript execution tools to production dependencies.**
|
||||
|
||||
### Forbidden Production Dependencies
|
||||
|
||||
The following packages must ONLY be in `devDependencies`, NEVER in `dependencies`:
|
||||
|
||||
- ❌ `tsx` - TypeScript execution (only for scripts during development)
|
||||
- ❌ `ts-node` - TypeScript execution
|
||||
- ❌ Any TypeScript compiler/executor that runs .ts/.tsx files at runtime
|
||||
|
||||
### Why This Matters
|
||||
|
||||
1. **Docker Image Size**: These tools add 50-100MB+ to production images
|
||||
2. **Security**: Running TypeScript at runtime is a security risk
|
||||
3. **Performance**: Production should run compiled JavaScript, not interpret TypeScript
|
||||
4. **Architecture**: If you need TypeScript at runtime, the code is in the wrong place
|
||||
|
||||
### What To Do Instead
|
||||
|
||||
**❌ WRONG - Adding tsx to dependencies to run .ts/.tsx at runtime:**
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"tsx": "^4.20.5" // NEVER DO THIS
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**✅ CORRECT - Move code to proper location:**
|
||||
|
||||
1. **For Next.js API routes**: Move files to `src/` so Next.js bundles them during build
|
||||
- Example: `scripts/generateCalendar.tsx` → `src/utils/calendar/generateCalendar.tsx`
|
||||
- Next.js will compile and bundle these during `npm run build`
|
||||
|
||||
2. **For standalone scripts**: Keep in `scripts/` and use `tsx` from devDependencies
|
||||
- Only run during development/build, never at runtime
|
||||
- Scripts can use `tsx` because it's available during build
|
||||
|
||||
3. **For server-side TypeScript**: Compile to JavaScript during build
|
||||
- Use `tsc` to compile `src/` to `dist/`
|
||||
- Production runs the compiled JavaScript from `dist/`
|
||||
|
||||
### Historical Context
|
||||
|
||||
**We've made this mistake TWICE:**
|
||||
|
||||
1. **First time (commit ffae9c1b)**: Added tsx to dependencies for calendar generation scripts
|
||||
- **Fix**: Moved scripts to `src/utils/calendar/` so Next.js bundles them
|
||||
|
||||
2. **Second time (would have happened again)**: Almost added tsx again for same reason
|
||||
- **Learning**: If you're tempted to add tsx to dependencies, the architecture is wrong
|
||||
|
||||
### Red Flags
|
||||
|
||||
If you find yourself thinking:
|
||||
- "I need to add tsx to dependencies to run this .ts file in production"
|
||||
- "This script needs TypeScript at runtime"
|
||||
- "Production can't import this .tsx file"
|
||||
|
||||
**STOP.** The code is in the wrong place. Move it to `src/` for bundling.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Before modifying `package.json` dependencies:
|
||||
1. Check if any TypeScript execution tools are being added
|
||||
2. Ask yourself: "Could this code be in `src/` instead?"
|
||||
3. If unsure, ask the user before proceeding
|
||||
|
||||
## MANDATORY: Quality Checks for ALL Work
|
||||
|
||||
**BEFORE declaring ANY work complete, fixed, or working**, you MUST run and pass these checks:
|
||||
|
||||
1
apps/web/README.md
Normal file
1
apps/web/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Test deployment - Mon Nov 3 16:31:57 CST 2025
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
|
||||
"build": "node scripts/generate-build-info.js && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
|
||||
"build": "node scripts/generate-build-info.js && npx @pandacss/dev && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
|
||||
"start": "NODE_ENV=production node server.js",
|
||||
"lint": "npx @biomejs/biome lint . && npx eslint .",
|
||||
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Generate a simple abacus SVG (no customization for now - just get it working)
|
||||
* Usage: npx tsx scripts/generateCalendarAbacus.tsx <value> <columns>
|
||||
* Example: npx tsx scripts/generateCalendarAbacus.tsx 15 2
|
||||
*
|
||||
* Pattern copied directly from working generateDayIcon.tsx
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
const value = parseInt(process.argv[2], 10)
|
||||
const columns = parseInt(process.argv[3], 10)
|
||||
|
||||
if (isNaN(value) || isNaN(columns)) {
|
||||
console.error('Usage: npx tsx scripts/generateCalendarAbacus.tsx <value> <columns>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Use exact same pattern as generateDayIcon - inline customStyles
|
||||
const abacusMarkup = renderToStaticMarkup(
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
scaleFactor={1}
|
||||
animated={false}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
/>
|
||||
)
|
||||
|
||||
process.stdout.write(abacusMarkup)
|
||||
@@ -1,10 +1,12 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFileSync, readFileSync, mkdirSync, rmSync } from 'fs'
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
import { generateMonthlyTypst, generateDailyTypst, getDaysInMonth } from '../utils/typstGenerator'
|
||||
import type { AbacusConfig } from '@soroban/abacus-react'
|
||||
import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite'
|
||||
import { generateAbacusElement } from '@/utils/calendar/generateCalendarAbacus'
|
||||
|
||||
interface CalendarRequest {
|
||||
month: number
|
||||
@@ -18,6 +20,9 @@ export async function POST(request: NextRequest) {
|
||||
let tempDir: string | null = null
|
||||
|
||||
try {
|
||||
// Dynamic import to avoid Next.js bundler issues with react-dom/server
|
||||
const { renderToStaticMarkup } = await import('react-dom/server')
|
||||
|
||||
const body: CalendarRequest = await request.json()
|
||||
const { month, year, format, paperSize, abacusConfig } = body
|
||||
|
||||
@@ -26,58 +31,67 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create temp directory
|
||||
// Create temp directory for SVG files
|
||||
tempDir = join(tmpdir(), `calendar-${Date.now()}-${Math.random()}`)
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
|
||||
// Generate SVGs using script (avoids Next.js react-dom/server restriction)
|
||||
// Generate and write SVG files
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const maxDay = format === 'daily' ? daysInMonth : 31 // For monthly, pre-generate all
|
||||
const scriptPath = join(process.cwd(), 'scripts', 'generateCalendarAbacus.tsx')
|
||||
let typstContent: string
|
||||
|
||||
// Generate day SVGs (1 to maxDay)
|
||||
for (let day = 1; day <= maxDay; day++) {
|
||||
const svg = execSync(`npx tsx "${scriptPath}" ${day} 2`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd(),
|
||||
if (format === 'monthly') {
|
||||
// Generate single composite SVG for monthly calendar
|
||||
const calendarSvg = generateCalendarComposite({
|
||||
month,
|
||||
year,
|
||||
renderToString: renderToStaticMarkup
|
||||
})
|
||||
if (!calendarSvg || calendarSvg.trim().length === 0) {
|
||||
throw new Error('Generated empty composite calendar SVG')
|
||||
}
|
||||
writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg)
|
||||
|
||||
// Generate Typst document
|
||||
typstContent = generateMonthlyTypst({
|
||||
month,
|
||||
year,
|
||||
paperSize,
|
||||
daysInMonth,
|
||||
})
|
||||
} else {
|
||||
// Daily format: generate individual SVGs for each day
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const svg = renderToStaticMarkup(generateAbacusElement(day, 2))
|
||||
if (!svg || svg.trim().length === 0) {
|
||||
throw new Error(`Generated empty SVG for day ${day}`)
|
||||
}
|
||||
writeFileSync(join(tempDir, `day-${day}.svg`), svg)
|
||||
}
|
||||
|
||||
// Generate year SVG
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
const yearSvg = renderToStaticMarkup(generateAbacusElement(year, yearColumns))
|
||||
if (!yearSvg || yearSvg.trim().length === 0) {
|
||||
throw new Error(`Generated empty SVG for year ${year}`)
|
||||
}
|
||||
writeFileSync(join(tempDir, 'year.svg'), yearSvg)
|
||||
|
||||
// Generate Typst document
|
||||
typstContent = generateDailyTypst({
|
||||
month,
|
||||
year,
|
||||
paperSize,
|
||||
daysInMonth,
|
||||
})
|
||||
writeFileSync(join(tempDir, `day-${day}.svg`), svg)
|
||||
}
|
||||
|
||||
// Generate year SVG
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
const yearSvg = execSync(`npx tsx "${scriptPath}" ${year} ${yearColumns}`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
writeFileSync(join(tempDir, 'year.svg'), yearSvg)
|
||||
|
||||
// Generate Typst document
|
||||
const typstContent =
|
||||
format === 'monthly'
|
||||
? generateMonthlyTypst({
|
||||
month,
|
||||
year,
|
||||
paperSize,
|
||||
tempDir,
|
||||
daysInMonth,
|
||||
})
|
||||
: generateDailyTypst({
|
||||
month,
|
||||
year,
|
||||
paperSize,
|
||||
tempDir,
|
||||
daysInMonth,
|
||||
})
|
||||
|
||||
const typstPath = join(tempDir, 'calendar.typ')
|
||||
writeFileSync(typstPath, typstContent)
|
||||
|
||||
// Compile with Typst
|
||||
const pdfPath = join(tempDir, 'calendar.pdf')
|
||||
// Compile with Typst: stdin for .typ content, stdout for PDF output
|
||||
let pdfBuffer: Buffer
|
||||
try {
|
||||
execSync(`typst compile "${typstPath}" "${pdfPath}"`, {
|
||||
stdio: 'pipe',
|
||||
pdfBuffer = execSync('typst compile --format pdf - -', {
|
||||
input: typstContent,
|
||||
cwd: tempDir, // Run in temp dir so relative paths work
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB limit for large calendars
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Typst compilation error:', error)
|
||||
@@ -87,18 +101,14 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Read and return PDF
|
||||
const pdfBuffer = readFileSync(pdfPath)
|
||||
|
||||
// Clean up temp directory
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = null
|
||||
|
||||
return new NextResponse(pdfBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="calendar-${year}-${String(month).padStart(2, '0')}.pdf"`,
|
||||
},
|
||||
// Return JSON with PDF
|
||||
return NextResponse.json({
|
||||
pdf: pdfBuffer.toString('base64'),
|
||||
filename: `calendar-${year}-${String(month).padStart(2, '0')}.pdf`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar:', error)
|
||||
@@ -112,6 +122,17 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to generate calendar' }, { status: 500 })
|
||||
// Surface the actual error for debugging
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorStack = error instanceof Error ? error.stack : undefined
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to generate calendar',
|
||||
message: errorMessage,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: errorStack })
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
97
apps/web/src/app/api/create/calendar/preview/route.ts
Normal file
97
apps/web/src/app/api/create/calendar/preview/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
import { generateMonthlyTypst, getDaysInMonth } from '../utils/typstGenerator'
|
||||
import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite'
|
||||
|
||||
interface PreviewRequest {
|
||||
month: number
|
||||
year: number
|
||||
format: 'monthly' | 'daily'
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let tempDir: string | null = null
|
||||
|
||||
try {
|
||||
const body: PreviewRequest = await request.json()
|
||||
const { month, year, format } = body
|
||||
|
||||
// Validate inputs
|
||||
if (!month || month < 1 || month > 12 || !year || year < 1 || year > 9999) {
|
||||
return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Only generate preview for monthly format
|
||||
if (format !== 'monthly') {
|
||||
return NextResponse.json({ svg: null })
|
||||
}
|
||||
|
||||
// Dynamic import to avoid Next.js bundler issues
|
||||
const { renderToStaticMarkup } = await import('react-dom/server')
|
||||
|
||||
// Create temp directory for SVG file
|
||||
tempDir = join(tmpdir(), `calendar-preview-${Date.now()}-${Math.random()}`)
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
|
||||
// Generate and write composite SVG
|
||||
const calendarSvg = generateCalendarComposite({
|
||||
month,
|
||||
year,
|
||||
renderToString: renderToStaticMarkup
|
||||
})
|
||||
writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg)
|
||||
|
||||
// Generate Typst document content
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const typstContent = generateMonthlyTypst({
|
||||
month,
|
||||
year,
|
||||
paperSize: 'us-letter',
|
||||
daysInMonth,
|
||||
})
|
||||
|
||||
// Compile with Typst: stdin for .typ content, stdout for SVG output
|
||||
let svg: string
|
||||
try {
|
||||
svg = execSync('typst compile --format svg - -', {
|
||||
input: typstContent,
|
||||
encoding: 'utf8',
|
||||
cwd: tempDir, // Run in temp dir so relative paths work
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Typst compilation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to compile preview. Is Typst installed?' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = null
|
||||
|
||||
return NextResponse.json({ svg })
|
||||
} catch (error) {
|
||||
console.error('Error generating preview:', error)
|
||||
|
||||
// Clean up temp directory if it exists
|
||||
if (tempDir) {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to clean up temp directory:', cleanupError)
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate preview', message: errorMessage },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
interface TypstConfig {
|
||||
interface TypstMonthlyConfig {
|
||||
month: number
|
||||
year: number
|
||||
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
|
||||
daysInMonth: number
|
||||
}
|
||||
|
||||
interface TypstDailyConfig {
|
||||
month: number
|
||||
year: number
|
||||
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
|
||||
tempDir: string
|
||||
daysInMonth: number
|
||||
}
|
||||
|
||||
@@ -44,73 +50,38 @@ interface PaperConfig {
|
||||
|
||||
function getPaperConfig(size: string): PaperConfig {
|
||||
const configs: Record<PaperSize, PaperConfig> = {
|
||||
'us-letter': { typstName: 'us-letter', marginX: '0.75in', marginY: '1in' },
|
||||
a4: { typstName: 'a4', marginX: '2cm', marginY: '2.5cm' },
|
||||
a3: { typstName: 'a3', marginX: '2cm', marginY: '2.5cm' },
|
||||
tabloid: { typstName: 'us-tabloid', marginX: '1in', marginY: '1in' },
|
||||
// Tight margins to maximize space for calendar grid
|
||||
'us-letter': { typstName: 'us-letter', marginX: '0.5in', marginY: '0.5in' },
|
||||
// A4 is slightly taller/narrower than US Letter - adjust margins proportionally
|
||||
a4: { typstName: 'a4', marginX: '1.3cm', marginY: '1.3cm' },
|
||||
// A3 is 2x area of A4 - can use same margins but will scale content larger
|
||||
a3: { typstName: 'a3', marginX: '1.5cm', marginY: '1.5cm' },
|
||||
// Tabloid (11" × 17") is larger - can use more margin
|
||||
tabloid: { typstName: 'us-tabloid', marginX: '0.75in', marginY: '0.75in' },
|
||||
}
|
||||
return configs[size as PaperSize] || configs['us-letter']
|
||||
}
|
||||
|
||||
export function generateMonthlyTypst(config: TypstConfig): string {
|
||||
const { month, year, paperSize, tempDir, daysInMonth } = config
|
||||
export function generateMonthlyTypst(config: TypstMonthlyConfig): string {
|
||||
const { paperSize } = config
|
||||
const paperConfig = getPaperConfig(paperSize)
|
||||
const firstDayOfWeek = getFirstDayOfWeek(year, month)
|
||||
const monthName = MONTH_NAMES[month - 1]
|
||||
|
||||
// Generate calendar cells with proper empty cells before the first day
|
||||
let cells = ''
|
||||
|
||||
// Empty cells before first day
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
cells += ' [],\n'
|
||||
}
|
||||
|
||||
// Day cells
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
cells += ` [#image("${tempDir}/day-${day}.svg", width: 90%)],\n`
|
||||
}
|
||||
|
||||
// Single-page design: use one composite SVG that scales to fit
|
||||
// This prevents overflow - Typst will scale the image to fit available space
|
||||
return `#set page(
|
||||
paper: "${paperConfig.typstName}",
|
||||
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
|
||||
)
|
||||
|
||||
#set text(font: "Arial", size: 12pt)
|
||||
|
||||
// Title
|
||||
#align(center)[
|
||||
#text(size: 24pt, weight: "bold")[${monthName} ${year}]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
// Year as abacus
|
||||
#image("${tempDir}/year.svg", width: 35%)
|
||||
// Composite calendar SVG - scales to fit page (prevents multi-page overflow)
|
||||
#align(center + horizon)[
|
||||
#image("calendar.svg", width: 100%, fit: "contain")
|
||||
]
|
||||
|
||||
#v(1.5em)
|
||||
|
||||
// Calendar grid
|
||||
#grid(
|
||||
columns: (1fr, 1fr, 1fr, 1fr, 1fr, 1fr, 1fr),
|
||||
gutter: 4pt,
|
||||
|
||||
// Weekday headers
|
||||
[#align(center)[*Sun*]],
|
||||
[#align(center)[*Mon*]],
|
||||
[#align(center)[*Tue*]],
|
||||
[#align(center)[*Wed*]],
|
||||
[#align(center)[*Thu*]],
|
||||
[#align(center)[*Fri*]],
|
||||
[#align(center)[*Sat*]],
|
||||
|
||||
// Calendar days
|
||||
${cells})
|
||||
`
|
||||
}
|
||||
|
||||
export function generateDailyTypst(config: TypstConfig): string {
|
||||
const { month, year, paperSize, tempDir, daysInMonth } = config
|
||||
export function generateDailyTypst(config: TypstDailyConfig): string {
|
||||
const { month, year, paperSize, daysInMonth } = config
|
||||
const paperConfig = getPaperConfig(paperSize)
|
||||
const monthName = MONTH_NAMES[month - 1]
|
||||
|
||||
@@ -127,14 +98,14 @@ export function generateDailyTypst(config: TypstConfig): string {
|
||||
// Header: Year
|
||||
#align(center)[
|
||||
#v(1em)
|
||||
#image("${tempDir}/year.svg", width: 30%)
|
||||
#image("year.svg", width: 30%)
|
||||
]
|
||||
|
||||
#v(2em)
|
||||
|
||||
// Main: Day number as large abacus
|
||||
#align(center + horizon)[
|
||||
#image("${tempDir}/day-${day}.svg", width: 50%)
|
||||
#image("day-${day}.svg", width: 50%)
|
||||
]
|
||||
|
||||
#v(2em)
|
||||
|
||||
@@ -23,13 +23,11 @@ export function AbacusTarget({ number }: AbacusTargetProps) {
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns={1}
|
||||
compact={true}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.72}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -22,6 +22,8 @@ import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*
|
||||
* Test: Verifying compose-updater automatic deployment cycle
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { rithmomachiaGame } from '@/arcade-games/rithmomachia'
|
||||
|
||||
// Force dynamic rendering to avoid build-time initialization errors
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const { Provider, GameComponent } = rithmomachiaGame
|
||||
|
||||
export default function RithmomachiaPage() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import Link from 'next/link'
|
||||
import { AbacusDisplayDropdown } from '@/components/AbacusDisplayDropdown'
|
||||
|
||||
interface CalendarConfigPanelProps {
|
||||
month: number
|
||||
@@ -243,7 +243,7 @@ export function CalendarConfigPanel({
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
{/* Abacus Styling Info */}
|
||||
{/* Abacus Styling */}
|
||||
<div
|
||||
data-section="styling-info"
|
||||
className={css({
|
||||
@@ -259,7 +259,7 @@ export function CalendarConfigPanel({
|
||||
color: 'gray.300',
|
||||
})}
|
||||
>
|
||||
Using your saved abacus style:
|
||||
Calendar abacus style preview:
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
@@ -273,22 +273,17 @@ export function CalendarConfigPanel({
|
||||
columns={2}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.5}
|
||||
showNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/create"
|
||||
data-action="edit-style"
|
||||
<div
|
||||
className={css({
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.875rem',
|
||||
color: 'yellow.400',
|
||||
textDecoration: 'underline',
|
||||
_hover: { color: 'yellow.300' },
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
Edit your abacus style →
|
||||
</Link>
|
||||
<AbacusDisplayDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
|
||||
interface CalendarPreviewProps {
|
||||
month: number
|
||||
year: number
|
||||
format: 'monthly' | 'daily'
|
||||
previewSvg: string | null
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
]
|
||||
async function fetchTypstPreview(month: number, year: number, format: string): Promise<string | null> {
|
||||
const response = await fetch('/api/create/calendar/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ month, year, format }),
|
||||
})
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch preview')
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate()
|
||||
const data = await response.json()
|
||||
return data.svg
|
||||
}
|
||||
|
||||
function getFirstDayOfWeek(year: number, month: number): number {
|
||||
return new Date(year, month - 1, 1).getDay()
|
||||
}
|
||||
export function CalendarPreview({ month, year, format, previewSvg }: CalendarPreviewProps) {
|
||||
// Use React Query to fetch Typst-generated preview (client-side only)
|
||||
const { data: typstPreviewSvg, isLoading } = useQuery({
|
||||
queryKey: ['calendar-typst-preview', month, year, format],
|
||||
queryFn: () => fetchTypstPreview(month, year, format),
|
||||
enabled: typeof window !== 'undefined' && format === 'monthly', // Only run on client and for monthly format
|
||||
})
|
||||
|
||||
export function CalendarPreview({ month, year, format }: CalendarPreviewProps) {
|
||||
const abacusConfig = useAbacusConfig()
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const firstDayOfWeek = getFirstDayOfWeek(year, month)
|
||||
// Use generated PDF SVG if available, otherwise use Typst live preview
|
||||
const displaySvg = previewSvg || typstPreviewSvg
|
||||
|
||||
if (format === 'daily') {
|
||||
// Show loading state while fetching preview
|
||||
if (isLoading || (!displaySvg && format === 'monthly')) {
|
||||
return (
|
||||
<div
|
||||
data-component="calendar-preview"
|
||||
@@ -57,108 +55,42 @@ export function CalendarPreview({ month, year, format }: CalendarPreviewProps) {
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
color: 'gray.300',
|
||||
marginBottom: '1.5rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Daily format preview
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
padding: '3rem 2rem',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Year at top */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={year}
|
||||
columns={4}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Large day number */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={1}
|
||||
columns={2}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date text */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{new Date(year, month - 1, 1).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{MONTHS[month - 1]} 1, {year}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: 'gray.400',
|
||||
marginTop: '1rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Example of first day (1 page per day for all {daysInMonth} days)
|
||||
Loading preview...
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Monthly format
|
||||
const calendarDays: (number | null)[] = []
|
||||
|
||||
// Add empty cells for days before the first day of month
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
calendarDays.push(null)
|
||||
}
|
||||
|
||||
// Add actual days
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
calendarDays.push(day)
|
||||
if (!displaySvg) {
|
||||
return (
|
||||
<div
|
||||
data-component="calendar-preview"
|
||||
className={css({
|
||||
bg: 'gray.800',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '600px',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
color: 'gray.400',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{format === 'daily' ? 'Daily format - preview after generation' : 'No preview available'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -168,99 +100,32 @@ export function CalendarPreview({ month, year, format }: CalendarPreviewProps) {
|
||||
bg: 'gray.800',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
{MONTHS[month - 1]} {year}
|
||||
</h2>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={year}
|
||||
columns={4}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{/* Weekday headers */}
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
padding: '0.5rem',
|
||||
color: 'yellow.400',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Calendar days */}
|
||||
{calendarDays.map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={css({
|
||||
aspectRatio: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: day ? 'gray.700' : 'transparent',
|
||||
borderRadius: '6px',
|
||||
padding: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{day && (
|
||||
<AbacusReact
|
||||
value={day}
|
||||
columns={2}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.35}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: 'gray.400',
|
||||
marginTop: '1.5rem',
|
||||
fontSize: '1.125rem',
|
||||
color: 'yellow.400',
|
||||
marginBottom: '1rem',
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
Preview of monthly calendar layout (actual PDF will be optimized for printing)
|
||||
{previewSvg ? 'Generated PDF' : 'Live Preview'}
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
dangerouslySetInnerHTML={{ __html: displaySvg }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function CalendarCreatorPage() {
|
||||
const [format, setFormat] = useState<'monthly' | 'daily'>('monthly')
|
||||
const [paperSize, setPaperSize] = useState<'us-letter' | 'a4' | 'a3' | 'tabloid'>('us-letter')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [previewSvg, setPreviewSvg] = useState<string | null>(null)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsGenerating(true)
|
||||
@@ -34,21 +35,26 @@ export default function CalendarCreatorPage() {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate calendar')
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || 'Failed to generate calendar')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const data = await response.json()
|
||||
|
||||
// Convert base64 PDF to blob and trigger download
|
||||
const pdfBytes = Uint8Array.from(atob(data.pdf), c => c.charCodeAt(0))
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `calendar-${year}-${String(month).padStart(2, '0')}.pdf`
|
||||
a.download = data.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar:', error)
|
||||
alert('Failed to generate calendar. Please try again.')
|
||||
alert(`Failed to generate calendar: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
@@ -122,7 +128,7 @@ export default function CalendarCreatorPage() {
|
||||
/>
|
||||
|
||||
{/* Preview */}
|
||||
<CalendarPreview month={month} year={year} format={format} />
|
||||
<CalendarPreview month={month} year={year} format={format} previewSvg={previewSvg} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
66
apps/web/src/app/test-static-abacus/page.tsx
Normal file
66
apps/web/src/app/test-static-abacus/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Test page for AbacusStatic - Server Component
|
||||
* This demonstrates that AbacusStatic works without "use client"
|
||||
*
|
||||
* Note: Uses /static import path to avoid client-side code
|
||||
*/
|
||||
|
||||
import { AbacusStatic } from '@soroban/abacus-react/static'
|
||||
|
||||
export default function TestStaticAbacusPage() {
|
||||
const numbers = [1, 2, 3, 4, 5, 10, 25, 50, 100, 123, 456, 789]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1 style={{ marginBottom: '10px' }}>AbacusStatic Test (Server Component)</h1>
|
||||
<p style={{ color: '#64748b', marginBottom: '30px' }}>
|
||||
This page is a React Server Component - no "use client" directive!
|
||||
All abacus displays below are rendered on the server with zero client-side JavaScript.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
{numbers.map((num) => (
|
||||
<div
|
||||
key={num}
|
||||
style={{
|
||||
padding: '20px',
|
||||
background: 'white',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<AbacusStatic
|
||||
value={num}
|
||||
columns="auto"
|
||||
hideInactiveBeads
|
||||
compact
|
||||
scaleFactor={0.9}
|
||||
/>
|
||||
<span style={{ fontSize: '20px', fontWeight: 'bold', color: '#475569' }}>
|
||||
{num}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '40px', padding: '20px', background: '#f0fdf4', borderRadius: '8px' }}>
|
||||
<h2 style={{ marginTop: 0, color: '#166534' }}>✅ Success!</h2>
|
||||
<p style={{ color: '#15803d' }}>
|
||||
If you can see the abacus displays above, then AbacusStatic is working correctly
|
||||
in React Server Components. Check the page source - you'll see pure HTML/SVG with
|
||||
no client-side hydration markers!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { AbacusReact, useAbacusConfig, ABACUS_THEMES } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useHomeHero } from '../contexts/HomeHeroContext'
|
||||
|
||||
@@ -17,19 +17,8 @@ export function HeroAbacus() {
|
||||
const appConfig = useAbacusConfig()
|
||||
const heroRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Styling for structural elements (solid, no translucency)
|
||||
const structuralStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
// Use theme preset from abacus-react instead of manual definition
|
||||
const structuralStyles = ABACUS_THEMES.light
|
||||
|
||||
// Detect when hero scrolls out of view
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSpring, useTransition, animated } from '@react-spring/web'
|
||||
import * as Slider from '@radix-ui/react-slider'
|
||||
import { AbacusReact, StandaloneBead } from '@soroban/abacus-react'
|
||||
import { AbacusReact, StandaloneBead, ABACUS_THEMES } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack } from '../../styled-system/patterns'
|
||||
import { kyuLevelDetails } from '@/data/kyuLevelDetails'
|
||||
@@ -260,19 +260,8 @@ function parseKyuDetails(rawText: string) {
|
||||
return sections
|
||||
}
|
||||
|
||||
// Dark theme styles matching the homepage
|
||||
const darkStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: 'rgba(255, 255, 255, 0.2)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgba(255, 255, 255, 0.4)',
|
||||
stroke: 'rgba(255, 255, 255, 0.25)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
// Use dark theme preset from abacus-react instead of manual definition
|
||||
const darkStyles = ABACUS_THEMES.dark
|
||||
|
||||
interface LevelSliderDisplayProps {
|
||||
initialIndex?: number
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { AbacusReact, useAbacusConfig, ABACUS_THEMES } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useMyAbacus } from '@/contexts/MyAbacusContext'
|
||||
import { HomeHeroContext } from '@/contexts/HomeHeroContext'
|
||||
@@ -56,33 +56,9 @@ export function MyAbacus() {
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Hero mode styles - white structural (from original HeroAbacus)
|
||||
const structuralStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Trophy abacus styles - golden/premium look
|
||||
const trophyStyles = {
|
||||
columnPosts: {
|
||||
fill: '#fbbf24',
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: '#fbbf24',
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 4,
|
||||
},
|
||||
}
|
||||
// Use theme presets from abacus-react instead of manual definitions
|
||||
const structuralStyles = ABACUS_THEMES.light
|
||||
const trophyStyles = ABACUS_THEMES.trophy
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Resizable from 'react-resizable-layout'
|
||||
import { calculateBeadDiffFromValues } from '@soroban/abacus-react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { hstack, stack, vstack } from '../../../styled-system/patterns'
|
||||
import {
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
type TutorialValidation,
|
||||
} from '../../types/tutorial'
|
||||
import { generateAbacusInstructions } from '../../utils/abacusInstructionGenerator'
|
||||
import { calculateBeadDiffFromValues } from '../../utils/beadDiff'
|
||||
import { generateSingleProblem } from '../../utils/problemGenerator'
|
||||
import {
|
||||
createBasicAllowedConfiguration,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AbacusReact,
|
||||
type StepBeadHighlight,
|
||||
useAbacusDisplay,
|
||||
calculateBeadDiffFromValues,
|
||||
} from '@soroban/abacus-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -18,7 +19,6 @@ import type {
|
||||
TutorialStep,
|
||||
UIState,
|
||||
} from '../../types/tutorial'
|
||||
import { calculateBeadDiffFromValues } from '../../utils/beadDiff'
|
||||
import { generateUnifiedInstructionSequence } from '../../utils/unifiedStepGenerator'
|
||||
import { CoachBar } from './CoachBar/CoachBar'
|
||||
import { DecompositionWithReasons } from './DecompositionWithReasons'
|
||||
@@ -970,16 +970,11 @@ function TutorialPlayerContent({
|
||||
|
||||
// Two-level dynamic column highlights: group terms + individual term
|
||||
const dynamicColumnHighlights = useMemo(() => {
|
||||
console.log('🎨 COMPUTING COLUMN HIGHLIGHTS')
|
||||
console.log(' - activeTermIndices:', Array.from(activeTermIndices))
|
||||
console.log(' - activeIndividualTermIndex:', activeIndividualTermIndex)
|
||||
|
||||
const highlights: Record<number, any> = {}
|
||||
|
||||
// Level 1: Group highlights (blue glow for all terms in activeTermIndices)
|
||||
activeTermIndices.forEach((termIndex) => {
|
||||
const columnIndex = getColumnFromTermIndex(termIndex, true) // Use group column (rhsPlace)
|
||||
console.log(` - Group term ${termIndex} maps to column ${columnIndex} (using rhsPlace)`)
|
||||
if (columnIndex !== null) {
|
||||
highlights[columnIndex] = {
|
||||
// Group background glow effect (blue)
|
||||
@@ -998,16 +993,12 @@ function TutorialPlayerContent({
|
||||
borderColor: '#3b82f6',
|
||||
},
|
||||
}
|
||||
console.log(` 🔵 Added BLUE highlight for column ${columnIndex}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Level 2: Individual term highlight (orange glow, overrides group styling)
|
||||
if (activeIndividualTermIndex !== null) {
|
||||
const individualColumnIndex = getColumnFromTermIndex(activeIndividualTermIndex, false) // Use individual column (termPlace)
|
||||
console.log(
|
||||
` - Individual term ${activeIndividualTermIndex} maps to column ${individualColumnIndex} (using termPlace)`
|
||||
)
|
||||
if (individualColumnIndex !== null) {
|
||||
highlights[individualColumnIndex] = {
|
||||
// Individual background glow effect (orange) - overrides group glow
|
||||
@@ -1026,54 +1017,46 @@ function TutorialPlayerContent({
|
||||
borderColor: '#ea580c',
|
||||
},
|
||||
}
|
||||
console.log(
|
||||
` 🟠 Added ORANGE highlight for column ${individualColumnIndex} (overriding blue)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
'🎨 Final highlights:',
|
||||
Object.keys(highlights).map((col) => `Column ${col}`)
|
||||
)
|
||||
return highlights
|
||||
}, [activeTermIndices, activeIndividualTermIndex, getColumnFromTermIndex])
|
||||
|
||||
// Memoize custom styles calculation to avoid expensive recalculation on every render
|
||||
const customStyles = useMemo(() => {
|
||||
// Calculate valid column range based on abacusColumns
|
||||
const minValidColumn = 5 - abacusColumns
|
||||
|
||||
// Start with static highlights from step configuration
|
||||
const staticHighlights: Record<number, any> = {}
|
||||
// Separate bead-level and column-level styles
|
||||
const beadLevelHighlights: Record<number, any> = {}
|
||||
const columnLevelHighlights: Record<number, any> = {}
|
||||
|
||||
// Process static highlights from step configuration (bead-specific)
|
||||
if (currentStep.highlightBeads && Array.isArray(currentStep.highlightBeads)) {
|
||||
currentStep.highlightBeads.forEach((highlight) => {
|
||||
// Convert placeValue to columnIndex for AbacusReact compatibility
|
||||
const columnIndex = abacusColumns - 1 - highlight.placeValue
|
||||
|
||||
// Skip highlights for columns that don't exist
|
||||
if (columnIndex < minValidColumn) {
|
||||
// Skip highlights for columns that don't exist in the rendered abacus
|
||||
if (columnIndex < 0 || columnIndex >= abacusColumns) {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize column if it doesn't exist
|
||||
if (!staticHighlights[columnIndex]) {
|
||||
staticHighlights[columnIndex] = {}
|
||||
if (!beadLevelHighlights[columnIndex]) {
|
||||
beadLevelHighlights[columnIndex] = {}
|
||||
}
|
||||
|
||||
// Add the bead style to the appropriate type
|
||||
if (highlight.beadType === 'earth' && highlight.position !== undefined) {
|
||||
if (!staticHighlights[columnIndex].earth) {
|
||||
staticHighlights[columnIndex].earth = {}
|
||||
if (!beadLevelHighlights[columnIndex].earth) {
|
||||
beadLevelHighlights[columnIndex].earth = {}
|
||||
}
|
||||
staticHighlights[columnIndex].earth[highlight.position] = {
|
||||
beadLevelHighlights[columnIndex].earth[highlight.position] = {
|
||||
fill: '#fbbf24',
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 3,
|
||||
}
|
||||
} else {
|
||||
staticHighlights[columnIndex][highlight.beadType] = {
|
||||
beadLevelHighlights[columnIndex][highlight.beadType] = {
|
||||
fill: '#fbbf24',
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 3,
|
||||
@@ -1082,29 +1065,30 @@ function TutorialPlayerContent({
|
||||
})
|
||||
}
|
||||
|
||||
// Merge static and dynamic highlights (dynamic takes precedence)
|
||||
const mergedHighlights = { ...staticHighlights }
|
||||
// Process dynamic column highlights (column-level: backgroundGlow, numerals)
|
||||
Object.keys(dynamicColumnHighlights).forEach((columnIndexStr) => {
|
||||
const columnIndex = parseInt(columnIndexStr, 10)
|
||||
|
||||
// Skip highlights for columns that don't exist
|
||||
if (columnIndex < minValidColumn) {
|
||||
// Skip highlights for columns that don't exist in the rendered abacus
|
||||
if (columnIndex < 0 || columnIndex >= abacusColumns) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!mergedHighlights[columnIndex]) {
|
||||
mergedHighlights[columnIndex] = {}
|
||||
}
|
||||
// Merge dynamic highlights into the column
|
||||
Object.assign(mergedHighlights[columnIndex], dynamicColumnHighlights[columnIndex])
|
||||
// Dynamic highlights are column-level (backgroundGlow, numerals)
|
||||
columnLevelHighlights[columnIndex] = dynamicColumnHighlights[columnIndex]
|
||||
})
|
||||
|
||||
// Build the custom styles object
|
||||
const styles: any = {}
|
||||
|
||||
// Add column highlights if any
|
||||
if (Object.keys(mergedHighlights).length > 0) {
|
||||
styles.columns = mergedHighlights
|
||||
// Add bead-level highlights to styles.beads
|
||||
if (Object.keys(beadLevelHighlights).length > 0) {
|
||||
styles.beads = beadLevelHighlights
|
||||
}
|
||||
|
||||
// Add column-level highlights to styles.columns
|
||||
if (Object.keys(columnLevelHighlights).length > 0) {
|
||||
styles.columns = columnLevelHighlights
|
||||
}
|
||||
|
||||
// Add frame styling for dark mode
|
||||
@@ -1123,6 +1107,17 @@ function TutorialPlayerContent({
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging for custom styles
|
||||
if (Object.keys(styles).length > 0) {
|
||||
console.log('📋 TUTORIAL CUSTOM STYLES:', JSON.stringify({
|
||||
beadLevelHighlights,
|
||||
columnLevelHighlights,
|
||||
finalStyles: styles,
|
||||
currentStepHighlightBeads: currentStep.highlightBeads,
|
||||
abacusColumns,
|
||||
}, null, 2))
|
||||
}
|
||||
|
||||
return Object.keys(styles).length > 0 ? styles : undefined
|
||||
}, [currentStep.highlightBeads, dynamicColumnHighlights, abacusColumns, theme])
|
||||
|
||||
@@ -1608,7 +1603,7 @@ function TutorialPlayerContent({
|
||||
columns={abacusColumns}
|
||||
interactive={true}
|
||||
animated={true}
|
||||
scaleFactor={2.5}
|
||||
scaleFactor={1.5}
|
||||
colorScheme={abacusConfig.colorScheme}
|
||||
beadShape={abacusConfig.beadShape}
|
||||
hideInactiveBeads={abacusConfig.hideInactiveBeads}
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
// Automatic instruction generator for abacus tutorial steps
|
||||
import type { ValidPlaceValues } from '@soroban/abacus-react'
|
||||
// Re-exports core types and functions from abacus-react
|
||||
|
||||
export interface BeadState {
|
||||
heavenActive: boolean
|
||||
earthActive: number // 0-4
|
||||
}
|
||||
export type { ValidPlaceValues } from '@soroban/abacus-react'
|
||||
export {
|
||||
type BeadState,
|
||||
type AbacusState,
|
||||
type PlaceValueBasedBead as BeadHighlight,
|
||||
numberToAbacusState,
|
||||
calculateBeadChanges,
|
||||
} from '@soroban/abacus-react'
|
||||
|
||||
export interface AbacusState {
|
||||
[placeValue: number]: BeadState
|
||||
}
|
||||
import type { ValidPlaceValues, PlaceValueBasedBead } from '@soroban/abacus-react'
|
||||
import { numberToAbacusState, calculateBeadChanges } from '@soroban/abacus-react'
|
||||
|
||||
export interface BeadHighlight {
|
||||
placeValue: ValidPlaceValues
|
||||
beadType: 'heaven' | 'earth'
|
||||
position?: number
|
||||
}
|
||||
// Type alias for internal use
|
||||
type BeadHighlight = PlaceValueBasedBead
|
||||
|
||||
export interface StepBeadHighlight extends BeadHighlight {
|
||||
// App-specific extension for step-based tutorial highlighting
|
||||
export interface StepBeadHighlight extends PlaceValueBasedBead {
|
||||
stepIndex: number // Which instruction step this bead belongs to
|
||||
direction: 'up' | 'down' | 'activate' | 'deactivate' // Movement direction
|
||||
order?: number // Order within the step (for multiple beads per step)
|
||||
}
|
||||
|
||||
export interface GeneratedInstruction {
|
||||
highlightBeads: BeadHighlight[]
|
||||
highlightBeads: PlaceValueBasedBead[]
|
||||
expectedAction: 'add' | 'remove' | 'multi-step'
|
||||
actionDescription: string
|
||||
multiStepInstructions?: string[]
|
||||
@@ -40,68 +41,7 @@ export interface GeneratedInstruction {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a number to abacus state representation
|
||||
export function numberToAbacusState(value: number, maxPlaces: number = 5): AbacusState {
|
||||
const state: AbacusState = {}
|
||||
|
||||
for (let place = 0; place < maxPlaces; place++) {
|
||||
const placeValueNum = 10 ** place
|
||||
const digit = Math.floor(value / placeValueNum) % 10
|
||||
|
||||
state[place] = {
|
||||
heavenActive: digit >= 5,
|
||||
earthActive: digit >= 5 ? digit - 5 : digit,
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// Calculate the difference between two abacus states
|
||||
export function calculateBeadChanges(
|
||||
startState: AbacusState,
|
||||
targetState: AbacusState
|
||||
): {
|
||||
additions: BeadHighlight[]
|
||||
removals: BeadHighlight[]
|
||||
placeValue: number
|
||||
} {
|
||||
const additions: BeadHighlight[] = []
|
||||
const removals: BeadHighlight[] = []
|
||||
let mainPlaceValue = 0
|
||||
|
||||
for (const placeStr in targetState) {
|
||||
const place = parseInt(placeStr, 10) as ValidPlaceValues
|
||||
const start = startState[place] || { heavenActive: false, earthActive: 0 }
|
||||
const target = targetState[place]
|
||||
|
||||
// Check heaven bead changes
|
||||
if (!start.heavenActive && target.heavenActive) {
|
||||
additions.push({ placeValue: place, beadType: 'heaven' })
|
||||
mainPlaceValue = place
|
||||
} else if (start.heavenActive && !target.heavenActive) {
|
||||
removals.push({ placeValue: place, beadType: 'heaven' })
|
||||
mainPlaceValue = place
|
||||
}
|
||||
|
||||
// Check earth bead changes
|
||||
if (target.earthActive > start.earthActive) {
|
||||
// Adding earth beads
|
||||
for (let pos = start.earthActive; pos < target.earthActive; pos++) {
|
||||
additions.push({ placeValue: place, beadType: 'earth', position: pos })
|
||||
mainPlaceValue = place
|
||||
}
|
||||
} else if (target.earthActive < start.earthActive) {
|
||||
// Removing earth beads
|
||||
for (let pos = start.earthActive - 1; pos >= target.earthActive; pos--) {
|
||||
removals.push({ placeValue: place, beadType: 'earth', position: pos })
|
||||
mainPlaceValue = place
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { additions, removals, placeValue: mainPlaceValue }
|
||||
}
|
||||
// Note: numberToAbacusState and calculateBeadChanges are now re-exported from @soroban/abacus-react above
|
||||
|
||||
// Generate proper complement breakdown using simple bead movements
|
||||
function generateProperComplementDescription(
|
||||
|
||||
@@ -1,107 +1,25 @@
|
||||
// Dynamic bead diff algorithm for calculating transitions between abacus states
|
||||
// Provides arrows, highlights, and movement directions for tutorial UI
|
||||
// Re-export core bead diff functionality from abacus-react
|
||||
// App-specific extensions for multi-step tutorials and validation
|
||||
|
||||
import type { ValidPlaceValues } from '@soroban/abacus-react'
|
||||
import {
|
||||
export {
|
||||
type BeadDiffResult,
|
||||
type BeadDiffOutput,
|
||||
calculateBeadDiff,
|
||||
calculateBeadDiffFromValues,
|
||||
areStatesEqual,
|
||||
type AbacusState,
|
||||
type BeadHighlight,
|
||||
calculateBeadChanges,
|
||||
numberToAbacusState,
|
||||
} from './abacusInstructionGenerator'
|
||||
type BeadState,
|
||||
} from '@soroban/abacus-react'
|
||||
|
||||
export interface BeadDiffResult {
|
||||
placeValue: ValidPlaceValues
|
||||
beadType: 'heaven' | 'earth'
|
||||
position?: number
|
||||
direction: 'activate' | 'deactivate'
|
||||
order: number // Order of operations for animations
|
||||
}
|
||||
|
||||
export interface BeadDiffOutput {
|
||||
changes: BeadDiffResult[]
|
||||
highlights: BeadHighlight[]
|
||||
hasChanges: boolean
|
||||
summary: string
|
||||
}
|
||||
|
||||
/**
|
||||
* THE BEAD DIFF ALGORITHM
|
||||
*
|
||||
* Takes current and desired abacus states and returns exactly which beads
|
||||
* need to move with arrows and highlights for the tutorial UI.
|
||||
*
|
||||
* This is the core "diff" function that keeps tutorial highlights in sync.
|
||||
*/
|
||||
export function calculateBeadDiff(fromState: AbacusState, toState: AbacusState): BeadDiffOutput {
|
||||
const { additions, removals } = calculateBeadChanges(fromState, toState)
|
||||
|
||||
const changes: BeadDiffResult[] = []
|
||||
const highlights: BeadHighlight[] = []
|
||||
let order = 0
|
||||
|
||||
// Process removals first (pedagogical order: clear before adding)
|
||||
removals.forEach((removal) => {
|
||||
changes.push({
|
||||
placeValue: removal.placeValue,
|
||||
beadType: removal.beadType,
|
||||
position: removal.position,
|
||||
direction: 'deactivate',
|
||||
order: order++,
|
||||
})
|
||||
|
||||
highlights.push({
|
||||
placeValue: removal.placeValue,
|
||||
beadType: removal.beadType,
|
||||
position: removal.position,
|
||||
})
|
||||
})
|
||||
|
||||
// Process additions second (pedagogical order: add after clearing)
|
||||
additions.forEach((addition) => {
|
||||
changes.push({
|
||||
placeValue: addition.placeValue,
|
||||
beadType: addition.beadType,
|
||||
position: addition.position,
|
||||
direction: 'activate',
|
||||
order: order++,
|
||||
})
|
||||
|
||||
highlights.push({
|
||||
placeValue: addition.placeValue,
|
||||
beadType: addition.beadType,
|
||||
position: addition.position,
|
||||
})
|
||||
})
|
||||
|
||||
// Generate summary
|
||||
const summary = generateDiffSummary(changes)
|
||||
|
||||
return {
|
||||
changes,
|
||||
highlights,
|
||||
hasChanges: changes.length > 0,
|
||||
summary,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bead diff from numeric values
|
||||
* Convenience function for when you have numbers instead of states
|
||||
*/
|
||||
export function calculateBeadDiffFromValues(
|
||||
fromValue: number,
|
||||
toValue: number,
|
||||
maxPlaces: number = 5
|
||||
): BeadDiffOutput {
|
||||
const fromState = numberToAbacusState(fromValue, maxPlaces)
|
||||
const toState = numberToAbacusState(toValue, maxPlaces)
|
||||
return calculateBeadDiff(fromState, toState)
|
||||
}
|
||||
import type { BeadDiffOutput, BeadDiffResult, AbacusState } from '@soroban/abacus-react'
|
||||
import { calculateBeadDiffFromValues } from '@soroban/abacus-react'
|
||||
|
||||
/**
|
||||
* Calculate step-by-step bead diffs for multi-step operations
|
||||
* This is used for tutorial multi-step instructions where we want to show
|
||||
* the progression through intermediate states
|
||||
*
|
||||
* APP-SPECIFIC FUNCTION - not in core abacus-react
|
||||
*/
|
||||
export function calculateMultiStepBeadDiffs(
|
||||
startValue: number,
|
||||
@@ -133,126 +51,10 @@ export function calculateMultiStepBeadDiffs(
|
||||
return stepDiffs
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable summary of what the diff does
|
||||
* Respects pedagogical order: removals first, then additions
|
||||
*/
|
||||
function generateDiffSummary(changes: BeadDiffResult[]): string {
|
||||
if (changes.length === 0) {
|
||||
return 'No changes needed'
|
||||
}
|
||||
|
||||
// Sort by order to respect pedagogical sequence
|
||||
const sortedChanges = [...changes].sort((a, b) => a.order - b.order)
|
||||
|
||||
const deactivations = sortedChanges.filter((c) => c.direction === 'deactivate')
|
||||
const activations = sortedChanges.filter((c) => c.direction === 'activate')
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
// Process deactivations first (pedagogical order)
|
||||
if (deactivations.length > 0) {
|
||||
const deactivationsByPlace = groupByPlace(deactivations)
|
||||
Object.entries(deactivationsByPlace).forEach(([place, beads]) => {
|
||||
const placeName = getPlaceName(parseInt(place, 10))
|
||||
const heavenBeads = beads.filter((b) => b.beadType === 'heaven')
|
||||
const earthBeads = beads.filter((b) => b.beadType === 'earth')
|
||||
|
||||
if (heavenBeads.length > 0) {
|
||||
parts.push(`remove heaven bead in ${placeName}`)
|
||||
}
|
||||
if (earthBeads.length > 0) {
|
||||
const count = earthBeads.length
|
||||
parts.push(`remove ${count} earth bead${count > 1 ? 's' : ''} in ${placeName}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Process activations second (pedagogical order)
|
||||
if (activations.length > 0) {
|
||||
const activationsByPlace = groupByPlace(activations)
|
||||
Object.entries(activationsByPlace).forEach(([place, beads]) => {
|
||||
const placeName = getPlaceName(parseInt(place, 10))
|
||||
const heavenBeads = beads.filter((b) => b.beadType === 'heaven')
|
||||
const earthBeads = beads.filter((b) => b.beadType === 'earth')
|
||||
|
||||
if (heavenBeads.length > 0) {
|
||||
parts.push(`add heaven bead in ${placeName}`)
|
||||
}
|
||||
if (earthBeads.length > 0) {
|
||||
const count = earthBeads.length
|
||||
parts.push(`add ${count} earth bead${count > 1 ? 's' : ''} in ${placeName}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return parts.join(', then ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Group bead changes by place value
|
||||
*/
|
||||
function groupByPlace(changes: BeadDiffResult[]): {
|
||||
[place: string]: BeadDiffResult[]
|
||||
} {
|
||||
return changes.reduce(
|
||||
(groups, change) => {
|
||||
const place = change.placeValue.toString()
|
||||
if (!groups[place]) {
|
||||
groups[place] = []
|
||||
}
|
||||
groups[place].push(change)
|
||||
return groups
|
||||
},
|
||||
{} as { [place: string]: BeadDiffResult[] }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable place name
|
||||
*/
|
||||
function getPlaceName(place: number): string {
|
||||
switch (place) {
|
||||
case 0:
|
||||
return 'ones column'
|
||||
case 1:
|
||||
return 'tens column'
|
||||
case 2:
|
||||
return 'hundreds column'
|
||||
case 3:
|
||||
return 'thousands column'
|
||||
default:
|
||||
return `place ${place} column`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two abacus states are equal
|
||||
*/
|
||||
export function areStatesEqual(state1: AbacusState, state2: AbacusState): boolean {
|
||||
const places1 = Object.keys(state1)
|
||||
.map((k) => parseInt(k, 10))
|
||||
.sort()
|
||||
const places2 = Object.keys(state2)
|
||||
.map((k) => parseInt(k, 10))
|
||||
.sort()
|
||||
|
||||
if (places1.length !== places2.length) return false
|
||||
|
||||
for (const place of places1) {
|
||||
const bead1 = state1[place]
|
||||
const bead2 = state2[place]
|
||||
|
||||
if (!bead2) return false
|
||||
if (bead1.heavenActive !== bead2.heavenActive) return false
|
||||
if (bead1.earthActive !== bead2.earthActive) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a bead diff is feasible (no impossible bead states)
|
||||
*
|
||||
* APP-SPECIFIC FUNCTION - not in core abacus-react
|
||||
*/
|
||||
export function validateBeadDiff(diff: BeadDiffOutput): {
|
||||
isValid: boolean
|
||||
@@ -282,3 +84,20 @@ export function validateBeadDiff(diff: BeadDiffOutput): {
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for validation
|
||||
function groupByPlace(changes: BeadDiffResult[]): {
|
||||
[place: string]: BeadDiffResult[]
|
||||
} {
|
||||
return changes.reduce(
|
||||
(groups, change) => {
|
||||
const place = change.placeValue.toString()
|
||||
if (!groups[place]) {
|
||||
groups[place] = []
|
||||
}
|
||||
groups[place].push(change)
|
||||
return groups
|
||||
},
|
||||
{} as { [place: string]: BeadDiffResult[] }
|
||||
)
|
||||
}
|
||||
|
||||
19
apps/web/src/utils/calendar/generateCalendarAbacus.tsx
Normal file
19
apps/web/src/utils/calendar/generateCalendarAbacus.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Generate a simple abacus SVG element
|
||||
* Uses AbacusStatic for server-side rendering (no client hooks)
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { AbacusStatic } from '@soroban/abacus-react/static'
|
||||
|
||||
export function generateAbacusElement(value: number, columns: number) {
|
||||
return (
|
||||
<AbacusStatic
|
||||
value={value}
|
||||
columns={columns}
|
||||
scaleFactor={1}
|
||||
showNumbers={false}
|
||||
frameVisible={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
187
apps/web/src/utils/calendar/generateCalendarComposite.tsx
Normal file
187
apps/web/src/utils/calendar/generateCalendarComposite.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Generate a complete monthly calendar as a single SVG
|
||||
* This prevents multi-page overflow - one image scales to fit
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { AbacusStatic, calculateAbacusDimensions } from '@soroban/abacus-react/static'
|
||||
|
||||
interface CalendarCompositeOptions {
|
||||
month: number
|
||||
year: number
|
||||
renderToString: (element: React.ReactElement) => string
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate()
|
||||
}
|
||||
|
||||
function getFirstDayOfWeek(year: number, month: number): number {
|
||||
return new Date(year, month - 1, 1).getDay()
|
||||
}
|
||||
|
||||
export function generateCalendarComposite(options: CalendarCompositeOptions): string {
|
||||
const { month, year, renderToString } = options
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const firstDayOfWeek = getFirstDayOfWeek(year, month)
|
||||
const monthName = MONTH_NAMES[month - 1]
|
||||
|
||||
// Layout constants for US Letter aspect ratio (8.5 x 11)
|
||||
const WIDTH = 850
|
||||
const HEIGHT = 1100
|
||||
const MARGIN = 50
|
||||
const CONTENT_WIDTH = WIDTH - MARGIN * 2
|
||||
const CONTENT_HEIGHT = HEIGHT - MARGIN * 2
|
||||
|
||||
// Abacus natural size is 120x230 at scale=1
|
||||
const ABACUS_NATURAL_WIDTH = 120
|
||||
const ABACUS_NATURAL_HEIGHT = 230
|
||||
|
||||
// Calculate how many columns needed for year
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
|
||||
// Year abacus dimensions (calculate first to determine header height)
|
||||
// Use the shared dimension calculator so we stay in sync with AbacusStatic
|
||||
const { width: yearAbacusActualWidth, height: yearAbacusActualHeight } = calculateAbacusDimensions({
|
||||
columns: yearColumns,
|
||||
showNumbers: false,
|
||||
columnLabels: [],
|
||||
})
|
||||
|
||||
const yearAbacusDisplayWidth = WIDTH * 0.15 // Display size on page
|
||||
const yearAbacusDisplayHeight = (yearAbacusActualHeight / yearAbacusActualWidth) * yearAbacusDisplayWidth
|
||||
|
||||
// Header - sized to fit month name + year abacus
|
||||
const MONTH_NAME_HEIGHT = 40
|
||||
const HEADER_HEIGHT = MONTH_NAME_HEIGHT + yearAbacusDisplayHeight + 20 // 20px spacing
|
||||
const TITLE_Y = MARGIN + 35
|
||||
const yearAbacusX = (WIDTH - yearAbacusDisplayWidth) / 2
|
||||
const yearAbacusY = TITLE_Y + 10
|
||||
|
||||
// Calendar grid
|
||||
const GRID_START_Y = MARGIN + HEADER_HEIGHT
|
||||
const GRID_HEIGHT = CONTENT_HEIGHT - HEADER_HEIGHT
|
||||
const WEEKDAY_ROW_HEIGHT = 25
|
||||
const DAY_GRID_HEIGHT = GRID_HEIGHT - WEEKDAY_ROW_HEIGHT
|
||||
|
||||
// 7 columns, up to 6 rows (35 cells max = 5 empty + 30 days worst case)
|
||||
const CELL_WIDTH = CONTENT_WIDTH / 7
|
||||
const DAY_CELL_HEIGHT = DAY_GRID_HEIGHT / 6
|
||||
|
||||
// Day abacus sizing - fit in cell with padding
|
||||
const CELL_PADDING = 5
|
||||
|
||||
// Calculate max scale to fit in cell
|
||||
const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / ABACUS_NATURAL_WIDTH
|
||||
const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / ABACUS_NATURAL_HEIGHT
|
||||
const ABACUS_SCALE = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.9 // 90% to leave breathing room
|
||||
|
||||
const SCALED_ABACUS_WIDTH = ABACUS_NATURAL_WIDTH * ABACUS_SCALE
|
||||
const SCALED_ABACUS_HEIGHT = ABACUS_NATURAL_HEIGHT * ABACUS_SCALE
|
||||
|
||||
// Generate calendar grid
|
||||
const calendarCells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
calendarCells.push(null)
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
calendarCells.push(day)
|
||||
}
|
||||
|
||||
// Render individual abacus SVGs as complete SVG elements
|
||||
function renderAbacusSVG(value: number, columns: number, scale: number): string {
|
||||
return renderToString(
|
||||
<AbacusStatic
|
||||
value={value}
|
||||
columns={columns}
|
||||
scaleFactor={scale}
|
||||
showNumbers={false}
|
||||
frameVisible={true}
|
||||
compact={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Main composite SVG
|
||||
const compositeSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">
|
||||
<!-- Background -->
|
||||
<rect width="${WIDTH}" height="${HEIGHT}" fill="white"/>
|
||||
|
||||
<!-- Title: Month Name -->
|
||||
<text x="${WIDTH / 2}" y="${TITLE_Y}" text-anchor="middle" font-family="Arial" font-size="32" font-weight="bold" fill="#1a1a1a">
|
||||
${monthName}
|
||||
</text>
|
||||
|
||||
<!-- Year Abacus (centered below month name) -->
|
||||
${(() => {
|
||||
const yearAbacusSVG = renderAbacusSVG(year, yearColumns, 1)
|
||||
const yearAbacusContent = yearAbacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
|
||||
return `<svg x="${yearAbacusX}" y="${yearAbacusY}" width="${yearAbacusDisplayWidth}" height="${yearAbacusDisplayHeight}"
|
||||
viewBox="0 0 ${yearAbacusActualWidth} ${yearAbacusActualHeight}">
|
||||
${yearAbacusContent}
|
||||
</svg>`
|
||||
})()}
|
||||
|
||||
<!-- Weekday Headers -->
|
||||
${WEEKDAYS.map((day, i) => `
|
||||
<text x="${MARGIN + i * CELL_WIDTH + CELL_WIDTH / 2}" y="${GRID_START_Y + 18}"
|
||||
text-anchor="middle" font-family="Arial" font-size="14" font-weight="bold" fill="#555">
|
||||
${day}
|
||||
</text>`).join('')}
|
||||
|
||||
<!-- Separator line under weekdays -->
|
||||
<line x1="${MARGIN}" y1="${GRID_START_Y + WEEKDAY_ROW_HEIGHT}"
|
||||
x2="${WIDTH - MARGIN}" y2="${GRID_START_Y + WEEKDAY_ROW_HEIGHT}"
|
||||
stroke="#333" stroke-width="2"/>
|
||||
|
||||
<!-- Calendar Grid Cells -->
|
||||
${calendarCells.map((day, index) => {
|
||||
const row = Math.floor(index / 7)
|
||||
const col = index % 7
|
||||
const cellX = MARGIN + col * CELL_WIDTH
|
||||
const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT
|
||||
|
||||
return `
|
||||
<rect x="${cellX}" y="${cellY}" width="${CELL_WIDTH}" height="${DAY_CELL_HEIGHT}"
|
||||
fill="none" stroke="#333" stroke-width="2"/>`
|
||||
}).join('')}
|
||||
|
||||
<!-- Calendar Day Abaci -->
|
||||
${calendarCells.map((day, index) => {
|
||||
if (day === null) return ''
|
||||
|
||||
const row = Math.floor(index / 7)
|
||||
const col = index % 7
|
||||
const cellX = MARGIN + col * CELL_WIDTH
|
||||
const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT
|
||||
|
||||
// Center abacus in cell
|
||||
const abacusCenterX = cellX + CELL_WIDTH / 2
|
||||
const abacusCenterY = cellY + DAY_CELL_HEIGHT / 2
|
||||
|
||||
// Offset to top-left corner of abacus (accounting for scaled size)
|
||||
const abacusX = abacusCenterX - SCALED_ABACUS_WIDTH / 2
|
||||
const abacusY = abacusCenterY - SCALED_ABACUS_HEIGHT / 2
|
||||
|
||||
// Render at scale=1 and let the nested SVG handle scaling via viewBox
|
||||
const abacusSVG = renderAbacusSVG(day, 2, 1)
|
||||
const svgContent = abacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
|
||||
|
||||
return `
|
||||
<!-- Day ${day} (row ${row}, col ${col}) -->
|
||||
<svg x="${abacusX}" y="${abacusY}" width="${SCALED_ABACUS_WIDTH}" height="${SCALED_ABACUS_HEIGHT}"
|
||||
viewBox="0 0 ${ABACUS_NATURAL_WIDTH} ${ABACUS_NATURAL_HEIGHT}">
|
||||
${svgContent}
|
||||
</svg>`
|
||||
}).join('')}
|
||||
</svg>`
|
||||
|
||||
return compositeSVG
|
||||
}
|
||||
@@ -11,7 +11,13 @@
|
||||
"Bash(git commit:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(cat:*)"
|
||||
"Bash(cat:*)",
|
||||
"Bash(pnpm --filter @soroban/abacus-react build:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(pnpm --filter @soroban/web build:*)",
|
||||
"Bash(pnpm tsc:*)",
|
||||
"Bash(AbacusReact.tsx)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
|
||||
@@ -1,3 +1,96 @@
|
||||
## [2.8.3](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.8.2...abacus-react-v2.8.3) (2025-11-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tutorial:** correct column validation for bead highlights ([9ba1824](https://github.com/antialias/soroban-abacus-flashcards/commit/9ba18242262cd63cc6c25361aaec3a4c0f66b161))
|
||||
* **tutorial:** fix overlay rendering, arrow indicators, and bead visibility ([a804316](https://github.com/antialias/soroban-abacus-flashcards/commit/a80431608dbc4f54d8e4f1095936b95a258b4a72))
|
||||
* **web,docker:** add --format flag for Typst and upgrade to v0.13.0 ([19b9d7a](https://github.com/antialias/soroban-abacus-flashcards/commit/19b9d7a74f549c7e93c9564e4a903e1bcd5a4bbc))
|
||||
* **web:** move tsx to production dependencies for calendar generation ([ffae9c1](https://github.com/antialias/soroban-abacus-flashcards/commit/ffae9c1bdbccc5edb2e747a09d1fcad3b29e4eac))
|
||||
|
||||
## [2.8.2](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.8.1...abacus-react-v2.8.2) (2025-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **abacus-react:** add data-testid attributes back to beads for testing ([23ae1b0](https://github.com/antialias/soroban-abacus-flashcards/commit/23ae1b0c6f878daf79a993992d43ad80a89fa790))
|
||||
|
||||
## [2.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.8.0...abacus-react-v2.8.1) (2025-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **abacus-react:** fix animations by preventing component remounting ([be7d4c4](https://github.com/antialias/soroban-abacus-flashcards/commit/be7d4c471327534a95c4c75372680c629b5f12c2))
|
||||
* **abacus-react:** restore original AbacusReact measurements and positioning ([88c0baa](https://github.com/antialias/soroban-abacus-flashcards/commit/88c0baaad9b83b60ab8cdcad92070cc049d61cc7))
|
||||
|
||||
# [2.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.7.1...abacus-react-v2.8.0) (2025-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** add scripts, abacus-react, and tsx for production calendar generation ([33eb90e](https://github.com/antialias/soroban-abacus-flashcards/commit/33eb90e316f84650ae619f8c6c02c9e77c663d1b))
|
||||
* **web:** generate styled-system artifacts during build ([293390a](https://github.com/antialias/soroban-abacus-flashcards/commit/293390ae350a6c6aa467410f68c735512104d9dd))
|
||||
* **web:** move react-dom/server import to API route to satisfy Next.js ([00a8bc3](https://github.com/antialias/soroban-abacus-flashcards/commit/00a8bc3e5e8f044df280c4356d3605a852f82e84))
|
||||
* **web:** prevent abacus overlap in composite calendar ([448f93c](https://github.com/antialias/soroban-abacus-flashcards/commit/448f93c1e2a7f86bc48e678d4599ca968c6d81d2)), closes [#f0f0f0](https://github.com/antialias/soroban-abacus-flashcards/issues/f0f0f0)
|
||||
* **web:** use dynamic import for react-dom/server in API route ([4f93c7d](https://github.com/antialias/soroban-abacus-flashcards/commit/4f93c7d996732de4bc19e7acf2d4ce803cba88b6))
|
||||
* **web:** use nested SVG elements to prevent coordinate space conflicts ([f9cbee8](https://github.com/antialias/soroban-abacus-flashcards/commit/f9cbee8fcdf80641f3b82a65fad6b8a3575525fc))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** add shared dimension calculator for consistent sizing ([e5ba772](https://github.com/antialias/soroban-abacus-flashcards/commit/e5ba772fde9839c22daec92007f052ca125c7695))
|
||||
* **web:** add Typst-based preview endpoint with React Suspense ([599a758](https://github.com/antialias/soroban-abacus-flashcards/commit/599a758471c43ab0fc87301c5e7eeceed608062e))
|
||||
* **web:** add year abacus to calendar header and make grid bolder ([867c7ee](https://github.com/antialias/soroban-abacus-flashcards/commit/867c7ee17251b8df13665bee9c0391961975e681)), closes [#333](https://github.com/antialias/soroban-abacus-flashcards/issues/333)
|
||||
* **web:** optimize monthly calendar for single-page layout ([b277a89](https://github.com/antialias/soroban-abacus-flashcards/commit/b277a89415d1823455376c3e0f641b52f3394e7c))
|
||||
* **web:** redesign monthly calendar as single composite SVG ([8ce8038](https://github.com/antialias/soroban-abacus-flashcards/commit/8ce8038baeea0b8b0fffe3215746958731bd9d6a))
|
||||
|
||||
## [2.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.7.0...abacus-react-v2.7.1) (2025-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add xmlns to AbacusStatic for Typst SVG parsing ([98cd019](https://github.com/antialias/soroban-abacus-flashcards/commit/98cd019d4af91d7ca4e7a88f700194273476afb7))
|
||||
* **web:** use AbacusStatic for calendar SVG generation ([08c6a41](https://github.com/antialias/soroban-abacus-flashcards/commit/08c6a419e25d220560eba13d6db437145e6e61b8))
|
||||
|
||||
# [2.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.6.0...abacus-react-v2.7.0) (2025-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** add dynamic export to rithmomachia page ([329e623](https://github.com/antialias/soroban-abacus-flashcards/commit/329e62321245ef62726c986c917f19a909a5b65e))
|
||||
* **web:** fix Typst PDF generation path resolution ([7ce1287](https://github.com/antialias/soroban-abacus-flashcards/commit/7ce12875254a31d8acdb35ef5de7d36d215ccd92))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** add separate /static export path for React Server Components ([ed69f6b](https://github.com/antialias/soroban-abacus-flashcards/commit/ed69f6b917c543bbcaa4621a0e63745bee70f5bf))
|
||||
* **web:** add test page for AbacusStatic RSC compatibility ([903dea2](https://github.com/antialias/soroban-abacus-flashcards/commit/903dea25844f1d2b3730fbcbd8478e7af1887663))
|
||||
* **web:** improve calendar abacus preview styling ([8439727](https://github.com/antialias/soroban-abacus-flashcards/commit/8439727b152accf61f0c28158b92788510ca086e))
|
||||
|
||||
# [2.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.5.0...abacus-react-v2.6.0) (2025-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **abacus-react:** correct column highlighting offset in AbacusStatic ([0641eb7](https://github.com/antialias/soroban-abacus-flashcards/commit/0641eb719ef56c67de965296006df666f83e5b08))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** add AbacusStatic for React Server Components ([3b8e864](https://github.com/antialias/soroban-abacus-flashcards/commit/3b8e864cfa3af50b1912ce7ff55003d7f6b9c229))
|
||||
* **web:** add test page for AbacusStatic Server Component ([3588d5a](https://github.com/antialias/soroban-abacus-flashcards/commit/3588d5acde25588ce4db3ee32adb04ace0e394d4))
|
||||
|
||||
# [2.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.4.0...abacus-react-v2.5.0) (2025-11-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** add core utility functions for state management ([e65541c](https://github.com/antialias/soroban-abacus-flashcards/commit/e65541c100e590a51448750c6d5178ed4f3e8eeb))
|
||||
* **abacus-react:** add layout and educational props ([35bbcec](https://github.com/antialias/soroban-abacus-flashcards/commit/35bbcecb9e36f1ef5917a5a629f5e78f1f490e9c))
|
||||
* **abacus-react:** add pre-defined theme presets ([cf1f950](https://github.com/antialias/soroban-abacus-flashcards/commit/cf1f950c7c5fb9ee1f0de673235d6f037be3b9d6))
|
||||
* **abacus-react:** add React hooks for abacus calculations ([de038d2](https://github.com/antialias/soroban-abacus-flashcards/commit/de038d2afc26c36c1490d5ea45dace0ab812c5cc))
|
||||
* **abacus-react:** export new utilities, hooks, and themes ([ce4e44d](https://github.com/antialias/soroban-abacus-flashcards/commit/ce4e44d6302746053ad40dc61bab57ef3a0a9f31))
|
||||
|
||||
# [2.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.3.0...abacus-react-v2.4.0) (2025-11-03)
|
||||
|
||||
|
||||
|
||||
402
packages/abacus-react/ENHANCEMENT_PLAN.md
Normal file
402
packages/abacus-react/ENHANCEMENT_PLAN.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# Abacus-React Feature Enhancement Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The web application has developed numerous custom patterns and workarounds for styling, layout, and interactions with the abacus component. These patterns reveal gaps in the abacus-react API that, if addressed, would significantly improve developer experience and reduce code duplication across the application.
|
||||
|
||||
## Priority 1: Critical Features (High Impact, High Frequency)
|
||||
|
||||
### 1. **Inline "Mini Abacus" Component**
|
||||
**Location**: `apps/web/src/app/arcade/complement-race/components/AbacusTarget.tsx`
|
||||
|
||||
**Current Implementation**:
|
||||
```tsx
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns={1}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.72}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 },
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
**Problem**: Creating an inline mini-abacus for displaying single digits requires multiple props and style overrides. This pattern appears throughout game UIs.
|
||||
|
||||
**Proposed Solution**: Add a `variant` prop with preset configurations:
|
||||
|
||||
```tsx
|
||||
// Native API proposal
|
||||
<AbacusReact
|
||||
value={7}
|
||||
variant="inline-digit"
|
||||
// Automatically sets: columns=1, hideInactiveBeads, transparent frame, optimal scaleFactor
|
||||
/>
|
||||
|
||||
// Or more granular:
|
||||
<AbacusReact
|
||||
value={7}
|
||||
compact={true} // Removes frame, optimizes spacing
|
||||
frameVisible={false} // Hide posts and bar
|
||||
/>
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Single prop instead of 5+
|
||||
- Consistent inline abacus appearance across the app
|
||||
- Better semantic intent
|
||||
|
||||
---
|
||||
|
||||
### 2. **Theme-Aware Styling Presets**
|
||||
**Locations**:
|
||||
- `MyAbacus.tsx` (lines 60-85) - structural & trophy styles
|
||||
- `HeroAbacus.tsx` (lines 20-32) - structural styles
|
||||
- `LevelSliderDisplay.tsx` (lines 263-275) - dark theme styles
|
||||
|
||||
**Current Pattern**: Every component defines custom style objects for structural elements:
|
||||
|
||||
```tsx
|
||||
const structuralStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Problem**: Manual style object creation for common themes is repetitive and error-prone.
|
||||
|
||||
**Proposed Solution**: Add theme presets to abacus-react:
|
||||
|
||||
```tsx
|
||||
// Native API proposal
|
||||
<AbacusReact
|
||||
value={123}
|
||||
theme="dark" // or "light", "translucent", "solid", "trophy"
|
||||
/>
|
||||
|
||||
// Or expose theme constants
|
||||
import { ABACUS_THEMES } from '@soroban/abacus-react'
|
||||
<AbacusReact customStyles={ABACUS_THEMES.dark} />
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Eliminates ~30 lines of style definitions per component
|
||||
- Ensures visual consistency
|
||||
- Makes theme switching trivial
|
||||
|
||||
---
|
||||
|
||||
### 3. **Scaling Containers & Responsive Layouts**
|
||||
**Locations**:
|
||||
- `HeroAbacus.tsx` (lines 133-138) - manual scale transforms
|
||||
- `MyAbacus.tsx` (lines 214-218) - responsive scale values
|
||||
- `LevelSliderDisplay.tsx` (lines 370-379) - dynamic scale calculation
|
||||
|
||||
**Current Pattern**: Components manually wrap abacus in transform containers:
|
||||
|
||||
```tsx
|
||||
<div style={{
|
||||
transform: 'scale(3.5)',
|
||||
transformOrigin: 'center center'
|
||||
}}>
|
||||
<AbacusReact value={1234} columns={4} />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Problem**: Manual transform handling requires extra DOM nesting, breaks click boundaries, and makes centering complex.
|
||||
|
||||
**Proposed Solution**: Enhanced `scaleFactor` with responsive breakpoints:
|
||||
|
||||
```tsx
|
||||
// Native API proposal
|
||||
<AbacusReact
|
||||
value={1234}
|
||||
scaleFactor={{ base: 2.5, md: 3.5, lg: 4.5 }} // Responsive
|
||||
scaleOrigin="center" // Handle transform origin
|
||||
scaleContainer={true} // Apply correct boundaries for interaction
|
||||
/>
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Eliminates wrapper divs
|
||||
- Proper click/hover boundaries
|
||||
- Built-in responsive scaling
|
||||
|
||||
---
|
||||
|
||||
## Priority 2: Developer Experience Improvements
|
||||
|
||||
### 4. **Bead Diff Calculation System**
|
||||
**Location**: `apps/web/src/utils/beadDiff.ts` (285 lines) + `abacusInstructionGenerator.ts` (400+ lines)
|
||||
|
||||
**Current Implementation**: Complex utilities to calculate which beads need to move between states:
|
||||
|
||||
```tsx
|
||||
// Current external pattern
|
||||
import { calculateBeadDiffFromValues } from '@/utils/beadDiff'
|
||||
|
||||
const diff = calculateBeadDiffFromValues(fromValue, toValue)
|
||||
const stepBeadHighlights = diff.changes.map(change => ({
|
||||
placeValue: change.placeValue,
|
||||
beadType: change.beadType,
|
||||
direction: change.direction,
|
||||
// ...
|
||||
}))
|
||||
```
|
||||
|
||||
**Problem**: Tutorial/game developers need to calculate bead movements manually. This core logic belongs in abacus-react.
|
||||
|
||||
**Proposed Solution**: Add a diff calculation hook:
|
||||
|
||||
```tsx
|
||||
// Native API proposal
|
||||
import { useAbacusDiff } from '@soroban/abacus-react'
|
||||
|
||||
function Tutorial() {
|
||||
const diff = useAbacusDiff(startValue, targetValue)
|
||||
|
||||
return (
|
||||
<AbacusReact
|
||||
value={currentValue}
|
||||
stepBeadHighlights={diff.highlights} // Generated by hook
|
||||
// diff also includes: instructions, order, validation
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Centralizes "diff" algorithm
|
||||
- Eliminates ~500 lines of application code
|
||||
- Better tested and maintained
|
||||
|
||||
---
|
||||
|
||||
### 5. **Tutorial/Step Context Provider**
|
||||
**Location**: `apps/web/src/components/tutorial/TutorialContext.tsx`
|
||||
|
||||
**Current Pattern**: Apps need to implement complex state management for multi-step tutorial flows with reducer patterns, event tracking, and error handling.
|
||||
|
||||
**Problem**: Tutorial infrastructure is duplicated across components. The logic for tracking progress through abacus instruction steps is tightly coupled to application code.
|
||||
|
||||
**Proposed Solution**: Add optional tutorial/stepper context to abacus-react:
|
||||
|
||||
```tsx
|
||||
// Native API proposal
|
||||
import { AbacusReact, AbacusTutorial } from '@soroban/abacus-react'
|
||||
|
||||
<AbacusTutorial
|
||||
steps={[
|
||||
{ from: 0, to: 5, instruction: "Add 5" },
|
||||
{ from: 5, to: 15, instruction: "Add 10" },
|
||||
]}
|
||||
onStepComplete={(step) => { /* analytics */ }}
|
||||
onComplete={() => { /* celebration */ }}
|
||||
>
|
||||
<AbacusReact />
|
||||
</AbacusTutorial>
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Reusable tutorial infrastructure
|
||||
- Built-in progress tracking and validation
|
||||
- Could power educational features across projects
|
||||
|
||||
---
|
||||
|
||||
## Priority 3: Nice-to-Have Enhancements
|
||||
|
||||
### 6. **Animation Speed Configuration**
|
||||
**Location**: `LevelSliderDisplay.tsx` (lines 306-345)
|
||||
|
||||
**Current Pattern**: Applications control animation speed by rapidly changing the value prop:
|
||||
|
||||
```tsx
|
||||
const intervalMs = 500 - danProgress * 490 // 500ms down to 10ms
|
||||
setInterval(() => {
|
||||
setAnimatedDigits(prev => {
|
||||
// Rapidly change digits to simulate calculation
|
||||
})
|
||||
}, intervalMs)
|
||||
```
|
||||
|
||||
**Problem**: "Rapid calculation" animation requires external interval management.
|
||||
|
||||
**Proposed Solution**: Add animation speed prop:
|
||||
|
||||
```tsx
|
||||
// Native API proposal
|
||||
<AbacusReact
|
||||
value={calculatingValue}
|
||||
animationSpeed="fast" // or "normal", "slow", or ms number
|
||||
autoAnimate={true} // Animate value prop changes automatically
|
||||
/>
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Smoother animations with internal management
|
||||
- Consistent timing across the app
|
||||
|
||||
---
|
||||
|
||||
### 7. **Draggable/Positionable Abacus Cards**
|
||||
**Location**: `InteractiveFlashcards.tsx`
|
||||
|
||||
**Current Pattern**: Complex drag-and-drop implementation wrapped around each AbacusReact instance with pointer capture, offset tracking, and rotation.
|
||||
|
||||
**Problem**: Making abacus instances draggable requires significant boilerplate.
|
||||
|
||||
**Proposed Solution**: This is probably too specific to remain external. However, a ref-based API to get bounding boxes would help:
|
||||
|
||||
```tsx
|
||||
// Possible improvement
|
||||
const abacusRef = useAbacusRef()
|
||||
|
||||
<AbacusReact ref={abacusRef} />
|
||||
|
||||
// abacusRef.current.getBoundingBox() for drag calculations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. **Column Highlighting for Multi-Step Problems**
|
||||
**Location**: Tutorial system extensively
|
||||
|
||||
**Current Pattern**: Manual column highlighting based on place values with custom overlay positioning logic.
|
||||
|
||||
**Problem**: Highlighting specific columns (e.g., "the tens column") requires external overlay management.
|
||||
|
||||
**Proposed Solution**: Add native column highlighting:
|
||||
|
||||
```tsx
|
||||
// Native API proposal
|
||||
<AbacusReact
|
||||
value={123}
|
||||
highlightColumns={[1]} // Highlight tens column
|
||||
columnLabels={["ones", "tens", "hundreds"]} // Optional labels
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Priority 4: Documentation & Exports
|
||||
|
||||
### 9. **Utility Functions & Types**
|
||||
**Current State**: Apps re-implement utilities for working with abacus states:
|
||||
- `numberToAbacusState()` - convert numbers to bead states
|
||||
- `calculateBeadChanges()` - diff algorithm
|
||||
- `ValidPlaceValues` type - imported but limited
|
||||
|
||||
**Proposed Solution**: Export more utilities from abacus-react:
|
||||
|
||||
```tsx
|
||||
// Expanded exports
|
||||
export {
|
||||
// Utilities
|
||||
numberToAbacusState,
|
||||
abacusStateToNumber,
|
||||
calculateBeadDiff,
|
||||
validateAbacusValue,
|
||||
|
||||
// Types
|
||||
AbacusState,
|
||||
BeadState,
|
||||
PlaceValue,
|
||||
|
||||
// Hooks
|
||||
useAbacusDiff,
|
||||
useAbacusValidation,
|
||||
useAbacusState,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1 (Immediate) ✅ COMPLETED
|
||||
1. ✅ Add `frameVisible={false}` prop
|
||||
2. ✅ Add `compact` prop/variant
|
||||
3. ✅ Export theme presets (ABACUS_THEMES constant)
|
||||
|
||||
### Phase 2 (Short-term) ✅ COMPLETED
|
||||
4. ⏸️ Enhanced `scaleFactor` with responsive object support (DEFERRED - too complex, low priority)
|
||||
5. ✅ Export utility functions (numberToAbacusState, calculateBeadDiff, etc.)
|
||||
|
||||
### Phase 3 (Medium-term) ✅ COMPLETED
|
||||
6. ✅ Add `useAbacusDiff` hook
|
||||
7. ✅ Add native column highlighting with `highlightColumns` and `columnLabels` props
|
||||
|
||||
### Phase 4 (Long-term - Future)
|
||||
8. 📋 Consider tutorial context provider (needs more research)
|
||||
9. 📋 Animation speed controls
|
||||
|
||||
## Completed Features Summary
|
||||
|
||||
### New Props
|
||||
- `frameVisible?: boolean` - Show/hide column posts and reckoning bar
|
||||
- `compact?: boolean` - Compact layout for inline display (implies frameVisible=false)
|
||||
- `highlightColumns?: number[]` - Highlight specific columns by index
|
||||
- `columnLabels?: string[]` - Optional labels for columns
|
||||
|
||||
### New Exports
|
||||
- `ABACUS_THEMES` - Pre-defined theme presets (light, dark, trophy, translucent, solid, traditional)
|
||||
- `AbacusThemeName` type - TypeScript type for theme names
|
||||
|
||||
### New Utility Functions
|
||||
- `numberToAbacusState(value, maxPlaces)` - Convert number to bead positions
|
||||
- `abacusStateToNumber(state)` - Convert bead positions to number
|
||||
- `calculateBeadChanges(startState, targetState)` - Calculate bead differences
|
||||
- `calculateBeadDiff(fromState, toState)` - Full diff with order and directions
|
||||
- `calculateBeadDiffFromValues(from, to, maxPlaces)` - Convenience wrapper
|
||||
- `validateAbacusValue(value, maxPlaces)` - Validate number ranges
|
||||
- `areStatesEqual(state1, state2)` - Compare states
|
||||
|
||||
### New Hooks
|
||||
- `useAbacusDiff(fromValue, toValue, maxPlaces)` - Calculate bead differences for tutorials
|
||||
- `useAbacusState(value, maxPlaces)` - Convert number to abacus state (memoized)
|
||||
|
||||
### New Types
|
||||
- `BeadState` - Bead state in a single column
|
||||
- `AbacusState` - Complete abacus state
|
||||
- `BeadDiffResult` - Single bead movement result
|
||||
- `BeadDiffOutput` - Complete diff output
|
||||
- `PlaceValueBasedBead` - Internal place-value based bead type
|
||||
|
||||
---
|
||||
|
||||
## Metrics & Impact
|
||||
|
||||
**Code Reduction Estimate**:
|
||||
- Eliminates ~800-1000 lines of repetitive application code
|
||||
- Reduces component complexity by ~40% for tutorial/game components
|
||||
|
||||
**Developer Experience**:
|
||||
- Faster onboarding for new features using abacus
|
||||
- More consistent UX across application
|
||||
- Better TypeScript support and autocomplete
|
||||
|
||||
**Maintenance**:
|
||||
- Centralized logic easier to test and debug
|
||||
- Single source of truth for abacus behavior
|
||||
- Easier to add new features (e.g., sound effects for different themes)
|
||||
|
||||
---
|
||||
|
||||
## Questions for Discussion
|
||||
|
||||
1. Should we split these into separate packages (e.g., `@soroban/abacus-tutorial`)?
|
||||
2. Which theme presets should be included by default?
|
||||
3. Should responsive scaling use CSS media queries or JS breakpoints?
|
||||
4. How much tutorial logic belongs in the core library vs. app code?
|
||||
162
packages/abacus-react/INTEGRATION_SUMMARY.md
Normal file
162
packages/abacus-react/INTEGRATION_SUMMARY.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Integration Summary
|
||||
|
||||
## ✅ Completed: Apps/Web Integration with Abacus-React Enhancements
|
||||
|
||||
### Features Implemented & Integrated
|
||||
|
||||
#### 1. **Theme Presets (ABACUS_THEMES)**
|
||||
**Status:** ✅ Fully integrated
|
||||
|
||||
**Files Updated:**
|
||||
- `apps/web/src/components/MyAbacus.tsx` - Now uses `ABACUS_THEMES.light` and `ABACUS_THEMES.trophy`
|
||||
- `apps/web/src/components/HeroAbacus.tsx` - Now uses `ABACUS_THEMES.light`
|
||||
- `apps/web/src/components/LevelSliderDisplay.tsx` - Now uses `ABACUS_THEMES.dark`
|
||||
|
||||
**Code Eliminated:** ~60 lines of duplicate theme style definitions
|
||||
|
||||
---
|
||||
|
||||
#### 2. **Compact Prop**
|
||||
**Status:** ✅ Fully integrated
|
||||
|
||||
**Files Updated:**
|
||||
- `apps/web/src/app/arcade/complement-race/components/AbacusTarget.tsx` - Now uses `compact={true}`
|
||||
|
||||
**Before:**
|
||||
```tsx
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns={1}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.72}
|
||||
customStyles={{ columnPosts: { opacity: 0 } }}
|
||||
/>
|
||||
```
|
||||
|
||||
**After:**
|
||||
```tsx
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns={1}
|
||||
compact={true}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.72}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. **Utility Functions**
|
||||
**Status:** ✅ Fully integrated
|
||||
|
||||
**Files Updated:**
|
||||
- `apps/web/src/utils/beadDiff.ts` - Now re-exports from abacus-react
|
||||
- `apps/web/src/utils/abacusInstructionGenerator.ts` - Now re-exports from abacus-react
|
||||
- `apps/web/src/components/tutorial/TutorialPlayer.tsx` - Imports `calculateBeadDiffFromValues` from abacus-react
|
||||
- `apps/web/src/components/tutorial/TutorialEditor.tsx` - Imports `calculateBeadDiffFromValues` from abacus-react
|
||||
|
||||
**Exports from abacus-react:**
|
||||
- `numberToAbacusState()`
|
||||
- `abacusStateToNumber()`
|
||||
- `calculateBeadChanges()`
|
||||
- `calculateBeadDiff()`
|
||||
- `calculateBeadDiffFromValues()`
|
||||
- `validateAbacusValue()`
|
||||
- `areStatesEqual()`
|
||||
|
||||
**Code Eliminated:** ~200+ lines of duplicate utility implementations
|
||||
|
||||
---
|
||||
|
||||
#### 4. **React Hooks**
|
||||
**Status:** ✅ Exported and ready to use
|
||||
|
||||
**Available Hooks:**
|
||||
- `useAbacusDiff(fromValue, toValue, maxPlaces)` - Memoized bead diff calculation
|
||||
- `useAbacusState(value, maxPlaces)` - Memoized state conversion
|
||||
|
||||
**Not yet used in app** (available for future tutorials)
|
||||
|
||||
---
|
||||
|
||||
#### 5. **Column Highlighting**
|
||||
**Status:** ✅ Implemented, not yet used
|
||||
|
||||
**New Props:**
|
||||
- `highlightColumns?: number[]` - Highlight specific columns
|
||||
- `columnLabels?: string[]` - Add educational labels above columns
|
||||
|
||||
**Usage Example:**
|
||||
```tsx
|
||||
<AbacusReact
|
||||
value={123}
|
||||
highlightColumns={[1]}
|
||||
columnLabels={['ones', 'tens', 'hundreds']}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Code Deduplication Summary
|
||||
|
||||
**Total Lines Eliminated:** ~260-300 lines
|
||||
|
||||
**Breakdown:**
|
||||
- Theme style definitions: ~60 lines
|
||||
- Utility function implementations: ~200 lines
|
||||
- Custom styles for inline abacus: ~5-10 lines per usage
|
||||
|
||||
---
|
||||
|
||||
### Remaining Work (Optional Future Enhancements)
|
||||
|
||||
1. Use `highlightColumns` and `columnLabels` in tutorial components
|
||||
2. Replace manual bead diff calculations with `useAbacusDiff` hook in interactive tutorials
|
||||
3. Use `useAbacusState` for state inspection in debugging/development tools
|
||||
4. Consider implementing `frameVisible` toggles in settings pages
|
||||
|
||||
---
|
||||
|
||||
### Files Modified
|
||||
|
||||
**packages/abacus-react:**
|
||||
- `src/AbacusReact.tsx` - Added new props (frameVisible, compact, highlightColumns, columnLabels)
|
||||
- `src/AbacusThemes.ts` - **NEW FILE** - 6 theme presets
|
||||
- `src/AbacusUtils.ts` - **NEW FILE** - Core utility functions
|
||||
- `src/AbacusHooks.ts` - **NEW FILE** - React hooks
|
||||
- `src/index.ts` - Updated exports
|
||||
- `src/AbacusReact.themes-and-utilities.stories.tsx` - **NEW FILE** - Storybook demos
|
||||
- `README.md` - Updated with new features documentation
|
||||
- `ENHANCEMENT_PLAN.md` - Updated with completion status
|
||||
|
||||
**apps/web:**
|
||||
- `src/components/MyAbacus.tsx` - Using ABACUS_THEMES
|
||||
- `src/components/HeroAbacus.tsx` - Using ABACUS_THEMES
|
||||
- `src/components/LevelSliderDisplay.tsx` - Using ABACUS_THEMES
|
||||
- `src/app/arcade/complement-race/components/AbacusTarget.tsx` - Using compact prop
|
||||
- `src/components/tutorial/TutorialPlayer.tsx` - Importing from abacus-react
|
||||
- `src/components/tutorial/TutorialEditor.tsx` - Importing from abacus-react
|
||||
- `src/utils/beadDiff.ts` - Re-exports from abacus-react
|
||||
- `src/utils/abacusInstructionGenerator.ts` - Re-exports from abacus-react
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
✅ Build successful for packages/abacus-react
|
||||
✅ TypeScript compilation passes for integrated files
|
||||
✅ Runtime tests confirm functions work correctly
|
||||
✅ Storybook stories demonstrate all new features
|
||||
|
||||
---
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. Monitor app for any runtime issues with the new integrations
|
||||
2. Consider using hooks in future tutorial implementations
|
||||
3. Explore using column highlighting in educational content
|
||||
4. Document best practices for theme usage in the app
|
||||
@@ -14,6 +14,7 @@ A comprehensive React component for rendering interactive Soroban (Japanese abac
|
||||
- 🎓 **Tutorial system** - Built-in overlay and guidance capabilities
|
||||
- 🧩 **Framework-free SVG** - Complete control over rendering
|
||||
- ✨ **3D Enhancement** - Three levels of progressive 3D effects for immersive visuals
|
||||
- 🚀 **Server Component support** - AbacusStatic works in React Server Components (Next.js App Router)
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -86,34 +87,213 @@ Personalized colors and highlights
|
||||
/>
|
||||
```
|
||||
|
||||
### Theme Presets
|
||||
|
||||
Use pre-defined themes for quick styling:
|
||||
|
||||
```tsx
|
||||
import { AbacusReact, ABACUS_THEMES } from '@soroban/abacus-react';
|
||||
|
||||
// Available themes: 'light', 'dark', 'trophy', 'translucent', 'solid', 'traditional'
|
||||
<AbacusReact
|
||||
value={123}
|
||||
columns={3}
|
||||
customStyles={ABACUS_THEMES.dark}
|
||||
/>
|
||||
|
||||
<AbacusReact
|
||||
value={456}
|
||||
columns={3}
|
||||
customStyles={ABACUS_THEMES.trophy} // Golden frame for achievements
|
||||
/>
|
||||
|
||||
<AbacusReact
|
||||
value={789}
|
||||
columns={3}
|
||||
customStyles={ABACUS_THEMES.traditional} // Brown wooden appearance
|
||||
/>
|
||||
```
|
||||
|
||||
**Available Themes:**
|
||||
- `light` - Solid white frame with subtle gray accents (best for light backgrounds)
|
||||
- `dark` - Translucent white with subtle glow (best for dark backgrounds)
|
||||
- `trophy` - Golden frame with warm tones (best for achievements/rewards)
|
||||
- `translucent` - Nearly invisible frame (best for inline/minimal UI)
|
||||
- `solid` - Black frame (best for high contrast/educational contexts)
|
||||
- `traditional` - Brown wooden appearance (best for traditional soroban aesthetic)
|
||||
|
||||
### Static Display (Server Components)
|
||||
|
||||
For static, non-interactive displays that work in React Server Components:
|
||||
|
||||
```tsx
|
||||
// IMPORTANT: Use /static import path for RSC compatibility!
|
||||
import { AbacusStatic } from '@soroban/abacus-react/static';
|
||||
|
||||
// ✅ Works in React Server Components - no "use client" needed!
|
||||
// ✅ No JavaScript sent to client
|
||||
// ✅ Perfect for SSG, SSR, and static previews
|
||||
|
||||
<AbacusStatic
|
||||
value={123}
|
||||
columns="auto"
|
||||
hideInactiveBeads
|
||||
compact
|
||||
/>
|
||||
```
|
||||
|
||||
**Import paths:**
|
||||
- `@soroban/abacus-react` - Full package (client components with hooks/animations)
|
||||
- `@soroban/abacus-react/static` - Server-compatible components only (no client code)
|
||||
|
||||
**Guaranteed Visual Consistency:**
|
||||
|
||||
Both `AbacusStatic` and `AbacusReact` share the same underlying layout engine. **Same props = same exact SVG output.** This ensures:
|
||||
- Static previews match interactive versions pixel-perfect
|
||||
- Server-rendered abaci look identical to client-rendered ones
|
||||
- PDF generation produces accurate representations
|
||||
- No visual discrepancies between environments
|
||||
|
||||
**Architecture: How We Guarantee Consistency**
|
||||
|
||||
The package uses a shared rendering architecture with dependency injection:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Shared Utilities (AbacusUtils.ts) │
|
||||
│ • calculateStandardDimensions() - Single │
|
||||
│ source of truth for all layout dimensions│
|
||||
│ • calculateBeadPosition() - Exact bead │
|
||||
│ positioning using shared formulas │
|
||||
└────────────┬────────────────────────────────┘
|
||||
│
|
||||
├──────────────────────────────────┐
|
||||
↓ ↓
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ AbacusStatic │ │ AbacusReact │
|
||||
│ (Server/Static) │ │ (Interactive) │
|
||||
└────────┬────────┘ └────────┬────────┘
|
||||
│ │
|
||||
└────────────┬───────────────────┘
|
||||
↓
|
||||
┌────────────────────────┐
|
||||
│ AbacusSVGRenderer │
|
||||
│ • Pure SVG structure │
|
||||
│ • Dependency injection │
|
||||
│ • Bead component prop │
|
||||
└────────────────────────┘
|
||||
↓
|
||||
┌───────────────┴───────────────┐
|
||||
↓ ↓
|
||||
┌──────────────┐ ┌──────────────────┐
|
||||
│ AbacusStatic │ │ AbacusAnimated │
|
||||
│ Bead │ │ Bead │
|
||||
│ (Simple SVG) │ │ (react-spring) │
|
||||
└──────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
**Key Components:**
|
||||
|
||||
1. **`calculateStandardDimensions()`** - Returns complete layout dimensions (bar position, bead sizes, gaps, etc.)
|
||||
2. **`calculateBeadPosition()`** - Calculates exact x,y coordinates for any bead
|
||||
3. **`AbacusSVGRenderer`** - Shared SVG rendering component that accepts a bead component via dependency injection
|
||||
4. **`AbacusStaticBead`** - Simple SVG shapes for static display (no hooks, RSC-compatible)
|
||||
5. **`AbacusAnimatedBead`** - Client component with react-spring animations and gesture handling
|
||||
|
||||
This architecture eliminates code duplication (~560 lines removed in the refactor) while guaranteeing pixel-perfect consistency.
|
||||
|
||||
**When to use `AbacusStatic` vs `AbacusReact`:**
|
||||
|
||||
| Feature | AbacusStatic | AbacusReact |
|
||||
|---------|--------------|-------------|
|
||||
| React Server Components | ✅ Yes | ❌ No (requires "use client") |
|
||||
| Client-side JavaScript | ❌ None | ✅ Yes |
|
||||
| User interaction | ❌ No | ✅ Click/drag beads |
|
||||
| Animations | ❌ No | ✅ Smooth transitions |
|
||||
| Sound effects | ❌ No | ✅ Optional sounds |
|
||||
| 3D effects | ❌ No | ✅ Yes |
|
||||
| **Visual output** | **✅ Identical** | **✅ Identical** |
|
||||
| Bundle size | 📦 Minimal | 📦 Full-featured |
|
||||
| Use cases | Preview cards, thumbnails, static pages, PDFs | Interactive tutorials, games, tools |
|
||||
|
||||
```tsx
|
||||
// Example: Server Component with static abacus cards
|
||||
// app/flashcards/page.tsx
|
||||
import { AbacusStatic } from '@soroban/abacus-react/static'
|
||||
|
||||
export default function FlashcardsPage() {
|
||||
const numbers = [1, 5, 10, 25, 50, 100]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{numbers.map(num => (
|
||||
<div key={num} className="card">
|
||||
<AbacusStatic value={num} columns="auto" compact />
|
||||
<p>{num}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Compact/Inline Display
|
||||
|
||||
Create mini abacus displays for inline use:
|
||||
|
||||
```tsx
|
||||
// Compact mode - automatically hides frame and optimizes spacing
|
||||
<AbacusReact
|
||||
value={7}
|
||||
columns={1}
|
||||
compact={true}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.7}
|
||||
/>
|
||||
|
||||
// Or manually control frame visibility
|
||||
<AbacusReact
|
||||
value={42}
|
||||
columns={2}
|
||||
frameVisible={false} // Hide column posts and reckoning bar
|
||||
/>
|
||||
```
|
||||
|
||||
### Tutorial System
|
||||
|
||||
Educational guidance with tooltips
|
||||
Educational guidance with tooltips and column highlighting
|
||||
|
||||
<img src="https://raw.githubusercontent.com/antialias/soroban-abacus-flashcards/main/packages/abacus-react/examples/tutorial-mode.svg" alt="Tutorial System">
|
||||
|
||||
```tsx
|
||||
<AbacusReact
|
||||
value={42}
|
||||
columns={2}
|
||||
columns={3}
|
||||
interactive={true}
|
||||
// Highlight the tens column with a label
|
||||
highlightColumns={[1]} // Highlight column index 1 (tens)
|
||||
columnLabels={['ones', 'tens', 'hundreds']} // Add labels to columns
|
||||
overlays={[{
|
||||
id: 'tip',
|
||||
type: 'tooltip',
|
||||
target: { type: 'bead', columnIndex: 0, beadType: 'earth', beadPosition: 1 },
|
||||
content: <div>Click this bead!</div>,
|
||||
target: { type: 'bead', columnIndex: 1, beadType: 'earth', beadPosition: 1 },
|
||||
content: <div>Click this bead in the tens column!</div>,
|
||||
offset: { x: 0, y: -30 }
|
||||
}]}
|
||||
callbacks={{
|
||||
onBeadClick: (event) => {
|
||||
if (event.columnIndex === 0 && event.beadType === 'earth' && event.position === 1) {
|
||||
console.log('Correct!');
|
||||
if (event.columnIndex === 1 && event.beadType === 'earth' && event.position === 1) {
|
||||
console.log('Correct! You clicked the tens column.');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
**Column Highlighting:**
|
||||
- `highlightColumns` - Array of column indices to highlight (e.g., `[0, 2]` highlights first and third columns)
|
||||
- `columnLabels` - Optional labels displayed above each column (indexed left to right)
|
||||
|
||||
## 3D Enhancement
|
||||
|
||||
Make the abacus feel tangible and satisfying with three progressive levels of 3D effects.
|
||||
@@ -209,10 +389,18 @@ interface AbacusConfig {
|
||||
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature';
|
||||
hideInactiveBeads?: boolean; // Hide/show inactive beads
|
||||
|
||||
// Layout & Frame
|
||||
frameVisible?: boolean; // Show/hide column posts and reckoning bar
|
||||
compact?: boolean; // Compact layout (implies frameVisible=false)
|
||||
|
||||
// Interaction
|
||||
interactive?: boolean; // Enable user interactions
|
||||
animated?: boolean; // Enable animations
|
||||
gestures?: boolean; // Enable drag gestures
|
||||
|
||||
// Tutorial Features
|
||||
highlightColumns?: number[]; // Highlight specific columns by index
|
||||
columnLabels?: string[]; // Optional labels for columns
|
||||
}
|
||||
```
|
||||
|
||||
@@ -359,6 +547,60 @@ function AdvancedExample() {
|
||||
|
||||
## Hooks
|
||||
|
||||
### useAbacusDiff
|
||||
|
||||
Calculate bead differences between values for tutorials and animations:
|
||||
|
||||
```tsx
|
||||
import { useAbacusDiff } from '@soroban/abacus-react';
|
||||
|
||||
function Tutorial() {
|
||||
const [currentValue, setCurrentValue] = useState(5);
|
||||
const targetValue = 15;
|
||||
|
||||
// Get diff information: which beads need to move
|
||||
const diff = useAbacusDiff(currentValue, targetValue);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{diff.summary}</p> {/* "add heaven bead in tens column, then..." */}
|
||||
<AbacusReact
|
||||
value={currentValue}
|
||||
stepBeadHighlights={diff.highlights} // Highlight beads that need to change
|
||||
interactive
|
||||
onValueChange={setCurrentValue}
|
||||
/>
|
||||
<p>Changes needed: {diff.changes.length}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
- `changes` - Array of bead movements with direction and order
|
||||
- `highlights` - Bead highlight data for stepBeadHighlights prop
|
||||
- `hasChanges` - Boolean indicating if any changes needed
|
||||
- `summary` - Human-readable description of changes (e.g., "add heaven bead in ones column")
|
||||
|
||||
### useAbacusState
|
||||
|
||||
Convert numbers to abacus bead states:
|
||||
|
||||
```tsx
|
||||
import { useAbacusState } from '@soroban/abacus-react';
|
||||
|
||||
function BeadAnalyzer() {
|
||||
const value = 123;
|
||||
const state = useAbacusState(value);
|
||||
|
||||
// Check bead positions
|
||||
const onesHasHeaven = state[0].heavenActive; // false (3 < 5)
|
||||
const tensEarthCount = state[1].earthActive; // 2 (20 = 2 tens)
|
||||
|
||||
return <div>Ones column heaven bead: {onesHasHeaven ? 'active' : 'inactive'}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### useAbacusDimensions
|
||||
|
||||
Get exact sizing information for layout planning:
|
||||
@@ -377,6 +619,149 @@ function MyComponent() {
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
|
||||
Low-level functions for working with abacus states and calculations:
|
||||
|
||||
### numberToAbacusState
|
||||
|
||||
Convert a number to bead positions:
|
||||
|
||||
```tsx
|
||||
import { numberToAbacusState } from '@soroban/abacus-react';
|
||||
|
||||
const state = numberToAbacusState(123, 5); // 5 columns
|
||||
// Returns: {
|
||||
// 0: { heavenActive: false, earthActive: 3 }, // ones = 3
|
||||
// 1: { heavenActive: false, earthActive: 2 }, // tens = 2
|
||||
// 2: { heavenActive: true, earthActive: 0 }, // hundreds = 1
|
||||
// ...
|
||||
// }
|
||||
```
|
||||
|
||||
### abacusStateToNumber
|
||||
|
||||
Convert bead positions back to a number:
|
||||
|
||||
```tsx
|
||||
import { abacusStateToNumber } from '@soroban/abacus-react';
|
||||
|
||||
const state = {
|
||||
0: { heavenActive: false, earthActive: 3 },
|
||||
1: { heavenActive: false, earthActive: 2 },
|
||||
2: { heavenActive: true, earthActive: 0 }
|
||||
};
|
||||
|
||||
const value = abacusStateToNumber(state); // 123
|
||||
```
|
||||
|
||||
### calculateBeadDiff
|
||||
|
||||
Calculate the exact bead movements needed between two states:
|
||||
|
||||
```tsx
|
||||
import { calculateBeadDiff, numberToAbacusState } from '@soroban/abacus-react';
|
||||
|
||||
const fromState = numberToAbacusState(5);
|
||||
const toState = numberToAbacusState(15);
|
||||
const diff = calculateBeadDiff(fromState, toState);
|
||||
|
||||
console.log(diff.summary); // "add heaven bead in tens column"
|
||||
console.log(diff.changes); // Detailed array of movements with order
|
||||
```
|
||||
|
||||
### calculateBeadDiffFromValues
|
||||
|
||||
Convenience wrapper for calculating diff from numbers:
|
||||
|
||||
```tsx
|
||||
import { calculateBeadDiffFromValues } from '@soroban/abacus-react';
|
||||
|
||||
const diff = calculateBeadDiffFromValues(42, 57);
|
||||
// Equivalent to: calculateBeadDiff(numberToAbacusState(42), numberToAbacusState(57))
|
||||
```
|
||||
|
||||
### validateAbacusValue
|
||||
|
||||
Check if a value is within the supported range:
|
||||
|
||||
```tsx
|
||||
import { validateAbacusValue } from '@soroban/abacus-react';
|
||||
|
||||
const result = validateAbacusValue(123456, 5); // 5 columns max
|
||||
console.log(result.isValid); // false
|
||||
console.log(result.error); // "Value exceeds maximum for 5 columns (max: 99999)"
|
||||
```
|
||||
|
||||
### areStatesEqual
|
||||
|
||||
Compare two abacus states:
|
||||
|
||||
```tsx
|
||||
import { areStatesEqual, numberToAbacusState } from '@soroban/abacus-react';
|
||||
|
||||
const state1 = numberToAbacusState(123);
|
||||
const state2 = numberToAbacusState(123);
|
||||
const isEqual = areStatesEqual(state1, state2); // true
|
||||
```
|
||||
|
||||
### calculateStandardDimensions
|
||||
|
||||
**⚡ Core Architecture Function** - Calculate complete layout dimensions for consistent rendering.
|
||||
|
||||
This is the **single source of truth** for all layout dimensions, used internally by both `AbacusStatic` and `AbacusReact` to guarantee pixel-perfect consistency.
|
||||
|
||||
```tsx
|
||||
import { calculateStandardDimensions } from '@soroban/abacus-react';
|
||||
|
||||
const dimensions = calculateStandardDimensions({
|
||||
columns: 3,
|
||||
scaleFactor: 1.5,
|
||||
showNumbers: true,
|
||||
columnLabels: ['ones', 'tens', 'hundreds']
|
||||
});
|
||||
|
||||
// Returns complete layout info:
|
||||
// {
|
||||
// width, height, // SVG canvas size
|
||||
// beadSize, // 12 * scaleFactor (standard bead size)
|
||||
// rodSpacing, // 25 * scaleFactor (column spacing)
|
||||
// rodWidth, // 3 * scaleFactor
|
||||
// barThickness, // 2 * scaleFactor
|
||||
// barY, // Reckoning bar Y position (30 * scaleFactor + labels)
|
||||
// heavenY, earthY, // Inactive bead rest positions
|
||||
// activeGap, // 1 * scaleFactor (gap to bar when active)
|
||||
// inactiveGap, // 8 * scaleFactor (gap between active/inactive)
|
||||
// adjacentSpacing, // 0.5 * scaleFactor (spacing between adjacent beads)
|
||||
// padding, labelHeight, numbersHeight, totalColumns
|
||||
// }
|
||||
```
|
||||
|
||||
**Why this matters:** Same input parameters = same exact layout dimensions = pixel-perfect visual consistency across static and interactive displays.
|
||||
|
||||
### calculateBeadPosition
|
||||
|
||||
**⚡ Core Architecture Function** - Calculate exact x,y coordinates for any bead.
|
||||
|
||||
Used internally by `AbacusSVGRenderer` to position all beads consistently in both static and interactive modes.
|
||||
|
||||
```tsx
|
||||
import { calculateBeadPosition, calculateStandardDimensions } from '@soroban/abacus-react';
|
||||
|
||||
const dimensions = calculateStandardDimensions({ columns: 3, scaleFactor: 1 });
|
||||
const bead = {
|
||||
type: 'heaven',
|
||||
active: true,
|
||||
position: 0,
|
||||
placeValue: 1 // tens column
|
||||
};
|
||||
|
||||
const position = calculateBeadPosition(bead, dimensions);
|
||||
// Returns: { x: 25, y: 29 } // exact pixel coordinates
|
||||
```
|
||||
|
||||
Useful for custom rendering or positioning tooltips/overlays relative to specific beads.
|
||||
|
||||
## Educational Use Cases
|
||||
|
||||
### Interactive Math Lessons
|
||||
@@ -439,14 +824,41 @@ Full TypeScript definitions included:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
// Components
|
||||
AbacusReact,
|
||||
|
||||
// Hooks
|
||||
useAbacusDiff,
|
||||
useAbacusState,
|
||||
useAbacusDimensions,
|
||||
|
||||
// Utility Functions
|
||||
numberToAbacusState,
|
||||
abacusStateToNumber,
|
||||
calculateBeadDiff,
|
||||
calculateBeadDiffFromValues,
|
||||
validateAbacusValue,
|
||||
areStatesEqual,
|
||||
calculateStandardDimensions, // NEW: Shared layout calculator
|
||||
calculateBeadPosition, // NEW: Bead position calculator
|
||||
|
||||
// Theme Presets
|
||||
ABACUS_THEMES,
|
||||
|
||||
// Types
|
||||
AbacusConfig,
|
||||
BeadConfig,
|
||||
BeadClickEvent,
|
||||
AbacusCustomStyles,
|
||||
AbacusOverlay,
|
||||
AbacusCallbacks,
|
||||
useAbacusDimensions
|
||||
AbacusState,
|
||||
BeadState,
|
||||
BeadDiffResult,
|
||||
BeadDiffOutput,
|
||||
AbacusThemeName,
|
||||
AbacusLayoutDimensions, // NEW: Complete layout dimensions type
|
||||
BeadPositionConfig // NEW: Bead config for position calculation
|
||||
} from '@soroban/abacus-react';
|
||||
|
||||
// All interfaces fully typed for excellent developer experience
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.es.js",
|
||||
"require": "./dist/index.cjs.js"
|
||||
},
|
||||
"./static": {
|
||||
"types": "./dist/static.d.ts",
|
||||
"import": "./dist/static.es.js",
|
||||
"require": "./dist/static.cjs.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
390
packages/abacus-react/src/AbacusAnimatedBead.tsx
Normal file
390
packages/abacus-react/src/AbacusAnimatedBead.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AbacusAnimatedBead - Interactive bead component for AbacusReact (Core Architecture)
|
||||
*
|
||||
* This is the **client-side bead component** injected into AbacusSVGRenderer by AbacusReact.
|
||||
* It provides animations and interactivity while the parent renderer handles positioning.
|
||||
*
|
||||
* ## Architecture Role:
|
||||
* - Injected into `AbacusSVGRenderer` via dependency injection (BeadComponent prop)
|
||||
* - Receives x,y position from `calculateBeadPosition()` (already calculated)
|
||||
* - Adds animations and interactions on top of the shared layout
|
||||
* - Used ONLY by AbacusReact (requires "use client")
|
||||
*
|
||||
* ## Features:
|
||||
* - ✅ React Spring animations for smooth position changes
|
||||
* - ✅ Drag gesture handling with @use-gesture/react
|
||||
* - ✅ Direction indicators for tutorials (pulsing arrows)
|
||||
* - ✅ 3D effects and gradients
|
||||
* - ✅ Click and hover interactions
|
||||
*
|
||||
* ## Comparison:
|
||||
* - `AbacusStaticBead` - Simple SVG shapes (no animations, RSC-compatible)
|
||||
* - `AbacusAnimatedBead` - This component (animations, gestures, client-only)
|
||||
*
|
||||
* Both receive the same position from `calculateBeadPosition()`, ensuring visual consistency.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef } from 'react'
|
||||
import { useSpring, animated, to } from '@react-spring/web'
|
||||
import { useDrag } from '@use-gesture/react'
|
||||
import type { BeadComponentProps } from './AbacusSVGRenderer'
|
||||
import type { BeadConfig } from './AbacusReact'
|
||||
|
||||
interface AnimatedBeadProps extends BeadComponentProps {
|
||||
// Animation controls
|
||||
enableAnimation: boolean
|
||||
physicsConfig: any
|
||||
|
||||
// Gesture handling
|
||||
enableGestures: boolean
|
||||
onGestureToggle?: (bead: BeadConfig, direction: 'activate' | 'deactivate') => void
|
||||
|
||||
// Direction indicators (for tutorials)
|
||||
showDirectionIndicator?: boolean
|
||||
direction?: 'activate' | 'deactivate'
|
||||
isCurrentStep?: boolean
|
||||
|
||||
// 3D effects
|
||||
enhanced3d?: 'none' | 'subtle' | 'realistic' | 'delightful'
|
||||
columnIndex?: number
|
||||
|
||||
// Hover state from parent abacus
|
||||
isAbacusHovered?: boolean
|
||||
}
|
||||
|
||||
export function AbacusAnimatedBead({
|
||||
bead,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
shape,
|
||||
color,
|
||||
hideInactiveBeads,
|
||||
customStyle,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onRef,
|
||||
enableAnimation,
|
||||
physicsConfig,
|
||||
enableGestures,
|
||||
onGestureToggle,
|
||||
showDirectionIndicator,
|
||||
direction,
|
||||
isCurrentStep,
|
||||
enhanced3d = 'none',
|
||||
columnIndex,
|
||||
isAbacusHovered = false,
|
||||
}: AnimatedBeadProps) {
|
||||
// x, y are already calculated by AbacusSVGRenderer
|
||||
|
||||
// Track hover state for showing hidden inactive beads
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
// Use abacus hover if provided, otherwise use individual bead hover
|
||||
const effectiveHoverState = isAbacusHovered || isHovered
|
||||
|
||||
// Spring animation for position
|
||||
const [{ springX, springY }, api] = useSpring(() => ({
|
||||
springX: x,
|
||||
springY: y,
|
||||
config: physicsConfig,
|
||||
}))
|
||||
|
||||
// Arrow pulse animation for direction indicators
|
||||
const [{ arrowPulse }, arrowApi] = useSpring(() => ({
|
||||
arrowPulse: 1,
|
||||
config: enableAnimation ? { tension: 200, friction: 10 } : { duration: 0 },
|
||||
}))
|
||||
|
||||
const gestureStateRef = useRef({
|
||||
isDragging: false,
|
||||
lastDirection: null as 'activate' | 'deactivate' | null,
|
||||
startY: 0,
|
||||
threshold: size * 0.3,
|
||||
hasGestureTriggered: false,
|
||||
})
|
||||
|
||||
// Calculate gesture direction based on bead type
|
||||
const getGestureDirection = useCallback(
|
||||
(deltaY: number) => {
|
||||
const movement = Math.abs(deltaY)
|
||||
if (movement < gestureStateRef.current.threshold) return null
|
||||
|
||||
if (bead.type === 'heaven') {
|
||||
return deltaY > 0 ? 'activate' : 'deactivate'
|
||||
} else {
|
||||
return deltaY < 0 ? 'activate' : 'deactivate'
|
||||
}
|
||||
},
|
||||
[bead.type, size]
|
||||
)
|
||||
|
||||
// Gesture handler
|
||||
const bind = enableGestures
|
||||
? useDrag(
|
||||
({ event, movement: [, deltaY], first, active }) => {
|
||||
if (first) {
|
||||
event?.preventDefault()
|
||||
gestureStateRef.current.isDragging = true
|
||||
gestureStateRef.current.lastDirection = null
|
||||
gestureStateRef.current.hasGestureTriggered = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!active || !gestureStateRef.current.isDragging) {
|
||||
if (!active) {
|
||||
gestureStateRef.current.isDragging = false
|
||||
gestureStateRef.current.lastDirection = null
|
||||
setTimeout(() => {
|
||||
gestureStateRef.current.hasGestureTriggered = false
|
||||
}, 100)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const currentDirection = getGestureDirection(deltaY)
|
||||
|
||||
if (
|
||||
currentDirection &&
|
||||
currentDirection !== gestureStateRef.current.lastDirection
|
||||
) {
|
||||
gestureStateRef.current.lastDirection = currentDirection
|
||||
gestureStateRef.current.hasGestureTriggered = true
|
||||
onGestureToggle?.(bead, currentDirection)
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: enableGestures,
|
||||
preventDefault: true,
|
||||
}
|
||||
)
|
||||
: () => ({})
|
||||
|
||||
// Update spring animation when position changes
|
||||
React.useEffect(() => {
|
||||
if (enableAnimation) {
|
||||
api.start({ springX: x, springY: y, config: physicsConfig })
|
||||
} else {
|
||||
api.set({ springX: x, springY: y })
|
||||
}
|
||||
}, [x, y, enableAnimation, api, physicsConfig])
|
||||
|
||||
// Pulse animation for direction indicators
|
||||
React.useEffect(() => {
|
||||
if (showDirectionIndicator && direction && isCurrentStep) {
|
||||
const startPulse = () => {
|
||||
arrowApi.start({
|
||||
from: { arrowPulse: 1 },
|
||||
to: async (next) => {
|
||||
await next({ arrowPulse: 1.3 })
|
||||
await next({ arrowPulse: 1 })
|
||||
},
|
||||
loop: true,
|
||||
})
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(startPulse, 200)
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
arrowApi.stop()
|
||||
}
|
||||
} else {
|
||||
arrowApi.set({ arrowPulse: 1 })
|
||||
}
|
||||
}, [showDirectionIndicator, direction, isCurrentStep, arrowApi])
|
||||
|
||||
// Render bead shape
|
||||
const renderShape = () => {
|
||||
const halfSize = size / 2
|
||||
|
||||
// Determine fill - use gradient for realistic mode, otherwise use color
|
||||
let fillValue = customStyle?.fill || color
|
||||
if (enhanced3d === 'realistic' && columnIndex !== undefined) {
|
||||
if (bead.type === 'heaven') {
|
||||
fillValue = `url(#bead-gradient-${columnIndex}-heaven)`
|
||||
} else {
|
||||
fillValue = `url(#bead-gradient-${columnIndex}-earth-${bead.position})`
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate opacity based on state and settings
|
||||
let opacity: number
|
||||
if (customStyle?.opacity !== undefined) {
|
||||
// Custom opacity always takes precedence
|
||||
opacity = customStyle.opacity
|
||||
} else if (bead.active) {
|
||||
// Active beads are always full opacity
|
||||
opacity = 1
|
||||
} else if (hideInactiveBeads && effectiveHoverState) {
|
||||
// Inactive beads that are hidden but being hovered show at low opacity
|
||||
opacity = 0.3
|
||||
} else if (hideInactiveBeads) {
|
||||
// Inactive beads that are hidden and not hovered are invisible (handled below)
|
||||
opacity = 0
|
||||
} else {
|
||||
// Inactive beads when hideInactiveBeads is false are full opacity
|
||||
opacity = 1
|
||||
}
|
||||
|
||||
const stroke = customStyle?.stroke || '#000'
|
||||
const strokeWidth = customStyle?.strokeWidth || 0.5
|
||||
|
||||
switch (shape) {
|
||||
case 'diamond':
|
||||
return (
|
||||
<polygon
|
||||
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
|
||||
fill={fillValue}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
opacity={opacity}
|
||||
/>
|
||||
)
|
||||
case 'square':
|
||||
return (
|
||||
<rect
|
||||
width={size}
|
||||
height={size}
|
||||
fill={fillValue}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
rx="1"
|
||||
opacity={opacity}
|
||||
/>
|
||||
)
|
||||
case 'circle':
|
||||
default:
|
||||
return (
|
||||
<circle
|
||||
cx={halfSize}
|
||||
cy={halfSize}
|
||||
r={halfSize}
|
||||
fill={fillValue}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
opacity={opacity}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate offsets for shape positioning
|
||||
const getXOffset = () => {
|
||||
return shape === 'diamond' ? size * 0.7 : size / 2
|
||||
}
|
||||
|
||||
const getYOffset = () => {
|
||||
return size / 2
|
||||
}
|
||||
|
||||
// Use animated.g if animations enabled, otherwise regular g
|
||||
const GElement = enableAnimation ? animated.g : 'g'
|
||||
const DirectionIndicatorG =
|
||||
enableAnimation && showDirectionIndicator && direction ? animated.g : 'g'
|
||||
|
||||
// Build style object
|
||||
// Show pointer cursor on hidden beads so users know they can interact
|
||||
const shouldShowCursor = bead.active || !hideInactiveBeads || effectiveHoverState
|
||||
const cursor = shouldShowCursor ? (enableGestures ? 'grab' : onClick ? 'pointer' : 'default') : 'default'
|
||||
|
||||
const beadStyle: any = enableAnimation
|
||||
? {
|
||||
transform: to(
|
||||
[springX, springY],
|
||||
(sx, sy) => `translate(${sx - getXOffset()}px, ${sy - getYOffset()}px)`
|
||||
),
|
||||
cursor,
|
||||
touchAction: 'none' as const,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
pointerEvents: 'auto' as const, // Ensure hidden beads can still be hovered
|
||||
}
|
||||
: {
|
||||
transform: `translate(${x - getXOffset()}px, ${y - getYOffset()}px)`,
|
||||
cursor,
|
||||
touchAction: 'none' as const,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
pointerEvents: 'auto' as const, // Ensure hidden beads can still be hovered
|
||||
}
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
// Prevent click if gesture was triggered
|
||||
if (gestureStateRef.current.hasGestureTriggered) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
onClick?.(bead, event)
|
||||
}
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
setIsHovered(true)
|
||||
onMouseEnter?.(bead, e as any)
|
||||
}
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
setIsHovered(false)
|
||||
onMouseLeave?.(bead, e as any)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GElement
|
||||
className={`abacus-bead ${bead.active ? 'active' : 'inactive'} ${hideInactiveBeads && !bead.active ? 'hidden-inactive' : ''}`}
|
||||
data-testid={`bead-place-${bead.placeValue}-${bead.type}${bead.type === 'earth' ? `-pos-${bead.position}` : ''}`}
|
||||
style={beadStyle}
|
||||
{...bind()}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={(el) => onRef?.(bead, el as any)}
|
||||
>
|
||||
{renderShape()}
|
||||
</GElement>
|
||||
|
||||
{/* Direction indicator for tutorials */}
|
||||
{showDirectionIndicator && direction && (
|
||||
<DirectionIndicatorG
|
||||
className="direction-indicator"
|
||||
style={
|
||||
(enableAnimation
|
||||
? {
|
||||
transform: to(
|
||||
[springX, springY, arrowPulse],
|
||||
(sx, sy, pulse) => {
|
||||
const centerX = shape === 'diamond' ? size * 0.7 : size / 2
|
||||
const centerY = size / 2
|
||||
// Scale from center: translate to position, then translate to center, scale, translate back
|
||||
return `translate(${sx}px, ${sy}px) scale(${pulse}) translate(${-centerX}px, ${-centerY}px)`
|
||||
}
|
||||
),
|
||||
pointerEvents: 'none' as const,
|
||||
}
|
||||
: {
|
||||
transform: `translate(${x}px, ${y}px) translate(${-(shape === 'diamond' ? size * 0.7 : size / 2)}px, ${-size / 2}px)`,
|
||||
pointerEvents: 'none' as const,
|
||||
}) as any
|
||||
}
|
||||
>
|
||||
<text
|
||||
x={shape === 'diamond' ? size * 0.7 : size / 2}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dy=".35em"
|
||||
fontSize={size * 0.7}
|
||||
fill="#fbbf24"
|
||||
fontWeight="bold"
|
||||
stroke="#000"
|
||||
strokeWidth="1.5"
|
||||
paintOrder="stroke"
|
||||
>
|
||||
{bead.type === 'heaven'
|
||||
? (direction === 'activate' ? '↓' : '↑')
|
||||
: (direction === 'activate' ? '↑' : '↓')}
|
||||
</text>
|
||||
</DirectionIndicatorG>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
71
packages/abacus-react/src/AbacusHooks.ts
Normal file
71
packages/abacus-react/src/AbacusHooks.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Utility hooks for working with abacus calculations and state
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
calculateBeadDiffFromValues,
|
||||
numberToAbacusState,
|
||||
type BeadDiffOutput,
|
||||
type AbacusState,
|
||||
} from './AbacusUtils'
|
||||
|
||||
/**
|
||||
* Hook to calculate bead differences between two values
|
||||
* Useful for tutorials, animations, and highlighting which beads need to move
|
||||
*
|
||||
* @param fromValue - Starting value
|
||||
* @param toValue - Target value
|
||||
* @param maxPlaces - Maximum number of place values to consider (default: 5)
|
||||
* @returns BeadDiffOutput with changes, highlights, and summary
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function Tutorial() {
|
||||
* const diff = useAbacusDiff(5, 15)
|
||||
*
|
||||
* return (
|
||||
* <AbacusReact
|
||||
* value={currentValue}
|
||||
* stepBeadHighlights={diff.highlights}
|
||||
* />
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useAbacusDiff(
|
||||
fromValue: number | bigint,
|
||||
toValue: number | bigint,
|
||||
maxPlaces: number = 5
|
||||
): BeadDiffOutput {
|
||||
return useMemo(() => {
|
||||
return calculateBeadDiffFromValues(fromValue, toValue, maxPlaces)
|
||||
}, [fromValue, toValue, maxPlaces])
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to convert a number to abacus state
|
||||
* Memoized for performance when used in components
|
||||
*
|
||||
* @param value - The number to convert
|
||||
* @param maxPlaces - Maximum number of place values (default: 5)
|
||||
* @returns AbacusState representing the bead positions
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const state = useAbacusState(123)
|
||||
*
|
||||
* // Check if ones column has heaven bead active
|
||||
* const onesHasHeaven = state[0].heavenActive
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useAbacusState(
|
||||
value: number | bigint,
|
||||
maxPlaces: number = 5
|
||||
): AbacusState {
|
||||
return useMemo(() => {
|
||||
return numberToAbacusState(value, maxPlaces)
|
||||
}, [value, maxPlaces])
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* Theme system, layout utilities, hooks, and helper functions
|
||||
* Features: Theme presets, compact mode, column highlighting, hooks, utility functions
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import React, { useState } from 'react';
|
||||
import AbacusReact from './AbacusReact';
|
||||
import {
|
||||
ABACUS_THEMES,
|
||||
AbacusThemeName,
|
||||
useAbacusDiff,
|
||||
useAbacusState,
|
||||
numberToAbacusState,
|
||||
abacusStateToNumber,
|
||||
calculateBeadDiffFromValues,
|
||||
validateAbacusValue,
|
||||
areStatesEqual
|
||||
} from './index';
|
||||
|
||||
const meta = {
|
||||
title: 'AbacusReact/Themes & Utilities',
|
||||
component: AbacusReact,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AbacusReact>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// ============================================================================
|
||||
// THEME PRESETS
|
||||
// ============================================================================
|
||||
|
||||
export const AllThemePresets: Story = {
|
||||
render: () => {
|
||||
const themes: AbacusThemeName[] = ['light', 'dark', 'trophy', 'translucent', 'solid', 'traditional'];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '30px', padding: '20px' }}>
|
||||
<h2>Theme Presets</h2>
|
||||
<p>Pre-defined themes eliminate manual style object creation</p>
|
||||
|
||||
{themes.map((themeName) => (
|
||||
<div key={themeName} style={{
|
||||
background: themeName === 'dark' ? '#1a1a1a' : '#f5f5f5',
|
||||
padding: '20px',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
marginTop: 0,
|
||||
color: themeName === 'dark' ? 'white' : 'black',
|
||||
textTransform: 'capitalize'
|
||||
}}>
|
||||
{themeName} Theme
|
||||
</h3>
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
customStyles={ABACUS_THEMES[themeName]}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LightTheme: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: '20px', background: '#f5f5f5' }}>
|
||||
<h3>Light Theme - Best for light backgrounds</h3>
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
customStyles={ABACUS_THEMES.light}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const DarkTheme: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: '20px', background: '#1a1a1a' }}>
|
||||
<h3 style={{ color: 'white' }}>Dark Theme - Best for dark backgrounds</h3>
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
customStyles={ABACUS_THEMES.dark}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const TrophyTheme: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: '20px', background: '#f0f0f0' }}>
|
||||
<h3>Trophy Theme - Golden frame for achievements</h3>
|
||||
<AbacusReact
|
||||
value={9999}
|
||||
columns={4}
|
||||
customStyles={ABACUS_THEMES.trophy}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const TraditionalTheme: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: '20px', background: '#f5f5f0' }}>
|
||||
<h3>Traditional Theme - Brown wooden soroban aesthetic</h3>
|
||||
<AbacusReact
|
||||
value={8765}
|
||||
columns={4}
|
||||
customStyles={ABACUS_THEMES.traditional}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMPACT MODE & FRAME VISIBILITY
|
||||
// ============================================================================
|
||||
|
||||
export const CompactMode: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Compact Mode - Inline mini-abacus displays</h3>
|
||||
<p>Perfect for inline number displays, badges, or game UI</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'center', marginTop: '20px' }}>
|
||||
<span>Single digits: </span>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => (
|
||||
<AbacusReact
|
||||
key={num}
|
||||
value={num}
|
||||
columns={1}
|
||||
compact={true}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.6}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '30px' }}>
|
||||
<h4>Two-digit compact displays:</h4>
|
||||
<div style={{ display: 'flex', gap: '15px', alignItems: 'center' }}>
|
||||
{[12, 34, 56, 78, 99].map(num => (
|
||||
<AbacusReact
|
||||
key={num}
|
||||
value={num}
|
||||
columns={2}
|
||||
compact={true}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.7}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const FrameVisibilityControl: Story = {
|
||||
render: () => {
|
||||
const [frameVisible, setFrameVisible] = useState(true);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Frame Visibility Control</h3>
|
||||
<p>Toggle column posts and reckoning bar on/off</p>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={frameVisible}
|
||||
onChange={(e) => setFrameVisible(e.target.checked)}
|
||||
/>
|
||||
{' '}Show Frame
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
frameVisible={frameVisible}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COLUMN HIGHLIGHTING & LABELS
|
||||
// ============================================================================
|
||||
|
||||
export const ColumnHighlighting: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Column Highlighting</h3>
|
||||
<p>Highlight specific columns for educational purposes</p>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h4>Highlight ones column:</h4>
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
highlightColumns={[0]}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h4>Highlight tens and hundreds:</h4>
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
highlightColumns={[1, 2]}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>Highlight all columns:</h4>
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
highlightColumns={[0, 1, 2, 3, 4]}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const ColumnLabels: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Column Labels</h3>
|
||||
<p>Add educational labels above columns</p>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h4>Standard place value labels:</h4>
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
columnLabels={['ones', 'tens', 'hundreds', 'thousands', '10k']}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>Custom labels:</h4>
|
||||
<AbacusReact
|
||||
value={789}
|
||||
columns={3}
|
||||
columnLabels={['1s', '10s', '100s']}
|
||||
highlightColumns={[1]}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const ColumnHighlightingWithLabels: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Combined: Column Highlighting + Labels</h3>
|
||||
<p>Perfect for tutorials showing which column to work with</p>
|
||||
|
||||
<AbacusReact
|
||||
value={42}
|
||||
columns={3}
|
||||
highlightColumns={[1]}
|
||||
columnLabels={['ones', 'tens', 'hundreds']}
|
||||
showNumbers={true}
|
||||
/>
|
||||
|
||||
<p style={{ marginTop: '20px', fontStyle: 'italic' }}>
|
||||
"Add 10 to the tens column"
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HOOKS: useAbacusDiff
|
||||
// ============================================================================
|
||||
|
||||
function AbacusDiffDemo() {
|
||||
const [currentValue, setCurrentValue] = useState(5);
|
||||
const targetValue = 23;
|
||||
|
||||
const diff = useAbacusDiff(currentValue, targetValue);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>useAbacusDiff Hook</h3>
|
||||
<p>Automatically calculate which beads need to move</p>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<p><strong>Current value:</strong> {currentValue}</p>
|
||||
<p><strong>Target value:</strong> {targetValue}</p>
|
||||
<p><strong>Instructions:</strong> {diff.summary}</p>
|
||||
<p><strong>Changes needed:</strong> {diff.changes.length}</p>
|
||||
</div>
|
||||
|
||||
<AbacusReact
|
||||
value={currentValue}
|
||||
columns={2}
|
||||
stepBeadHighlights={diff.highlights}
|
||||
showNumbers={true}
|
||||
interactive={true}
|
||||
onValueChange={setCurrentValue}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button onClick={() => setCurrentValue(5)}>Reset to 5</button>
|
||||
{' '}
|
||||
<button onClick={() => setCurrentValue(targetValue)}>Jump to target (23)</button>
|
||||
</div>
|
||||
|
||||
{diff.hasChanges ? (
|
||||
<div style={{ marginTop: '20px', color: '#666' }}>
|
||||
<p><strong>Detailed changes:</strong></p>
|
||||
<pre style={{ fontSize: '12px' }}>
|
||||
{JSON.stringify(diff.changes, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ marginTop: '20px', color: 'green', fontWeight: 'bold' }}>
|
||||
✓ Target reached!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const UseAbacusDiffHook: Story = {
|
||||
render: () => <AbacusDiffDemo />,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HOOKS: useAbacusState
|
||||
// ============================================================================
|
||||
|
||||
function AbacusStateDemo() {
|
||||
const [value, setValue] = useState(123);
|
||||
const state = useAbacusState(value);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>useAbacusState Hook</h3>
|
||||
<p>Convert numbers to bead positions (memoized)</p>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label>
|
||||
Value:
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => setValue(parseInt(e.target.value) || 0)}
|
||||
style={{ marginLeft: '10px', width: '100px' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={3}
|
||||
showNumbers={true}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '20px', fontSize: '14px' }}>
|
||||
<p><strong>Bead State Analysis:</strong></p>
|
||||
<table style={{ borderCollapse: 'collapse', marginTop: '10px' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f0f0f0' }}>
|
||||
<th style={{ border: '1px solid #ccc', padding: '8px' }}>Place</th>
|
||||
<th style={{ border: '1px solid #ccc', padding: '8px' }}>Heaven Active?</th>
|
||||
<th style={{ border: '1px solid #ccc', padding: '8px' }}>Earth Count</th>
|
||||
<th style={{ border: '1px solid #ccc', padding: '8px' }}>Digit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[0, 1, 2].map(place => {
|
||||
const placeState = state[place];
|
||||
const digit = (placeState.heavenActive ? 5 : 0) + placeState.earthActive;
|
||||
const placeName = ['Ones', 'Tens', 'Hundreds'][place];
|
||||
|
||||
return (
|
||||
<tr key={place}>
|
||||
<td style={{ border: '1px solid #ccc', padding: '8px' }}>{placeName}</td>
|
||||
<td style={{ border: '1px solid #ccc', padding: '8px', textAlign: 'center' }}>
|
||||
{placeState.heavenActive ? '✓' : '✗'}
|
||||
</td>
|
||||
<td style={{ border: '1px solid #ccc', padding: '8px', textAlign: 'center' }}>
|
||||
{placeState.earthActive}
|
||||
</td>
|
||||
<td style={{ border: '1px solid #ccc', padding: '8px', textAlign: 'center' }}>
|
||||
{digit}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const UseAbacusStateHook: Story = {
|
||||
render: () => <AbacusStateDemo />,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
function UtilityFunctionsDemo() {
|
||||
const [inputValue, setInputValue] = useState(123);
|
||||
|
||||
const state = numberToAbacusState(inputValue, 5);
|
||||
const backToNumber = abacusStateToNumber(state);
|
||||
|
||||
const fromValue = 42;
|
||||
const toValue = 57;
|
||||
const diff = calculateBeadDiffFromValues(fromValue, toValue);
|
||||
|
||||
const validation1 = validateAbacusValue(inputValue, 5);
|
||||
const validation2 = validateAbacusValue(123456, 5);
|
||||
|
||||
const state1 = numberToAbacusState(100);
|
||||
const state2 = numberToAbacusState(100);
|
||||
const state3 = numberToAbacusState(200);
|
||||
const areEqual1 = areStatesEqual(state1, state2);
|
||||
const areEqual2 = areStatesEqual(state1, state3);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Utility Functions</h3>
|
||||
<p>Low-level functions for working with abacus states</p>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h4>numberToAbacusState & abacusStateToNumber:</h4>
|
||||
<label>
|
||||
Input value:
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(parseInt(e.target.value) || 0)}
|
||||
style={{ marginLeft: '10px', width: '100px' }}
|
||||
/>
|
||||
</label>
|
||||
<pre style={{ background: '#f5f5f5', padding: '10px', marginTop: '10px', fontSize: '12px' }}>
|
||||
{`numberToAbacusState(${inputValue}, 5) = ${JSON.stringify(state, null, 2)}`}
|
||||
</pre>
|
||||
<pre style={{ background: '#f5f5f5', padding: '10px', fontSize: '12px' }}>
|
||||
{`abacusStateToNumber(state) = ${backToNumber}`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h4>calculateBeadDiffFromValues:</h4>
|
||||
<p>From {fromValue} to {toValue}:</p>
|
||||
<pre style={{ background: '#f5f5f5', padding: '10px', fontSize: '12px' }}>
|
||||
{`Summary: ${diff.summary}\nChanges: ${diff.changes.length}`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h4>validateAbacusValue:</h4>
|
||||
<pre style={{ background: '#f5f5f5', padding: '10px', fontSize: '12px' }}>
|
||||
{`validateAbacusValue(${inputValue}, 5):\n isValid: ${validation1.isValid}\n error: ${validation1.error || 'none'}`}
|
||||
</pre>
|
||||
<pre style={{ background: '#f5f5f5', padding: '10px', fontSize: '12px' }}>
|
||||
{`validateAbacusValue(123456, 5):\n isValid: ${validation2.isValid}\n error: ${validation2.error || 'none'}`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>areStatesEqual:</h4>
|
||||
<pre style={{ background: '#f5f5f5', padding: '10px', fontSize: '12px' }}>
|
||||
{`areStatesEqual(state(100), state(100)) = ${areEqual1}\nareStatesEqual(state(100), state(200)) = ${areEqual2}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const UtilityFunctions: Story = {
|
||||
render: () => <UtilityFunctionsDemo />,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMBINED FEATURES
|
||||
// ============================================================================
|
||||
|
||||
export const AllFeaturesShowcase: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState(42);
|
||||
const targetValue = 75;
|
||||
const diff = useAbacusDiff(value, targetValue);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '800px' }}>
|
||||
<h2>All New Features Combined</h2>
|
||||
<p>Theme preset + column highlighting + labels + diff hook</p>
|
||||
|
||||
<div style={{
|
||||
background: '#1a1a1a',
|
||||
padding: '30px',
|
||||
borderRadius: '8px',
|
||||
marginTop: '20px'
|
||||
}}>
|
||||
<h3 style={{ color: 'white', marginTop: 0 }}>
|
||||
Tutorial: Add to reach {targetValue}
|
||||
</h3>
|
||||
|
||||
<p style={{ color: '#ccc' }}>
|
||||
<strong>Current:</strong> {value} → <strong>Target:</strong> {targetValue}
|
||||
</p>
|
||||
<p style={{ color: '#fbbf24' }}>
|
||||
<strong>Instructions:</strong> {diff.summary}
|
||||
</p>
|
||||
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={2}
|
||||
customStyles={ABACUS_THEMES.dark}
|
||||
highlightColumns={[0, 1]}
|
||||
columnLabels={['ones', 'tens']}
|
||||
stepBeadHighlights={diff.highlights}
|
||||
showNumbers={true}
|
||||
interactive={true}
|
||||
onValueChange={setValue}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button onClick={() => setValue(42)}>Reset</button>
|
||||
{' '}
|
||||
<button onClick={() => setValue(targetValue)}>Show Answer</button>
|
||||
</div>
|
||||
|
||||
{!diff.hasChanges && (
|
||||
<p style={{ color: '#4ade80', fontWeight: 'bold', marginTop: '20px' }}>
|
||||
🎉 Perfect! You reached the target!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CompactThemeComparison: Story = {
|
||||
render: () => {
|
||||
const themes: AbacusThemeName[] = ['light', 'dark', 'trophy', 'traditional'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Compact Mode with Different Themes</h3>
|
||||
<p>Inline displays work with all theme presets</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', marginTop: '20px' }}>
|
||||
{themes.map(theme => (
|
||||
<div
|
||||
key={theme}
|
||||
style={{
|
||||
background: theme === 'dark' ? '#1a1a1a' : '#f5f5f5',
|
||||
padding: '15px',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
>
|
||||
<h4 style={{
|
||||
margin: '0 0 15px 0',
|
||||
color: theme === 'dark' ? 'white' : 'black',
|
||||
textTransform: 'capitalize'
|
||||
}}>
|
||||
{theme}:
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: '15px', alignItems: 'center' }}>
|
||||
{[7, 42, 99].map(num => (
|
||||
<div key={num}>
|
||||
<AbacusReact
|
||||
value={num}
|
||||
columns={num < 10 ? 1 : 2}
|
||||
compact={true}
|
||||
hideInactiveBeads={true}
|
||||
customStyles={ABACUS_THEMES[theme]}
|
||||
scaleFactor={0.7}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
383
packages/abacus-react/src/AbacusSVGRenderer.tsx
Normal file
383
packages/abacus-react/src/AbacusSVGRenderer.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* AbacusSVGRenderer - Shared SVG rendering component (Core Architecture)
|
||||
*
|
||||
* This is the **single SVG renderer** used by both AbacusStatic and AbacusReact to guarantee
|
||||
* pixel-perfect visual consistency. It implements dependency injection to support different
|
||||
* bead components while maintaining identical layout.
|
||||
*
|
||||
* ## Architecture Role:
|
||||
* ```
|
||||
* AbacusStatic + AbacusReact
|
||||
* ↓
|
||||
* calculateStandardDimensions() ← Single source for all layout dimensions
|
||||
* ↓
|
||||
* AbacusSVGRenderer ← This component (shared structure)
|
||||
* ↓
|
||||
* calculateBeadPosition() ← Exact positioning for every bead
|
||||
* ↓
|
||||
* BeadComponent (injected) ← AbacusStaticBead OR AbacusAnimatedBead
|
||||
* ```
|
||||
*
|
||||
* ## Key Features:
|
||||
* - ✅ No "use client" directive - works in React Server Components
|
||||
* - ✅ No hooks or state - pure rendering from props
|
||||
* - ✅ Dependency injection for bead components
|
||||
* - ✅ Supports 3D gradients, background glows, overlays (via props)
|
||||
* - ✅ Same props → same dimensions → same positions → same layout
|
||||
*
|
||||
* ## Why This Matters:
|
||||
* Before this architecture, AbacusStatic and AbacusReact had ~700 lines of duplicate
|
||||
* SVG rendering code with separate dimension calculations. This led to layout inconsistencies.
|
||||
* Now they share this single renderer, eliminating duplication and guaranteeing consistency.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import type { AbacusLayoutDimensions } from './AbacusUtils'
|
||||
import type { BeadConfig, AbacusCustomStyles, ValidPlaceValues } from './AbacusReact'
|
||||
import { numberToAbacusState, calculateBeadPosition, type AbacusState } from './AbacusUtils'
|
||||
|
||||
/**
|
||||
* Props that bead components must accept
|
||||
*/
|
||||
export interface BeadComponentProps {
|
||||
bead: BeadConfig
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
shape: 'circle' | 'diamond' | 'square'
|
||||
color: string
|
||||
hideInactiveBeads: boolean
|
||||
customStyle?: {
|
||||
fill?: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
opacity?: number
|
||||
}
|
||||
onClick?: (bead: BeadConfig, event?: React.MouseEvent) => void
|
||||
onMouseEnter?: (bead: BeadConfig, event?: React.MouseEvent) => void
|
||||
onMouseLeave?: (bead: BeadConfig, event?: React.MouseEvent) => void
|
||||
onRef?: (bead: BeadConfig, element: SVGElement | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the SVG renderer
|
||||
*/
|
||||
export interface AbacusSVGRendererProps {
|
||||
// Core data
|
||||
value: number | bigint
|
||||
columns: number
|
||||
state: AbacusState
|
||||
beadConfigs: BeadConfig[][] // Array of columns, each containing beads
|
||||
|
||||
// Layout
|
||||
dimensions: AbacusLayoutDimensions
|
||||
scaleFactor?: number
|
||||
|
||||
// Appearance
|
||||
beadShape: 'circle' | 'diamond' | 'square'
|
||||
colorScheme: string
|
||||
colorPalette: string
|
||||
hideInactiveBeads: boolean
|
||||
frameVisible: boolean
|
||||
showNumbers: boolean
|
||||
customStyles?: AbacusCustomStyles
|
||||
interactive?: boolean // Enable interactive CSS styles
|
||||
|
||||
// Tutorial features
|
||||
highlightColumns?: number[]
|
||||
columnLabels?: string[]
|
||||
|
||||
// 3D Enhancement (optional - only used by AbacusReact)
|
||||
defsContent?: React.ReactNode // Custom defs content (gradients, patterns, etc.)
|
||||
|
||||
// Additional content (overlays, etc.)
|
||||
children?: React.ReactNode // Rendered at the end of the SVG
|
||||
|
||||
// Dependency injection
|
||||
BeadComponent: React.ComponentType<any> // Accept any bead component (base props + extra props)
|
||||
getBeadColor: (bead: BeadConfig, totalColumns: number, colorScheme: string, colorPalette: string) => string
|
||||
|
||||
// Event handlers (optional, passed through to beads)
|
||||
onBeadClick?: (bead: BeadConfig, event?: React.MouseEvent) => void
|
||||
onBeadMouseEnter?: (bead: BeadConfig, event?: React.MouseEvent) => void
|
||||
onBeadMouseLeave?: (bead: BeadConfig, event?: React.MouseEvent) => void
|
||||
onBeadRef?: (bead: BeadConfig, element: SVGElement | null) => void
|
||||
|
||||
// Extra props calculator (for animations, gestures, etc.)
|
||||
// This function is called for each bead to get extra props
|
||||
calculateExtraBeadProps?: (bead: BeadConfig, baseProps: BeadComponentProps) => Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure SVG renderer for abacus
|
||||
* Uses dependency injection to support both static and animated beads
|
||||
*/
|
||||
export function AbacusSVGRenderer({
|
||||
value,
|
||||
columns,
|
||||
state,
|
||||
beadConfigs,
|
||||
dimensions,
|
||||
scaleFactor = 1,
|
||||
beadShape,
|
||||
colorScheme,
|
||||
colorPalette,
|
||||
hideInactiveBeads,
|
||||
frameVisible,
|
||||
showNumbers,
|
||||
customStyles,
|
||||
interactive = false,
|
||||
highlightColumns = [],
|
||||
columnLabels = [],
|
||||
defsContent,
|
||||
children,
|
||||
BeadComponent,
|
||||
getBeadColor,
|
||||
onBeadClick,
|
||||
onBeadMouseEnter,
|
||||
onBeadMouseLeave,
|
||||
onBeadRef,
|
||||
calculateExtraBeadProps,
|
||||
}: AbacusSVGRendererProps) {
|
||||
const { width, height, rodSpacing, barY, beadSize, barThickness, labelHeight, numbersHeight } = dimensions
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={width * scaleFactor}
|
||||
height={height * scaleFactor}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={`abacus-svg ${hideInactiveBeads ? 'hide-inactive-mode' : ''} ${interactive ? 'interactive' : ''}`}
|
||||
style={{ overflow: 'visible', display: 'block' }}
|
||||
>
|
||||
<defs>
|
||||
<style>{`
|
||||
/* CSS-based opacity system for hidden inactive beads */
|
||||
.abacus-bead {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Hidden inactive beads are invisible by default */
|
||||
.hide-inactive-mode .abacus-bead.hidden-inactive {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Interactive abacus: When hovering over the abacus, hidden inactive beads become semi-transparent */
|
||||
.abacus-svg.hide-inactive-mode.interactive:hover .abacus-bead.hidden-inactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Interactive abacus: When hovering over a specific hidden inactive bead, it becomes fully visible */
|
||||
.hide-inactive-mode.interactive .abacus-bead.hidden-inactive:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Non-interactive abacus: Hidden inactive beads always stay at opacity 0 */
|
||||
.abacus-svg.hide-inactive-mode:not(.interactive) .abacus-bead.hidden-inactive {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Custom defs content (for 3D gradients, patterns, etc.) */}
|
||||
{defsContent}
|
||||
</defs>
|
||||
|
||||
{/* Background glow effects - rendered behind everything */}
|
||||
{Array.from({ length: columns }, (_, colIndex) => {
|
||||
const placeValue = columns - 1 - colIndex
|
||||
const columnStyles = customStyles?.columns?.[colIndex]
|
||||
const backgroundGlow = columnStyles?.backgroundGlow
|
||||
|
||||
if (!backgroundGlow) return null
|
||||
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2
|
||||
const glowWidth = rodSpacing + (backgroundGlow.spread || 0)
|
||||
const glowHeight = height + (backgroundGlow.spread || 0)
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={`background-glow-pv${placeValue}`}
|
||||
x={x - glowWidth / 2}
|
||||
y={-(backgroundGlow.spread || 0) / 2}
|
||||
width={glowWidth}
|
||||
height={glowHeight}
|
||||
fill={backgroundGlow.fill || 'rgba(59, 130, 246, 0.2)'}
|
||||
filter={backgroundGlow.blur ? `blur(${backgroundGlow.blur}px)` : 'none'}
|
||||
opacity={backgroundGlow.opacity ?? 0.6}
|
||||
rx={8}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Column highlights */}
|
||||
{highlightColumns.map((colIndex) => {
|
||||
if (colIndex < 0 || colIndex >= columns) return null
|
||||
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2
|
||||
const highlightWidth = rodSpacing * 0.9
|
||||
const highlightHeight = height - labelHeight - numbersHeight
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={`column-highlight-${colIndex}`}
|
||||
x={x - highlightWidth / 2}
|
||||
y={labelHeight}
|
||||
width={highlightWidth}
|
||||
height={highlightHeight}
|
||||
fill="rgba(59, 130, 246, 0.15)"
|
||||
stroke="rgba(59, 130, 246, 0.4)"
|
||||
strokeWidth={2}
|
||||
rx={6}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Column labels */}
|
||||
{columnLabels.map((label, colIndex) => {
|
||||
if (!label || colIndex >= columns) return null
|
||||
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2
|
||||
|
||||
return (
|
||||
<text
|
||||
key={`column-label-${colIndex}`}
|
||||
x={x}
|
||||
y={labelHeight / 2 + 5}
|
||||
textAnchor="middle"
|
||||
fontSize="14"
|
||||
fontWeight="600"
|
||||
fill="rgba(0, 0, 0, 0.7)"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Rods (column posts) */}
|
||||
{frameVisible && beadConfigs.map((_, colIndex) => {
|
||||
const placeValue = columns - 1 - colIndex
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2
|
||||
|
||||
// Apply custom column post styling (column-specific overrides global)
|
||||
const columnStyles = customStyles?.columns?.[colIndex]
|
||||
const globalColumnPosts = customStyles?.columnPosts
|
||||
const rodStyle = {
|
||||
fill: columnStyles?.columnPost?.fill || globalColumnPosts?.fill || 'rgb(0, 0, 0, 0.1)',
|
||||
stroke: columnStyles?.columnPost?.stroke || globalColumnPosts?.stroke || 'none',
|
||||
strokeWidth: columnStyles?.columnPost?.strokeWidth ?? globalColumnPosts?.strokeWidth ?? 0,
|
||||
opacity: columnStyles?.columnPost?.opacity ?? globalColumnPosts?.opacity ?? 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={`rod-pv${placeValue}`}
|
||||
x={x - dimensions.rodWidth / 2}
|
||||
y={labelHeight}
|
||||
width={dimensions.rodWidth}
|
||||
height={height - labelHeight - numbersHeight}
|
||||
fill={rodStyle.fill}
|
||||
stroke={rodStyle.stroke}
|
||||
strokeWidth={rodStyle.strokeWidth}
|
||||
opacity={rodStyle.opacity}
|
||||
className="column-post"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Reckoning bar */}
|
||||
{frameVisible && (
|
||||
<rect
|
||||
x={0}
|
||||
y={barY}
|
||||
width={columns * rodSpacing}
|
||||
height={barThickness}
|
||||
fill={customStyles?.reckoningBar?.fill || 'rgb(0, 0, 0, 0.15)'}
|
||||
stroke={customStyles?.reckoningBar?.stroke || 'rgba(0, 0, 0, 0.3)'}
|
||||
strokeWidth={customStyles?.reckoningBar?.strokeWidth || 2}
|
||||
opacity={customStyles?.reckoningBar?.opacity ?? 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Beads - delegated to injected component */}
|
||||
{beadConfigs.map((columnBeads, colIndex) => {
|
||||
const placeValue = columns - 1 - colIndex
|
||||
// Get column state for inactive earth bead positioning
|
||||
const columnState = state[placeValue] || { heavenActive: false, earthActive: 0 }
|
||||
|
||||
return (
|
||||
<g key={`column-${colIndex}`}>
|
||||
{columnBeads.map((bead, beadIndex) => {
|
||||
// Calculate position using shared utility with column state for accurate positioning
|
||||
const position = calculateBeadPosition(bead, dimensions, { earthActive: columnState.earthActive })
|
||||
const color = getBeadColor(bead, columns, colorScheme, colorPalette)
|
||||
|
||||
// Get custom style for this specific bead
|
||||
const customStyle =
|
||||
bead.type === 'heaven'
|
||||
? customStyles?.heavenBeads
|
||||
: customStyles?.earthBeads
|
||||
|
||||
// Build base props
|
||||
const baseProps: BeadComponentProps = {
|
||||
bead,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
size: beadSize,
|
||||
shape: beadShape,
|
||||
color,
|
||||
hideInactiveBeads,
|
||||
customStyle,
|
||||
onClick: onBeadClick,
|
||||
onMouseEnter: onBeadMouseEnter,
|
||||
onMouseLeave: onBeadMouseLeave,
|
||||
onRef: onBeadRef,
|
||||
}
|
||||
|
||||
// Calculate extra props if provided (for animations, etc.)
|
||||
const extraProps = calculateExtraBeadProps?.(bead, baseProps) || {}
|
||||
|
||||
return (
|
||||
<BeadComponent
|
||||
key={`bead-pv${bead.placeValue}-${bead.type}-${bead.position}`}
|
||||
{...baseProps}
|
||||
{...extraProps}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Column numbers */}
|
||||
{showNumbers && beadConfigs.map((_, colIndex) => {
|
||||
const placeValue = columns - 1 - colIndex
|
||||
const columnState = state[placeValue] || { heavenActive: false, earthActive: 0 }
|
||||
const digit = (columnState.heavenActive ? 5 : 0) + columnState.earthActive
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2
|
||||
|
||||
return (
|
||||
<text
|
||||
key={`number-${colIndex}`}
|
||||
x={x}
|
||||
y={height - numbersHeight / 2 + 5}
|
||||
textAnchor="middle"
|
||||
fontSize={customStyles?.numerals?.fontSize || '16px'}
|
||||
fontWeight={customStyles?.numerals?.fontWeight || '600'}
|
||||
fill={customStyles?.numerals?.color || 'rgba(0, 0, 0, 0.8)'}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{digit}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Additional content (overlays, numbers, etc.) */}
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default AbacusSVGRenderer
|
||||
283
packages/abacus-react/src/AbacusStatic.stories.tsx
Normal file
283
packages/abacus-react/src/AbacusStatic.stories.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { AbacusStatic } from './AbacusStatic'
|
||||
import { ABACUS_THEMES } from './AbacusThemes'
|
||||
|
||||
/**
|
||||
* AbacusStatic - Server Component compatible static abacus
|
||||
*
|
||||
* ## Key Features:
|
||||
* - ✅ Works in React Server Components (no "use client")
|
||||
* - ✅ **Identical layout to AbacusReact** - same props = same exact SVG output
|
||||
* - ✅ No animations, hooks, or client-side JavaScript
|
||||
* - ✅ Lightweight rendering for static displays
|
||||
*
|
||||
* ## Shared Architecture (Zero Duplication!):
|
||||
* Both AbacusStatic and AbacusReact use the **exact same rendering pipeline**:
|
||||
*
|
||||
* ```
|
||||
* calculateStandardDimensions() → AbacusSVGRenderer → calculateBeadPosition()
|
||||
* ↓
|
||||
* ┌───────────────────┴───────────────────┐
|
||||
* ↓ ↓
|
||||
* AbacusStaticBead AbacusAnimatedBead
|
||||
* (Simple SVG) (react-spring)
|
||||
* ```
|
||||
*
|
||||
* - `calculateStandardDimensions()` - Single source of truth for layout (beadSize, gaps, bar position, etc.)
|
||||
* - `AbacusSVGRenderer` - Shared SVG structure with dependency injection for bead components
|
||||
* - `calculateBeadPosition()` - Exact positioning formulas used by both variants
|
||||
* - `AbacusStaticBead` - RSC-compatible simple SVG shapes (this component)
|
||||
* - `AbacusAnimatedBead` - Client component with animations (AbacusReact)
|
||||
*
|
||||
* ## Visual Consistency Guarantee:
|
||||
* Both AbacusStatic and AbacusReact produce **pixel-perfect identical output** for the same props.
|
||||
* This ensures previews match interactive versions, PDFs match web displays, etc.
|
||||
*
|
||||
* **Architecture benefit:** ~560 lines of duplicate code eliminated. Same props = same dimensions = same positions = same layout.
|
||||
*
|
||||
* ## When to Use:
|
||||
* - React Server Components (Next.js App Router)
|
||||
* - Static site generation
|
||||
* - Non-interactive previews
|
||||
* - PDF generation
|
||||
* - Server-side rendering without hydration
|
||||
*/
|
||||
const meta = {
|
||||
title: 'AbacusStatic/Server Component Ready',
|
||||
component: AbacusStatic,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AbacusStatic>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: 123,
|
||||
columns: 'auto',
|
||||
},
|
||||
}
|
||||
|
||||
export const DifferentValues: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap' }}>
|
||||
{[1, 5, 10, 25, 50, 100, 456, 789].map((value) => (
|
||||
<div key={value} style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={value} columns="auto" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ColorSchemes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={456} colorScheme="place-value" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Place Value</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={456} colorScheme="monochrome" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Monochrome</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={456} colorScheme="heaven-earth" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Heaven-Earth</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={456} colorScheme="alternating" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Alternating</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const BeadShapes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={42} beadShape="circle" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Circle</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={42} beadShape="diamond" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Diamond</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={42} beadShape="square" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Square</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const CompactMode: Story = {
|
||||
render: () => (
|
||||
<div style={{ fontSize: '24px', display: 'flex', alignItems: 'center', gap: '15px' }}>
|
||||
<span>The equation:</span>
|
||||
<AbacusStatic value={5} columns={1} compact hideInactiveBeads scaleFactor={0.7} />
|
||||
<span>+</span>
|
||||
<AbacusStatic value={3} columns={1} compact hideInactiveBeads scaleFactor={0.7} />
|
||||
<span>=</span>
|
||||
<AbacusStatic value={8} columns={1} compact hideInactiveBeads scaleFactor={0.7} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const HideInactiveBeads: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={25} hideInactiveBeads={false} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Show All</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={25} hideInactiveBeads />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Hide Inactive</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithThemes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={123} customStyles={ABACUS_THEMES.light} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Light</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', background: '#1e293b', borderRadius: '8px' }}>
|
||||
<AbacusStatic value={123} customStyles={ABACUS_THEMES.dark} />
|
||||
<p style={{ marginTop: '10px', color: '#cbd5e1' }}>Dark</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={123} customStyles={ABACUS_THEMES.trophy} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Trophy</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ColumnHighlightingAndLabels: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '40px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic
|
||||
value={456}
|
||||
highlightColumns={[1]}
|
||||
columnLabels={['ones', 'tens', 'hundreds']}
|
||||
/>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Highlighting tens place</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic
|
||||
value={789}
|
||||
highlightColumns={[0, 2]}
|
||||
columnLabels={['ones', 'tens', 'hundreds']}
|
||||
/>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Multiple highlights</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Scaling: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', alignItems: 'flex-end' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={9} scaleFactor={0.5} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>0.5x</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={9} scaleFactor={1} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>1x</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={9} scaleFactor={1.5} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>1.5x</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ServerComponentExample: Story = {
|
||||
render: () => (
|
||||
<div style={{ maxWidth: '700px', padding: '20px', background: '#f8fafc', borderRadius: '8px' }}>
|
||||
<h3 style={{ marginTop: 0 }}>React Server Component Usage</h3>
|
||||
<pre
|
||||
style={{
|
||||
background: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
padding: '15px',
|
||||
borderRadius: '6px',
|
||||
overflow: 'auto',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{`// app/flashcards/page.tsx (Server Component)
|
||||
import { AbacusStatic } from '@soroban/abacus-react'
|
||||
|
||||
export default function FlashcardsPage() {
|
||||
const numbers = [1, 5, 10, 25, 50, 100]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{numbers.map(num => (
|
||||
<div key={num} className="card">
|
||||
<AbacusStatic
|
||||
value={num}
|
||||
columns="auto"
|
||||
hideInactiveBeads
|
||||
compact
|
||||
/>
|
||||
<p>{num}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ No "use client" needed!
|
||||
// ✅ Rendered on server
|
||||
// ✅ Zero client JavaScript`}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const PreviewCards: Story = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '20px',
|
||||
maxWidth: '900px',
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 25, 50].map((value) => (
|
||||
<div
|
||||
key={value}
|
||||
style={{
|
||||
padding: '15px',
|
||||
background: 'white',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<AbacusStatic value={value} columns="auto" scaleFactor={0.8} hideInactiveBeads />
|
||||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#475569' }}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
184
packages/abacus-react/src/AbacusStatic.tsx
Normal file
184
packages/abacus-react/src/AbacusStatic.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* AbacusStatic - Server Component compatible static abacus
|
||||
*
|
||||
* Shares layout and rendering with AbacusReact through dependency injection.
|
||||
* Uses standard dimensions to ensure same props = same exact visual output.
|
||||
* Reuses: AbacusSVGRenderer for structure, shared dimension/position calculators
|
||||
* Different: No hooks, no animations, no interactions, simplified bead rendering
|
||||
*/
|
||||
|
||||
import { numberToAbacusState, calculateStandardDimensions } from './AbacusUtils'
|
||||
import { AbacusSVGRenderer } from './AbacusSVGRenderer'
|
||||
import { AbacusStaticBead } from './AbacusStaticBead'
|
||||
import type {
|
||||
AbacusCustomStyles,
|
||||
BeadConfig,
|
||||
ValidPlaceValues
|
||||
} from './AbacusReact'
|
||||
|
||||
export interface AbacusStaticConfig {
|
||||
value: number | bigint
|
||||
columns?: number | 'auto'
|
||||
beadShape?: 'circle' | 'diamond' | 'square'
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'alternating' | 'heaven-earth'
|
||||
colorPalette?: 'default' | 'pastel' | 'vibrant' | 'earth-tones'
|
||||
showNumbers?: boolean | 'always' | 'never'
|
||||
hideInactiveBeads?: boolean
|
||||
scaleFactor?: number
|
||||
frameVisible?: boolean
|
||||
compact?: boolean
|
||||
customStyles?: AbacusCustomStyles
|
||||
highlightColumns?: number[]
|
||||
columnLabels?: string[]
|
||||
}
|
||||
|
||||
// Shared color logic (matches AbacusReact)
|
||||
function getBeadColor(
|
||||
bead: BeadConfig,
|
||||
totalColumns: number,
|
||||
colorScheme: string,
|
||||
colorPalette: string
|
||||
): string {
|
||||
const placeValue = bead.placeValue
|
||||
|
||||
// Place-value coloring
|
||||
if (colorScheme === 'place-value') {
|
||||
const colors: Record<string, string[]> = {
|
||||
default: [
|
||||
'#ef4444', // red - ones
|
||||
'#f59e0b', // amber - tens
|
||||
'#10b981', // emerald - hundreds
|
||||
'#3b82f6', // blue - thousands
|
||||
'#8b5cf6', // purple - ten thousands
|
||||
'#ec4899', // pink - hundred thousands
|
||||
'#14b8a6', // teal - millions
|
||||
'#f97316', // orange - ten millions
|
||||
'#6366f1', // indigo - hundred millions
|
||||
'#84cc16', // lime - billions
|
||||
],
|
||||
pastel: [
|
||||
'#fca5a5', '#fcd34d', '#6ee7b7', '#93c5fd', '#c4b5fd',
|
||||
'#f9a8d4', '#5eead4', '#fdba74', '#a5b4fc', '#bef264',
|
||||
],
|
||||
vibrant: [
|
||||
'#dc2626', '#d97706', '#059669', '#2563eb', '#7c3aed',
|
||||
'#db2777', '#0d9488', '#ea580c', '#4f46e5', '#65a30d',
|
||||
],
|
||||
'earth-tones': [
|
||||
'#92400e', '#78350f', '#365314', '#1e3a8a', '#4c1d95',
|
||||
'#831843', '#134e4a', '#7c2d12', '#312e81', '#3f6212',
|
||||
],
|
||||
}
|
||||
|
||||
const palette = colors[colorPalette] || colors.default
|
||||
return palette[placeValue % palette.length]
|
||||
}
|
||||
|
||||
// Heaven-earth coloring
|
||||
if (colorScheme === 'heaven-earth') {
|
||||
return bead.type === 'heaven' ? '#3b82f6' : '#10b981'
|
||||
}
|
||||
|
||||
// Alternating coloring
|
||||
if (colorScheme === 'alternating') {
|
||||
const columnIndex = totalColumns - 1 - placeValue
|
||||
return columnIndex % 2 === 0 ? '#3b82f6' : '#10b981'
|
||||
}
|
||||
|
||||
// Monochrome (default)
|
||||
return '#3b82f6'
|
||||
}
|
||||
|
||||
/**
|
||||
* AbacusStatic - Pure static abacus component (Server Component compatible)
|
||||
*/
|
||||
export function AbacusStatic({
|
||||
value,
|
||||
columns = 'auto',
|
||||
beadShape = 'circle',
|
||||
colorScheme = 'place-value',
|
||||
colorPalette = 'default',
|
||||
showNumbers = true,
|
||||
hideInactiveBeads = false,
|
||||
scaleFactor = 1,
|
||||
frameVisible = true,
|
||||
compact = false,
|
||||
customStyles,
|
||||
highlightColumns = [],
|
||||
columnLabels = [],
|
||||
}: AbacusStaticConfig) {
|
||||
// Calculate columns
|
||||
const valueStr = value.toString().replace('-', '')
|
||||
const minColumns = Math.max(1, valueStr.length)
|
||||
const effectiveColumns = columns === 'auto' ? minColumns : Math.max(columns, minColumns)
|
||||
|
||||
// Use shared utility to convert value to bead states
|
||||
const state = numberToAbacusState(value, effectiveColumns)
|
||||
|
||||
// Generate bead configs (matching AbacusReact's structure)
|
||||
const beadConfigs: BeadConfig[][] = []
|
||||
for (let colIndex = 0; colIndex < effectiveColumns; colIndex++) {
|
||||
const placeValue = (effectiveColumns - 1 - colIndex) as ValidPlaceValues
|
||||
const columnState = state[placeValue] || { heavenActive: false, earthActive: 0 }
|
||||
|
||||
const beads: BeadConfig[] = []
|
||||
|
||||
// Heaven bead
|
||||
beads.push({
|
||||
type: 'heaven',
|
||||
value: 5,
|
||||
active: columnState.heavenActive,
|
||||
position: 0,
|
||||
placeValue,
|
||||
})
|
||||
|
||||
// Earth beads
|
||||
for (let i = 0; i < 4; i++) {
|
||||
beads.push({
|
||||
type: 'earth',
|
||||
value: 1,
|
||||
active: i < columnState.earthActive,
|
||||
position: i,
|
||||
placeValue,
|
||||
})
|
||||
}
|
||||
|
||||
beadConfigs.push(beads)
|
||||
}
|
||||
|
||||
// Calculate standard dimensions (same as AbacusReact!)
|
||||
const dimensions = calculateStandardDimensions({
|
||||
columns: effectiveColumns,
|
||||
scaleFactor,
|
||||
showNumbers: !!showNumbers,
|
||||
columnLabels,
|
||||
})
|
||||
|
||||
// Compact mode hides frame
|
||||
const effectiveFrameVisible = compact ? false : frameVisible
|
||||
|
||||
// Use shared renderer with static bead component
|
||||
return (
|
||||
<AbacusSVGRenderer
|
||||
value={value}
|
||||
columns={effectiveColumns}
|
||||
state={state}
|
||||
beadConfigs={beadConfigs}
|
||||
dimensions={dimensions}
|
||||
scaleFactor={scaleFactor}
|
||||
beadShape={beadShape}
|
||||
colorScheme={colorScheme}
|
||||
colorPalette={colorPalette}
|
||||
hideInactiveBeads={hideInactiveBeads}
|
||||
frameVisible={effectiveFrameVisible}
|
||||
showNumbers={!!showNumbers}
|
||||
customStyles={customStyles}
|
||||
highlightColumns={highlightColumns}
|
||||
columnLabels={columnLabels}
|
||||
BeadComponent={AbacusStaticBead}
|
||||
getBeadColor={getBeadColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AbacusStatic
|
||||
101
packages/abacus-react/src/AbacusStaticBead.tsx
Normal file
101
packages/abacus-react/src/AbacusStaticBead.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* StaticBead - Pure SVG bead with no animations or interactions
|
||||
* Used by AbacusStatic for server-side rendering
|
||||
*/
|
||||
|
||||
import type { BeadConfig, BeadStyle } from './AbacusReact'
|
||||
|
||||
export interface StaticBeadProps {
|
||||
bead: BeadConfig
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
shape: 'diamond' | 'square' | 'circle'
|
||||
color: string
|
||||
customStyle?: BeadStyle
|
||||
hideInactiveBeads?: boolean
|
||||
}
|
||||
|
||||
export function AbacusStaticBead({
|
||||
bead,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
shape,
|
||||
color,
|
||||
customStyle,
|
||||
hideInactiveBeads = false,
|
||||
}: StaticBeadProps) {
|
||||
// Don't render inactive beads if hideInactiveBeads is true
|
||||
if (!bead.active && hideInactiveBeads) {
|
||||
return null
|
||||
}
|
||||
|
||||
const halfSize = size / 2
|
||||
const opacity = bead.active ? (customStyle?.opacity ?? 1) : 0.3
|
||||
const fill = customStyle?.fill || color
|
||||
const stroke = customStyle?.stroke || '#000'
|
||||
const strokeWidth = customStyle?.strokeWidth || 0.5
|
||||
|
||||
// Calculate offset based on shape (matching AbacusReact positioning)
|
||||
const getXOffset = () => {
|
||||
return shape === 'diamond' ? size * 0.7 : halfSize
|
||||
}
|
||||
|
||||
const getYOffset = () => {
|
||||
return halfSize
|
||||
}
|
||||
|
||||
const transform = `translate(${x - getXOffset()}, ${y - getYOffset()})`
|
||||
|
||||
const renderShape = () => {
|
||||
switch (shape) {
|
||||
case 'diamond':
|
||||
return (
|
||||
<polygon
|
||||
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
opacity={opacity}
|
||||
/>
|
||||
)
|
||||
case 'square':
|
||||
return (
|
||||
<rect
|
||||
width={size}
|
||||
height={size}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
rx="1"
|
||||
opacity={opacity}
|
||||
/>
|
||||
)
|
||||
case 'circle':
|
||||
default:
|
||||
return (
|
||||
<circle
|
||||
cx={halfSize}
|
||||
cy={halfSize}
|
||||
r={halfSize}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
opacity={opacity}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
className={`abacus-bead ${bead.active ? 'active' : 'inactive'} ${hideInactiveBeads && !bead.active ? 'hidden-inactive' : ''}`}
|
||||
data-testid={`bead-place-${bead.placeValue}-${bead.type}${bead.type === 'earth' ? `-pos-${bead.position}` : ''}`}
|
||||
transform={transform}
|
||||
style={{ transition: 'opacity 0.2s ease-in-out' }}
|
||||
>
|
||||
{renderShape()}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
115
packages/abacus-react/src/AbacusThemes.ts
Normal file
115
packages/abacus-react/src/AbacusThemes.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Pre-defined theme presets for AbacusReact component
|
||||
* These eliminate the need for manual style object creation
|
||||
*/
|
||||
|
||||
import type { AbacusCustomStyles } from './AbacusReact'
|
||||
|
||||
export const ABACUS_THEMES = {
|
||||
/**
|
||||
* Light theme - solid white frame with subtle gray accents
|
||||
* Best for: Clean, minimalist designs on light backgrounds
|
||||
*/
|
||||
light: {
|
||||
columnPosts: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
} as AbacusCustomStyles,
|
||||
|
||||
/**
|
||||
* Dark theme - translucent white with subtle glow
|
||||
* Best for: Dark backgrounds, hero sections, dramatic presentations
|
||||
*/
|
||||
dark: {
|
||||
columnPosts: {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: 'rgba(255, 255, 255, 0.2)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgba(255, 255, 255, 0.4)',
|
||||
stroke: 'rgba(255, 255, 255, 0.25)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
} as AbacusCustomStyles,
|
||||
|
||||
/**
|
||||
* Trophy/Premium theme - golden frame with warm tones
|
||||
* Best for: Achievements, rewards, celebration contexts
|
||||
*/
|
||||
trophy: {
|
||||
columnPosts: {
|
||||
fill: '#fbbf24',
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: '#fbbf24',
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 4,
|
||||
},
|
||||
} as AbacusCustomStyles,
|
||||
|
||||
/**
|
||||
* Translucent theme - subtle, nearly invisible frame
|
||||
* Best for: Inline displays, minimal UI, focus on beads
|
||||
*/
|
||||
translucent: {
|
||||
columnPosts: {
|
||||
fill: 'rgba(0, 0, 0, 0.05)',
|
||||
stroke: 'rgba(0, 0, 0, 0.1)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgba(0, 0, 0, 0.1)',
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
},
|
||||
} as AbacusCustomStyles,
|
||||
|
||||
/**
|
||||
* Solid/High-contrast theme - black frame for maximum visibility
|
||||
* Best for: Educational contexts, high visibility requirements
|
||||
*/
|
||||
solid: {
|
||||
columnPosts: {
|
||||
fill: 'rgb(0, 0, 0)',
|
||||
stroke: 'rgb(0, 0, 0)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgb(0, 0, 0)',
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
},
|
||||
} as AbacusCustomStyles,
|
||||
|
||||
/**
|
||||
* Traditional/Natural theme - brown wooden appearance
|
||||
* Best for: Traditional soroban aesthetic, cultural contexts
|
||||
*/
|
||||
traditional: {
|
||||
columnPosts: {
|
||||
fill: '#8B5A2B',
|
||||
stroke: '#654321',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: '#8B5A2B',
|
||||
stroke: '#654321',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
} as AbacusCustomStyles,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Theme names type for TypeScript autocomplete
|
||||
*/
|
||||
export type AbacusThemeName = keyof typeof ABACUS_THEMES
|
||||
562
packages/abacus-react/src/AbacusUtils.ts
Normal file
562
packages/abacus-react/src/AbacusUtils.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Utility functions for working with abacus states and calculations
|
||||
* These help convert between numbers and bead positions, calculate diffs, etc.
|
||||
*/
|
||||
|
||||
import type { ValidPlaceValues, BeadHighlight } from './AbacusReact'
|
||||
|
||||
/**
|
||||
* Represents the state of beads in a single column
|
||||
*/
|
||||
export interface BeadState {
|
||||
heavenActive: boolean
|
||||
earthActive: number // 0-4
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the complete state of an abacus
|
||||
* Key is the place value (0 = ones, 1 = tens, etc.)
|
||||
*/
|
||||
export interface AbacusState {
|
||||
[placeValue: number]: BeadState
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a number to abacus state representation
|
||||
* @param value - The number to convert
|
||||
* @param maxPlaces - Maximum number of place values to include
|
||||
* @returns AbacusState object representing the bead positions
|
||||
*/
|
||||
export function numberToAbacusState(value: number | bigint, maxPlaces: number = 5): AbacusState {
|
||||
const state: AbacusState = {}
|
||||
const valueNum = typeof value === 'bigint' ? Number(value) : value
|
||||
|
||||
for (let place = 0; place < maxPlaces; place++) {
|
||||
const placeValueNum = 10 ** place
|
||||
const digit = Math.floor(valueNum / placeValueNum) % 10
|
||||
|
||||
state[place] = {
|
||||
heavenActive: digit >= 5,
|
||||
earthActive: digit >= 5 ? digit - 5 : digit,
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert abacus state to a number
|
||||
* @param state - The abacus state to convert
|
||||
* @returns The numeric value represented by the abacus
|
||||
*/
|
||||
export function abacusStateToNumber(state: AbacusState): number {
|
||||
let total = 0
|
||||
|
||||
for (const placeStr in state) {
|
||||
const place = parseInt(placeStr, 10)
|
||||
const beadState = state[place]
|
||||
const digit = (beadState.heavenActive ? 5 : 0) + beadState.earthActive
|
||||
total += digit * (10 ** place)
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Bead highlight with place value (internal type for calculations)
|
||||
*/
|
||||
export interface PlaceValueBasedBead {
|
||||
placeValue: ValidPlaceValues
|
||||
beadType: 'heaven' | 'earth'
|
||||
position?: 0 | 1 | 2 | 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate which beads need to change between two abacus states
|
||||
* @param startState - The starting abacus state
|
||||
* @param targetState - The target abacus state
|
||||
* @returns Object with arrays of bead additions and removals
|
||||
*/
|
||||
export function calculateBeadChanges(
|
||||
startState: AbacusState,
|
||||
targetState: AbacusState
|
||||
): {
|
||||
additions: PlaceValueBasedBead[]
|
||||
removals: PlaceValueBasedBead[]
|
||||
placeValue: number
|
||||
} {
|
||||
const additions: PlaceValueBasedBead[] = []
|
||||
const removals: PlaceValueBasedBead[] = []
|
||||
let mainPlaceValue = 0
|
||||
|
||||
for (const placeStr in targetState) {
|
||||
const place = parseInt(placeStr, 10) as ValidPlaceValues
|
||||
const start = startState[place] || { heavenActive: false, earthActive: 0 }
|
||||
const target = targetState[place]
|
||||
|
||||
// Check heaven bead changes
|
||||
if (!start.heavenActive && target.heavenActive) {
|
||||
additions.push({ placeValue: place, beadType: 'heaven' })
|
||||
mainPlaceValue = place
|
||||
} else if (start.heavenActive && !target.heavenActive) {
|
||||
removals.push({ placeValue: place, beadType: 'heaven' })
|
||||
mainPlaceValue = place
|
||||
}
|
||||
|
||||
// Check earth bead changes
|
||||
if (target.earthActive > start.earthActive) {
|
||||
// Adding earth beads
|
||||
for (let pos = start.earthActive; pos < target.earthActive; pos++) {
|
||||
additions.push({ placeValue: place, beadType: 'earth', position: pos as 0 | 1 | 2 | 3 })
|
||||
mainPlaceValue = place
|
||||
}
|
||||
} else if (target.earthActive < start.earthActive) {
|
||||
// Removing earth beads
|
||||
for (let pos = start.earthActive - 1; pos >= target.earthActive; pos--) {
|
||||
removals.push({ placeValue: place, beadType: 'earth', position: pos as 0 | 1 | 2 | 3 })
|
||||
mainPlaceValue = place
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { additions, removals, placeValue: mainPlaceValue }
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a bead diff calculation
|
||||
*/
|
||||
export interface BeadDiffResult {
|
||||
placeValue: ValidPlaceValues
|
||||
beadType: 'heaven' | 'earth'
|
||||
position?: number
|
||||
direction: 'activate' | 'deactivate'
|
||||
order: number // Order of operations for animations
|
||||
}
|
||||
|
||||
/**
|
||||
* Output of calculateBeadDiff function
|
||||
*/
|
||||
export interface BeadDiffOutput {
|
||||
changes: BeadDiffResult[]
|
||||
highlights: PlaceValueBasedBead[]
|
||||
hasChanges: boolean
|
||||
summary: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the diff between two abacus states
|
||||
* Returns exactly which beads need to move with directions and order
|
||||
* @param fromState - Starting state
|
||||
* @param toState - Target state
|
||||
* @returns BeadDiffOutput with changes, highlights, and summary
|
||||
*/
|
||||
export function calculateBeadDiff(fromState: AbacusState, toState: AbacusState): BeadDiffOutput {
|
||||
const { additions, removals } = calculateBeadChanges(fromState, toState)
|
||||
|
||||
const changes: BeadDiffResult[] = []
|
||||
const highlights: PlaceValueBasedBead[] = []
|
||||
let order = 0
|
||||
|
||||
// Process removals first (pedagogical order: clear before adding)
|
||||
removals.forEach((removal) => {
|
||||
changes.push({
|
||||
placeValue: removal.placeValue,
|
||||
beadType: removal.beadType,
|
||||
position: removal.position,
|
||||
direction: 'deactivate',
|
||||
order: order++,
|
||||
})
|
||||
|
||||
highlights.push({
|
||||
placeValue: removal.placeValue,
|
||||
beadType: removal.beadType,
|
||||
position: removal.position,
|
||||
})
|
||||
})
|
||||
|
||||
// Process additions second (pedagogical order: add after clearing)
|
||||
additions.forEach((addition) => {
|
||||
changes.push({
|
||||
placeValue: addition.placeValue,
|
||||
beadType: addition.beadType,
|
||||
position: addition.position,
|
||||
direction: 'activate',
|
||||
order: order++,
|
||||
})
|
||||
|
||||
highlights.push({
|
||||
placeValue: addition.placeValue,
|
||||
beadType: addition.beadType,
|
||||
position: addition.position,
|
||||
})
|
||||
})
|
||||
|
||||
// Generate summary
|
||||
const summary = generateDiffSummary(changes)
|
||||
|
||||
return {
|
||||
changes,
|
||||
highlights,
|
||||
hasChanges: changes.length > 0,
|
||||
summary,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bead diff from numeric values
|
||||
* Convenience function for when you have numbers instead of states
|
||||
* @param fromValue - Starting numeric value
|
||||
* @param toValue - Target numeric value
|
||||
* @param maxPlaces - Maximum number of place values to consider
|
||||
* @returns BeadDiffOutput
|
||||
*/
|
||||
export function calculateBeadDiffFromValues(
|
||||
fromValue: number | bigint,
|
||||
toValue: number | bigint,
|
||||
maxPlaces: number = 5
|
||||
): BeadDiffOutput {
|
||||
const fromState = numberToAbacusState(fromValue, maxPlaces)
|
||||
const toState = numberToAbacusState(toValue, maxPlaces)
|
||||
return calculateBeadDiff(fromState, toState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an abacus value is within the supported range
|
||||
* @param value - The value to validate
|
||||
* @param maxPlaces - Maximum number of place values supported
|
||||
* @returns Object with isValid boolean and optional error message
|
||||
*/
|
||||
export function validateAbacusValue(
|
||||
value: number | bigint,
|
||||
maxPlaces: number = 5
|
||||
): { isValid: boolean; error?: string } {
|
||||
const valueNum = typeof value === 'bigint' ? Number(value) : value
|
||||
|
||||
if (valueNum < 0) {
|
||||
return { isValid: false, error: 'Negative values are not supported' }
|
||||
}
|
||||
|
||||
const maxValue = 10 ** maxPlaces - 1
|
||||
if (valueNum > maxValue) {
|
||||
return { isValid: false, error: `Value exceeds maximum for ${maxPlaces} columns (max: ${maxValue})` }
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two abacus states are equal
|
||||
* @param state1 - First state
|
||||
* @param state2 - Second state
|
||||
* @returns true if states are equal
|
||||
*/
|
||||
export function areStatesEqual(state1: AbacusState, state2: AbacusState): boolean {
|
||||
const places1 = Object.keys(state1)
|
||||
.map((k) => parseInt(k, 10))
|
||||
.sort()
|
||||
const places2 = Object.keys(state2)
|
||||
.map((k) => parseInt(k, 10))
|
||||
.sort()
|
||||
|
||||
if (places1.length !== places2.length) return false
|
||||
|
||||
for (const place of places1) {
|
||||
const bead1 = state1[place]
|
||||
const bead2 = state2[place]
|
||||
|
||||
if (!bead2) return false
|
||||
if (bead1.heavenActive !== bead2.heavenActive) return false
|
||||
if (bead1.earthActive !== bead2.earthActive) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Internal helper functions
|
||||
|
||||
function generateDiffSummary(changes: BeadDiffResult[]): string {
|
||||
if (changes.length === 0) {
|
||||
return 'No changes needed'
|
||||
}
|
||||
|
||||
// Sort by order to respect pedagogical sequence
|
||||
const sortedChanges = [...changes].sort((a, b) => a.order - b.order)
|
||||
|
||||
const deactivations = sortedChanges.filter((c) => c.direction === 'deactivate')
|
||||
const activations = sortedChanges.filter((c) => c.direction === 'activate')
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
// Process deactivations first (pedagogical order)
|
||||
if (deactivations.length > 0) {
|
||||
const deactivationsByPlace = groupByPlace(deactivations)
|
||||
Object.entries(deactivationsByPlace).forEach(([place, beads]) => {
|
||||
const placeName = getPlaceName(parseInt(place, 10))
|
||||
const heavenBeads = beads.filter((b) => b.beadType === 'heaven')
|
||||
const earthBeads = beads.filter((b) => b.beadType === 'earth')
|
||||
|
||||
if (heavenBeads.length > 0) {
|
||||
parts.push(`remove heaven bead in ${placeName}`)
|
||||
}
|
||||
if (earthBeads.length > 0) {
|
||||
const count = earthBeads.length
|
||||
parts.push(`remove ${count} earth bead${count > 1 ? 's' : ''} in ${placeName}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Process activations second (pedagogical order)
|
||||
if (activations.length > 0) {
|
||||
const activationsByPlace = groupByPlace(activations)
|
||||
Object.entries(activationsByPlace).forEach(([place, beads]) => {
|
||||
const placeName = getPlaceName(parseInt(place, 10))
|
||||
const heavenBeads = beads.filter((b) => b.beadType === 'heaven')
|
||||
const earthBeads = beads.filter((b) => b.beadType === 'earth')
|
||||
|
||||
if (heavenBeads.length > 0) {
|
||||
parts.push(`add heaven bead in ${placeName}`)
|
||||
}
|
||||
if (earthBeads.length > 0) {
|
||||
const count = earthBeads.length
|
||||
parts.push(`add ${count} earth bead${count > 1 ? 's' : ''} in ${placeName}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return parts.join(', then ')
|
||||
}
|
||||
|
||||
function groupByPlace(changes: BeadDiffResult[]): {
|
||||
[place: string]: BeadDiffResult[]
|
||||
} {
|
||||
return changes.reduce(
|
||||
(groups, change) => {
|
||||
const place = change.placeValue.toString()
|
||||
if (!groups[place]) {
|
||||
groups[place] = []
|
||||
}
|
||||
groups[place].push(change)
|
||||
return groups
|
||||
},
|
||||
{} as { [place: string]: BeadDiffResult[] }
|
||||
)
|
||||
}
|
||||
|
||||
function getPlaceName(place: number): string {
|
||||
switch (place) {
|
||||
case 0:
|
||||
return 'ones column'
|
||||
case 1:
|
||||
return 'tens column'
|
||||
case 2:
|
||||
return 'hundreds column'
|
||||
case 3:
|
||||
return 'thousands column'
|
||||
default:
|
||||
return `place ${place} column`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete layout dimensions for abacus rendering
|
||||
* Used by both static and dynamic rendering to ensure identical layouts
|
||||
*/
|
||||
export interface AbacusLayoutDimensions {
|
||||
// SVG canvas size
|
||||
width: number
|
||||
height: number
|
||||
|
||||
// Bead and spacing
|
||||
beadSize: number
|
||||
rodSpacing: number // Same as columnSpacing
|
||||
rodWidth: number
|
||||
barThickness: number
|
||||
|
||||
// Gaps and positioning
|
||||
heavenEarthGap: number // Gap between heaven and earth sections (where bar sits)
|
||||
activeGap: number // Gap between active beads and reckoning bar
|
||||
inactiveGap: number // Gap between inactive beads and active beads/bar
|
||||
adjacentSpacing: number // Minimal spacing for adjacent beads of same type
|
||||
|
||||
// Key Y positions (absolute coordinates)
|
||||
barY: number // Y position of reckoning bar
|
||||
heavenY: number // Y position where inactive heaven beads rest
|
||||
earthY: number // Y position where inactive earth beads rest
|
||||
|
||||
// Padding and extras
|
||||
padding: number
|
||||
labelHeight: number
|
||||
numbersHeight: number
|
||||
|
||||
// Derived values
|
||||
totalColumns: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate standard layout dimensions for abacus rendering
|
||||
* This ensures both static and dynamic rendering use identical geometry
|
||||
* Same props = same exact visual output
|
||||
*
|
||||
* @param columns - Number of columns in the abacus
|
||||
* @param scaleFactor - Size multiplier (default: 1)
|
||||
* @param showNumbers - Whether numbers are shown below columns
|
||||
* @param columnLabels - Array of column labels (if any)
|
||||
* @returns Complete layout dimensions object
|
||||
*/
|
||||
export function calculateStandardDimensions({
|
||||
columns,
|
||||
scaleFactor = 1,
|
||||
showNumbers = false,
|
||||
columnLabels = [],
|
||||
}: {
|
||||
columns: number
|
||||
scaleFactor?: number
|
||||
showNumbers?: boolean
|
||||
columnLabels?: string[]
|
||||
}): AbacusLayoutDimensions {
|
||||
// Standard dimensions - used by both AbacusStatic and AbacusReact
|
||||
const rodWidth = 3 * scaleFactor
|
||||
const beadSize = 12 * scaleFactor
|
||||
const adjacentSpacing = 0.5 * scaleFactor
|
||||
const columnSpacing = 25 * scaleFactor
|
||||
const heavenEarthGap = 30 * scaleFactor
|
||||
const barThickness = 2 * scaleFactor
|
||||
|
||||
// Positioning gaps
|
||||
const activeGap = 1 * scaleFactor
|
||||
const inactiveGap = 8 * scaleFactor
|
||||
|
||||
// Calculate total dimensions
|
||||
const totalWidth = columns * columnSpacing
|
||||
const baseHeight = heavenEarthGap + 5 * (beadSize + 4 * scaleFactor) + 10 * scaleFactor
|
||||
|
||||
// Extra spacing
|
||||
const numbersSpace = showNumbers ? 40 * scaleFactor : 0
|
||||
const labelSpace = columnLabels.length > 0 ? 30 * scaleFactor : 0
|
||||
const padding = 0 // No padding - keeps layout clean
|
||||
|
||||
const totalHeight = baseHeight + numbersSpace + labelSpace
|
||||
|
||||
// Key Y positions - bar is at heavenEarthGap from top
|
||||
const barY = heavenEarthGap + labelSpace
|
||||
const heavenY = labelSpace + activeGap // Top area for inactive heaven beads
|
||||
const earthY = barY + barThickness + (4 * beadSize) + activeGap + inactiveGap // Bottom area for inactive earth
|
||||
|
||||
return {
|
||||
width: totalWidth,
|
||||
height: totalHeight,
|
||||
beadSize,
|
||||
rodSpacing: columnSpacing,
|
||||
rodWidth,
|
||||
barThickness,
|
||||
heavenEarthGap,
|
||||
activeGap,
|
||||
inactiveGap,
|
||||
adjacentSpacing,
|
||||
barY,
|
||||
heavenY,
|
||||
earthY,
|
||||
padding,
|
||||
labelHeight: labelSpace,
|
||||
numbersHeight: numbersSpace,
|
||||
totalColumns: columns,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use calculateStandardDimensions instead for full layout info
|
||||
* This function only returns width/height for backward compatibility
|
||||
*/
|
||||
export function calculateAbacusDimensions({
|
||||
columns,
|
||||
showNumbers = true,
|
||||
columnLabels = [],
|
||||
}: {
|
||||
columns: number
|
||||
showNumbers?: boolean
|
||||
columnLabels?: string[]
|
||||
}): { width: number; height: number } {
|
||||
// Redirect to new function for backward compatibility
|
||||
const dims = calculateStandardDimensions({ columns, scaleFactor: 1, showNumbers, columnLabels })
|
||||
return { width: dims.width, height: dims.height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified bead config for position calculation
|
||||
* (Compatible with BeadConfig from AbacusReact)
|
||||
*/
|
||||
export interface BeadPositionConfig {
|
||||
type: 'heaven' | 'earth'
|
||||
active: boolean
|
||||
position: number // 0 for heaven, 0-3 for earth
|
||||
placeValue: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Column state needed for earth bead positioning
|
||||
* (Required to calculate inactive earth bead positions correctly)
|
||||
*/
|
||||
export interface ColumnStateForPositioning {
|
||||
earthActive: number // Number of active earth beads (0-4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the x,y position for a bead based on standard layout dimensions
|
||||
* This ensures both static and dynamic rendering position beads identically
|
||||
* Uses exact Typst formulas from the original implementation
|
||||
*
|
||||
* @param bead - Bead configuration
|
||||
* @param dimensions - Layout dimensions from calculateStandardDimensions
|
||||
* @param columnState - Optional column state (required for inactive earth beads)
|
||||
* @returns Object with x and y coordinates
|
||||
*/
|
||||
export function calculateBeadPosition(
|
||||
bead: BeadPositionConfig,
|
||||
dimensions: AbacusLayoutDimensions,
|
||||
columnState?: ColumnStateForPositioning
|
||||
): { x: number; y: number } {
|
||||
const { beadSize, rodSpacing, heavenEarthGap, barThickness, activeGap, inactiveGap, adjacentSpacing, totalColumns } = dimensions
|
||||
|
||||
// X position based on place value (rightmost = ones place)
|
||||
const columnIndex = totalColumns - 1 - bead.placeValue
|
||||
const x = columnIndex * rodSpacing + rodSpacing / 2
|
||||
|
||||
// Y position based on bead type and active state
|
||||
// These formulas match the original Typst implementation exactly
|
||||
if (bead.type === 'heaven') {
|
||||
if (bead.active) {
|
||||
// Active heaven bead: positioned close to reckoning bar (Typst line 175)
|
||||
const y = heavenEarthGap - beadSize / 2 - activeGap
|
||||
return { x, y }
|
||||
} else {
|
||||
// Inactive heaven bead: positioned away from reckoning bar (Typst line 178)
|
||||
const y = heavenEarthGap - inactiveGap - beadSize / 2
|
||||
return { x, y }
|
||||
}
|
||||
} else {
|
||||
// Earth bead positioning (Typst lines 249-261)
|
||||
const earthActive = columnState?.earthActive ?? 0
|
||||
|
||||
if (bead.active) {
|
||||
// Active beads: positioned near reckoning bar, adjacent beads touch (Typst line 251)
|
||||
const y = heavenEarthGap + barThickness + activeGap + beadSize / 2 +
|
||||
bead.position * (beadSize + adjacentSpacing)
|
||||
return { x, y }
|
||||
} else {
|
||||
// Inactive beads: positioned after active beads + gap (Typst lines 254-261)
|
||||
let y: number
|
||||
if (earthActive > 0) {
|
||||
// Position after the last active bead + gap, then adjacent inactive beads touch (Typst line 256)
|
||||
y = heavenEarthGap + barThickness + activeGap + beadSize / 2 +
|
||||
(earthActive - 1) * (beadSize + adjacentSpacing) +
|
||||
beadSize / 2 + inactiveGap + beadSize / 2 +
|
||||
(bead.position - earthActive) * (beadSize + adjacentSpacing)
|
||||
} else {
|
||||
// No active beads: position after reckoning bar + gap, adjacent inactive beads touch (Typst line 259)
|
||||
y = heavenEarthGap + barThickness + inactiveGap + beadSize / 2 +
|
||||
bead.position * (beadSize + adjacentSpacing)
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
export { default as AbacusReact } from "./AbacusReact";
|
||||
export type { AbacusConfig, BeadConfig, AbacusDimensions } from "./AbacusReact";
|
||||
export type {
|
||||
AbacusConfig,
|
||||
BeadConfig,
|
||||
AbacusDimensions,
|
||||
AbacusCustomStyles,
|
||||
BeadStyle,
|
||||
ColumnPostStyle,
|
||||
ReckoningBarStyle,
|
||||
NumeralStyle,
|
||||
ValidPlaceValues,
|
||||
BeadHighlight,
|
||||
StepBeadHighlight,
|
||||
BeadClickEvent,
|
||||
AbacusCallbacks,
|
||||
AbacusOverlay,
|
||||
} from "./AbacusReact";
|
||||
|
||||
export {
|
||||
useAbacusConfig,
|
||||
@@ -17,3 +32,33 @@ export type {
|
||||
|
||||
export { StandaloneBead } from "./StandaloneBead";
|
||||
export type { StandaloneBeadProps } from "./StandaloneBead";
|
||||
|
||||
export { AbacusStatic } from "./AbacusStatic";
|
||||
export type { AbacusStaticConfig } from "./AbacusStatic";
|
||||
|
||||
export { ABACUS_THEMES } from "./AbacusThemes";
|
||||
export type { AbacusThemeName } from "./AbacusThemes";
|
||||
|
||||
export {
|
||||
numberToAbacusState,
|
||||
abacusStateToNumber,
|
||||
calculateBeadChanges,
|
||||
calculateBeadDiff,
|
||||
calculateBeadDiffFromValues,
|
||||
validateAbacusValue,
|
||||
areStatesEqual,
|
||||
calculateAbacusDimensions,
|
||||
calculateStandardDimensions, // NEW: Shared layout calculator
|
||||
calculateBeadPosition, // NEW: Bead position calculator
|
||||
} from "./AbacusUtils";
|
||||
export type {
|
||||
BeadState,
|
||||
AbacusState,
|
||||
BeadDiffResult,
|
||||
BeadDiffOutput,
|
||||
PlaceValueBasedBead,
|
||||
AbacusLayoutDimensions, // NEW: Complete layout dimensions type
|
||||
BeadPositionConfig, // NEW: Bead config for position calculation
|
||||
} from "./AbacusUtils";
|
||||
|
||||
export { useAbacusDiff, useAbacusState } from "./AbacusHooks";
|
||||
|
||||
18
packages/abacus-react/src/static.ts
Normal file
18
packages/abacus-react/src/static.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Server Component compatible exports
|
||||
* This entry point only exports components that work without "use client"
|
||||
*/
|
||||
|
||||
export { AbacusStatic } from './AbacusStatic'
|
||||
export type { AbacusStaticConfig } from './AbacusStatic'
|
||||
export { AbacusStaticBead } from './AbacusStaticBead'
|
||||
export type { StaticBeadProps } from './AbacusStaticBead'
|
||||
|
||||
// Re-export shared utilities that are safe for server components
|
||||
export { numberToAbacusState, calculateAbacusDimensions } from './AbacusUtils'
|
||||
export type {
|
||||
AbacusCustomStyles,
|
||||
BeadConfig,
|
||||
PlaceState,
|
||||
ValidPlaceValues,
|
||||
} from './AbacusReact'
|
||||
@@ -8,10 +8,12 @@ export default defineConfig(async () => {
|
||||
plugins: [react()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
name: "AbacusReact",
|
||||
entry: {
|
||||
index: resolve(__dirname, "src/index.ts"),
|
||||
static: resolve(__dirname, "src/static.ts"),
|
||||
},
|
||||
formats: ["es", "cjs"],
|
||||
fileName: (format) => `index.${format}.js`,
|
||||
fileName: (format, entryName) => `${entryName}.${format === "es" ? "es" : "cjs"}.js`,
|
||||
},
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
|
||||
Reference in New Issue
Block a user