Compare commits
50 Commits
abacus-rea
...
abacus-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac6c3c1376 | ||
|
|
104f3e65d4 | ||
|
|
35b0824fc4 | ||
|
|
b080970d76 | ||
|
|
80657a6604 | ||
|
|
5242f890f7 | ||
|
|
b9416883b6 | ||
|
|
bdca3154f8 | ||
|
|
651b14f630 | ||
|
|
41a3707841 | ||
|
|
7ea8399745 | ||
|
|
e0585b8ac7 | ||
|
|
ec887c895c | ||
|
|
e08fdfd676 | ||
|
|
eaaf17cd4c | ||
|
|
32f51ae739 | ||
|
|
59d594c939 | ||
|
|
1db779c49f | ||
|
|
cd15c70a25 | ||
|
|
602c648adc | ||
|
|
e1bcd24169 | ||
|
|
2df4423684 | ||
|
|
7e54c6f4fc | ||
|
|
8a170833f3 | ||
|
|
1074624b2f | ||
|
|
1021efb715 | ||
|
|
bea4842a29 | ||
|
|
bf1ed6890a | ||
|
|
247c3d9874 | ||
|
|
98863026b7 | ||
|
|
58fc5d8912 | ||
|
|
4559fb121d | ||
|
|
fab227d686 | ||
|
|
11d0c341a8 | ||
|
|
ece2ffb40f | ||
|
|
a80431608d | ||
|
|
9ba1824226 | ||
|
|
17970f6e9a | ||
|
|
770cfc3aca | ||
|
|
2f086ebb82 | ||
|
|
19b9d7a74f | ||
|
|
bf0a0bf01b | ||
|
|
379698fea3 | ||
|
|
ffae9c1bdb | ||
|
|
16ccaf2c8b | ||
|
|
23ae1b0c6f | ||
|
|
e852afddc5 | ||
|
|
645140648a | ||
|
|
be7d4c4713 | ||
|
|
88c0baaad9 |
4182
CHANGELOG.md
4182
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
46
Dockerfile
46
Dockerfile
@@ -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 && \
|
||||
@@ -107,23 +107,60 @@ RUN mkdir -p /bosl2 && \
|
||||
find . -type f ! -name "*.scad" -delete && \
|
||||
find . -type d -empty -delete
|
||||
|
||||
# OpenSCAD builder stage - download and prepare newer OpenSCAD binary
|
||||
FROM node:18-slim AS openscad-builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
ca-certificates \
|
||||
file \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Download latest OpenSCAD AppImage and extract it
|
||||
# Using 2024.11 which has CGAL fixes for intersection operations
|
||||
RUN wget -q https://files.openscad.org/OpenSCAD-2024.11.18-x86_64.AppImage -O /tmp/openscad.AppImage && \
|
||||
chmod +x /tmp/openscad.AppImage && \
|
||||
cd /tmp && \
|
||||
./openscad.AppImage --appimage-extract && \
|
||||
mv squashfs-root/usr/bin/openscad /usr/local/bin/openscad && \
|
||||
mv squashfs-root/usr/lib /usr/local/openscad-lib && \
|
||||
chmod +x /usr/local/bin/openscad
|
||||
|
||||
# Production image - Using Debian base for OpenSCAD availability
|
||||
FROM node:18-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install ONLY runtime dependencies (no build tools)
|
||||
# Using Debian because OpenSCAD is not available in Alpine repos
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-pip \
|
||||
qpdf \
|
||||
openscad \
|
||||
ca-certificates \
|
||||
libgomp1 \
|
||||
libglu1-mesa \
|
||||
libglew2.2 \
|
||||
libfreetype6 \
|
||||
libfontconfig1 \
|
||||
libharfbuzz0b \
|
||||
libxml2 \
|
||||
libzip4 \
|
||||
libdouble-conversion3 \
|
||||
libqt5core5a \
|
||||
libqt5gui5 \
|
||||
libqt5widgets5 \
|
||||
libqt5concurrent5 \
|
||||
libqt5multimedia5 \
|
||||
libqt5network5 \
|
||||
libqt5dbus5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy typst binary from typst-builder stage
|
||||
COPY --from=typst-builder /usr/local/bin/typst /usr/local/bin/typst
|
||||
|
||||
# Copy newer OpenSCAD from openscad-builder stage
|
||||
COPY --from=openscad-builder /usr/local/bin/openscad /usr/local/bin/openscad
|
||||
COPY --from=openscad-builder /usr/local/openscad-lib /usr/local/openscad-lib
|
||||
ENV LD_LIBRARY_PATH=/usr/local/openscad-lib:$LD_LIBRARY_PATH
|
||||
|
||||
# Copy minimized BOSL2 library from bosl2-builder stage
|
||||
RUN mkdir -p /usr/share/openscad/libraries
|
||||
COPY --from=bosl2-builder /bosl2 /usr/share/openscad/libraries/BOSL2
|
||||
@@ -146,9 +183,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/dist ./apps/web/dist
|
||||
# Copy database migrations
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizzle
|
||||
|
||||
# Copy scripts directory (needed for calendar generation)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/scripts ./apps/web/scripts
|
||||
|
||||
# Copy PRODUCTION node_modules only (no dev dependencies)
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
|
||||
|
||||
@@ -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:
|
||||
@@ -56,13 +126,14 @@ When asked to make ANY changes:
|
||||
|
||||
**CRITICAL: The user manages running the dev server, NOT Claude Code.**
|
||||
|
||||
- ❌ DO NOT run `npm run dev` or `npm start`
|
||||
- ❌ DO NOT run `pnpm dev`, `npm run dev`, or `npm start`
|
||||
- ❌ DO NOT attempt to start, stop, or restart the dev server
|
||||
- ❌ DO NOT kill processes on port 3000
|
||||
- ❌ DO NOT use background Bash processes for the dev server
|
||||
- ✅ Make code changes and let the user restart the server when needed
|
||||
- ✅ You may run other commands like `npm run type-check`, `npm run lint`, etc.
|
||||
|
||||
The user will manually start/restart the dev server after you make changes.
|
||||
**The user runs the dev server themselves.** The user will manually start/restart the dev server after you make changes.
|
||||
|
||||
## Details
|
||||
|
||||
|
||||
@@ -168,7 +168,5 @@
|
||||
"ask": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"sqlite"
|
||||
]
|
||||
"enabledMcpjsonServers": ["sqlite"]
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"next": "^14.2.32",
|
||||
"next-auth": "5.0.0-beta.29",
|
||||
"next-intl": "^4.4.0",
|
||||
"openscad-wasm-prebuilt": "^1.2.0",
|
||||
"python-bridge": "^1.1.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
47
apps/web/public/3d-models/abacus-inline.scad
Normal file
47
apps/web/public/3d-models/abacus-inline.scad
Normal file
@@ -0,0 +1,47 @@
|
||||
// Inline version of abacus.scad that doesn't require BOSL2
|
||||
// This version uses a hardcoded bounding box size instead of the bounding_box() function
|
||||
|
||||
// ---- USER CUSTOMIZABLE PARAMETERS ----
|
||||
// These can be overridden via command line: -D 'columns=7' etc.
|
||||
columns = 13; // Total number of columns (1-13, mirrored book design)
|
||||
scale_factor = 1.5; // Overall size scale (preserves aspect ratio)
|
||||
// -----------------------------------------
|
||||
|
||||
stl_path = "/3d-models/simplified.abacus.stl";
|
||||
|
||||
// Known bounding box dimensions of the simplified.abacus.stl file
|
||||
// These were measured from the original file
|
||||
bbox_size = [186, 60, 120]; // [width, depth, height] in STL units
|
||||
|
||||
// Calculate parameters based on column count
|
||||
// The full STL has 13 columns. We want columns/2 per side (mirrored).
|
||||
total_columns_in_stl = 13;
|
||||
columns_per_side = columns / 2;
|
||||
width_scale = columns_per_side / total_columns_in_stl;
|
||||
|
||||
// Column spacing: distance between mirrored halves
|
||||
units_per_column = bbox_size[0] / total_columns_in_stl; // ~14.3 units per column
|
||||
column_spacing = columns_per_side * units_per_column;
|
||||
|
||||
// --- actual model ---
|
||||
module imported() {
|
||||
import(stl_path, convexity = 10);
|
||||
}
|
||||
|
||||
// Create a bounding box manually instead of using BOSL2's bounding_box()
|
||||
module bounding_box_manual() {
|
||||
translate([-bbox_size[0]/2, -bbox_size[1]/2, -bbox_size[2]/2])
|
||||
cube(bbox_size);
|
||||
}
|
||||
|
||||
module half_abacus() {
|
||||
intersection() {
|
||||
scale([width_scale, 1, 1]) bounding_box_manual();
|
||||
imported();
|
||||
}
|
||||
}
|
||||
|
||||
scale([scale_factor, scale_factor, scale_factor]) {
|
||||
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
|
||||
half_abacus();
|
||||
}
|
||||
@@ -1,40 +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
|
||||
*
|
||||
* 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// CLI interface (if run directly)
|
||||
if (require.main === module) {
|
||||
// Only import react-dom/server for CLI usage
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
process.stdout.write(renderToStaticMarkup(generateAbacusElement(value, columns)))
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Generate a complete monthly calendar as a single SVG
|
||||
* This prevents multi-page overflow - one image scales to fit
|
||||
*
|
||||
* Usage: npx tsx scripts/generateCalendarComposite.tsx <month> <year>
|
||||
* Example: npx tsx scripts/generateCalendarComposite.tsx 12 2025
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// CLI interface (if run directly)
|
||||
if (require.main === module) {
|
||||
// Only import react-dom/server for CLI usage
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
const month = parseInt(process.argv[2], 10)
|
||||
const year = parseInt(process.argv[3], 10)
|
||||
|
||||
if (isNaN(month) || isNaN(year) || month < 1 || month > 12) {
|
||||
console.error('Usage: npx tsx scripts/generateCalendarComposite.tsx <month> <year>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(generateCalendarComposite({ month, year, renderToString: renderToStaticMarkup }))
|
||||
}
|
||||
@@ -8,7 +8,13 @@
|
||||
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import {
|
||||
AbacusReact,
|
||||
numberToAbacusState,
|
||||
calculateStandardDimensions,
|
||||
calculateBeadPosition,
|
||||
type BeadPositionConfig,
|
||||
} from '@soroban/abacus-react'
|
||||
|
||||
// Extract just the SVG element content from rendered output
|
||||
function extractSvgContent(markup: string): string {
|
||||
@@ -27,50 +33,88 @@ interface BoundingBox {
|
||||
maxY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bounding box for icon cropping using actual bead position calculations
|
||||
* This replaces fragile regex parsing with deterministic position math
|
||||
*/
|
||||
function getAbacusBoundingBox(
|
||||
svgContent: string,
|
||||
day: number,
|
||||
scaleFactor: number,
|
||||
columns: number
|
||||
): BoundingBox {
|
||||
// Parse column posts: <rect x="..." y="..." width="..." height="..." ... >
|
||||
const postRegex = /<rect\s+x="([^"]+)"\s+y="([^"]+)"\s+width="([^"]+)"\s+height="([^"]+)"/g
|
||||
const postMatches = [...svgContent.matchAll(postRegex)]
|
||||
// Get which beads are active for this day
|
||||
const abacusState = numberToAbacusState(day, columns)
|
||||
|
||||
// Parse active bead transforms: <g class="abacus-bead active" transform="translate(x, y)">
|
||||
const activeBeadRegex =
|
||||
/<g\s+class="abacus-bead active[^"]*"\s+transform="translate\(([^,]+),\s*([^)]+)\)"/g
|
||||
const beadMatches = [...svgContent.matchAll(activeBeadRegex)]
|
||||
// Get layout dimensions
|
||||
const dimensions = calculateStandardDimensions({
|
||||
columns,
|
||||
scaleFactor,
|
||||
showNumbers: false,
|
||||
columnLabels: [],
|
||||
})
|
||||
|
||||
if (beadMatches.length === 0) {
|
||||
// Fallback if no active beads found - show full abacus
|
||||
// Calculate positions of all active beads
|
||||
const activeBeadPositions: Array<{ x: number; y: number }> = []
|
||||
|
||||
for (let placeValue = 0; placeValue < columns; placeValue++) {
|
||||
const columnState = abacusState[placeValue]
|
||||
if (!columnState) continue
|
||||
|
||||
// Heaven bead
|
||||
if (columnState.heavenActive) {
|
||||
const bead: BeadPositionConfig = {
|
||||
type: 'heaven',
|
||||
active: true,
|
||||
position: 0,
|
||||
placeValue,
|
||||
}
|
||||
const pos = calculateBeadPosition(bead, dimensions, { earthActive: columnState.earthActive })
|
||||
activeBeadPositions.push(pos)
|
||||
}
|
||||
|
||||
// Earth beads
|
||||
for (let earthPos = 0; earthPos < columnState.earthActive; earthPos++) {
|
||||
const bead: BeadPositionConfig = {
|
||||
type: 'earth',
|
||||
active: true,
|
||||
position: earthPos,
|
||||
placeValue,
|
||||
}
|
||||
const pos = calculateBeadPosition(bead, dimensions, { earthActive: columnState.earthActive })
|
||||
activeBeadPositions.push(pos)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeBeadPositions.length === 0) {
|
||||
// Fallback if no active beads - show full abacus
|
||||
return { minX: 0, minY: 0, maxX: 50 * scaleFactor, maxY: 120 * scaleFactor }
|
||||
}
|
||||
|
||||
// Bead dimensions (diamond): width ≈ 30px * scaleFactor, height ≈ 21px * scaleFactor
|
||||
const beadHeight = 21.6 * scaleFactor
|
||||
// Calculate bounding box from active bead positions
|
||||
const beadSize = dimensions.beadSize
|
||||
const beadWidth = beadSize * 2.5 // Diamond width is ~2.5x the size parameter
|
||||
const beadHeight = beadSize * 1.8 // Diamond height is ~1.8x the size parameter
|
||||
|
||||
// HORIZONTAL BOUNDS: Always show full width of both columns (fixed for all days)
|
||||
let minX = Infinity
|
||||
let maxX = -Infinity
|
||||
|
||||
for (const match of postMatches) {
|
||||
const x = parseFloat(match[1])
|
||||
const width = parseFloat(match[3])
|
||||
minX = Math.min(minX, x)
|
||||
maxX = Math.max(maxX, x + width)
|
||||
}
|
||||
|
||||
// VERTICAL BOUNDS: Crop to active beads (dynamic based on which beads are active)
|
||||
let minY = Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (const match of beadMatches) {
|
||||
const y = parseFloat(match[2])
|
||||
// Top of topmost active bead to bottom of bottommost active bead
|
||||
minY = Math.min(minY, y)
|
||||
maxY = Math.max(maxY, y + beadHeight)
|
||||
for (const pos of activeBeadPositions) {
|
||||
// Bead center is at pos.x, pos.y
|
||||
// Calculate bounding box for diamond shape
|
||||
minX = Math.min(minX, pos.x - beadWidth / 2)
|
||||
maxX = Math.max(maxX, pos.x + beadWidth / 2)
|
||||
minY = Math.min(minY, pos.y - beadHeight / 2)
|
||||
maxY = Math.max(maxY, pos.y + beadHeight / 2)
|
||||
}
|
||||
|
||||
// HORIZONTAL BOUNDS: Always show full width of all columns (consistent across all days)
|
||||
// Use rod positions for consistent horizontal bounds
|
||||
const rodSpacing = dimensions.rodSpacing
|
||||
minX = rodSpacing / 2 - beadWidth / 2
|
||||
maxX = (columns - 0.5) * rodSpacing + beadWidth / 2
|
||||
|
||||
return { minX, minY, maxX, maxY }
|
||||
}
|
||||
|
||||
@@ -125,8 +169,8 @@ let svgContent = extractSvgContent(abacusMarkup)
|
||||
// Remove !important from CSS (production code policy)
|
||||
svgContent = svgContent.replace(/\s*!important/g, '')
|
||||
|
||||
// Calculate bounding box including posts, bar, and active beads
|
||||
const bbox = getAbacusBoundingBox(svgContent, 1.8, 2)
|
||||
// Calculate bounding box using proper bead position calculations
|
||||
const bbox = getAbacusBoundingBox(day, 1.8, 2)
|
||||
|
||||
// Add minimal padding around active beads (in abacus coordinates)
|
||||
// Less padding below since we want to cut tight to the last bead
|
||||
|
||||
@@ -5,8 +5,8 @@ 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 '@/../../scripts/generateCalendarComposite'
|
||||
import { generateAbacusElement } from '@/../../scripts/generateCalendarAbacus'
|
||||
import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite'
|
||||
import { generateAbacusElement } from '@/utils/calendar/generateCalendarAbacus'
|
||||
|
||||
interface CalendarRequest {
|
||||
month: number
|
||||
@@ -44,7 +44,7 @@ export async function POST(request: NextRequest) {
|
||||
const calendarSvg = generateCalendarComposite({
|
||||
month,
|
||||
year,
|
||||
renderToString: renderToStaticMarkup
|
||||
renderToString: renderToStaticMarkup,
|
||||
})
|
||||
if (!calendarSvg || calendarSvg.trim().length === 0) {
|
||||
throw new Error('Generated empty composite calendar SVG')
|
||||
@@ -88,7 +88,7 @@ export async function POST(request: NextRequest) {
|
||||
// Compile with Typst: stdin for .typ content, stdout for PDF output
|
||||
let pdfBuffer: Buffer
|
||||
try {
|
||||
pdfBuffer = execSync('typst compile - -', {
|
||||
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
|
||||
@@ -130,7 +130,7 @@ export async function POST(request: NextRequest) {
|
||||
{
|
||||
error: 'Failed to generate calendar',
|
||||
message: errorMessage,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: errorStack })
|
||||
...(process.env.NODE_ENV === 'development' && { stack: errorStack }),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -4,7 +4,8 @@ import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
import { generateMonthlyTypst, getDaysInMonth } from '../utils/typstGenerator'
|
||||
import { generateCalendarComposite } from '@/../../scripts/generateCalendarComposite'
|
||||
import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite'
|
||||
import { generateAbacusElement } from '@/utils/calendar/generateCalendarAbacus'
|
||||
|
||||
interface PreviewRequest {
|
||||
month: number
|
||||
@@ -26,34 +27,137 @@ export async function POST(request: NextRequest) {
|
||||
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
|
||||
// Create temp directory for SVG file(s)
|
||||
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,
|
||||
})
|
||||
let typstContent: string
|
||||
|
||||
if (format === 'monthly') {
|
||||
// Generate and write composite SVG
|
||||
const calendarSvg = generateCalendarComposite({
|
||||
month,
|
||||
year,
|
||||
renderToString: renderToStaticMarkup,
|
||||
})
|
||||
writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg)
|
||||
|
||||
typstContent = generateMonthlyTypst({
|
||||
month,
|
||||
year,
|
||||
paperSize: 'us-letter',
|
||||
daysInMonth,
|
||||
})
|
||||
} else {
|
||||
// Daily format: Create a SINGLE composite SVG (like monthly) to avoid multi-image export issue
|
||||
|
||||
// Generate individual abacus SVGs
|
||||
const daySvg = renderToStaticMarkup(generateAbacusElement(1, 2))
|
||||
if (!daySvg || daySvg.trim().length === 0) {
|
||||
throw new Error('Generated empty SVG for day 1')
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
// Create composite SVG with both year and day abacus
|
||||
const monthName = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
][month - 1]
|
||||
const dayOfWeek = new Date(year, month - 1, 1).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
})
|
||||
|
||||
// Extract SVG content (remove outer <svg> tags)
|
||||
const yearSvgContent = yearSvg.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
|
||||
const daySvgContent = daySvg.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
|
||||
|
||||
// Create composite SVG (850x1100 = US Letter aspect ratio)
|
||||
const compositeWidth = 850
|
||||
const compositeHeight = 1100
|
||||
const yearAbacusWidth = 120 // Natural width at scale 1
|
||||
const yearAbacusHeight = 230
|
||||
const dayAbacusWidth = 120
|
||||
const dayAbacusHeight = 230
|
||||
|
||||
const compositeSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${compositeWidth}" height="${compositeHeight}" viewBox="0 0 ${compositeWidth} ${compositeHeight}">
|
||||
<!-- Background -->
|
||||
<rect width="${compositeWidth}" height="${compositeHeight}" fill="white"/>
|
||||
|
||||
<!-- Decorative border -->
|
||||
<rect x="40" y="40" width="${compositeWidth - 80}" height="${compositeHeight - 80}" fill="none" stroke="#2563eb" stroke-width="3" rx="8"/>
|
||||
<rect x="50" y="50" width="${compositeWidth - 100}" height="${compositeHeight - 100}" fill="none" stroke="#2563eb" stroke-width="1" rx="4"/>
|
||||
|
||||
<!-- Header section with background -->
|
||||
<rect x="70" y="70" width="${compositeWidth - 140}" height="120" fill="#eff6ff" stroke="#2563eb" stroke-width="2" rx="6"/>
|
||||
|
||||
<!-- Month name -->
|
||||
<text x="${compositeWidth / 2}" y="125" text-anchor="middle" font-family="Georgia, serif" font-size="48" font-weight="bold" fill="#1e40af" letter-spacing="2">
|
||||
${monthName.toUpperCase()}
|
||||
</text>
|
||||
|
||||
<!-- Year abacus (smaller, in header) -->
|
||||
<svg x="${compositeWidth / 2 - yearAbacusWidth * 0.4}" y="140" width="${yearAbacusWidth * 0.8}" height="${yearAbacusHeight * 0.8}" viewBox="0 0 ${yearAbacusWidth} ${yearAbacusHeight}">
|
||||
${yearSvgContent}
|
||||
</svg>
|
||||
|
||||
<!-- Day of week (large and prominent) -->
|
||||
<text x="${compositeWidth / 2}" y="260" text-anchor="middle" font-family="Georgia, serif" font-size="42" font-weight="bold" fill="#1e3a8a">
|
||||
${dayOfWeek}
|
||||
</text>
|
||||
|
||||
<!-- Day abacus (much larger, main focus) -->
|
||||
<svg x="${compositeWidth / 2 - dayAbacusWidth * 1.25}" y="300" width="${dayAbacusWidth * 2.5}" height="${dayAbacusHeight * 2.5}" viewBox="0 0 ${dayAbacusWidth} ${dayAbacusHeight}">
|
||||
${daySvgContent}
|
||||
</svg>
|
||||
|
||||
<!-- Full date (below day abacus) -->
|
||||
<text x="${compositeWidth / 2}" y="890" text-anchor="middle" font-family="Georgia, serif" font-size="24" font-weight="500" fill="#475569">
|
||||
${monthName} 1, ${year}
|
||||
</text>
|
||||
|
||||
<!-- Notes section with decorative box -->
|
||||
<rect x="70" y="930" width="${compositeWidth - 140}" height="120" fill="#fefce8" stroke="#ca8a04" stroke-width="2" rx="4"/>
|
||||
<text x="90" y="960" font-family="Georgia, serif" font-size="18" font-weight="bold" fill="#854d0e">
|
||||
Notes:
|
||||
</text>
|
||||
<line x1="90" y1="980" x2="${compositeWidth - 90}" y2="980" stroke="#ca8a04" stroke-width="1"/>
|
||||
<line x1="90" y1="1005" x2="${compositeWidth - 90}" y2="1005" stroke="#ca8a04" stroke-width="1"/>
|
||||
<line x1="90" y1="1030" x2="${compositeWidth - 90}" y2="1030" stroke="#ca8a04" stroke-width="1"/>
|
||||
</svg>`
|
||||
|
||||
writeFileSync(join(tempDir, 'daily-preview.svg'), compositeSvg)
|
||||
|
||||
// Use single composite image (like monthly)
|
||||
typstContent = `#set page(
|
||||
paper: "us-letter",
|
||||
margin: (x: 0.5in, y: 0.5in),
|
||||
)
|
||||
|
||||
#align(center + horizon)[
|
||||
#image("daily-preview.svg", width: 100%, fit: "contain")
|
||||
]
|
||||
`
|
||||
}
|
||||
|
||||
// Compile with Typst: stdin for .typ content, stdout for SVG output
|
||||
let svg: string
|
||||
|
||||
@@ -95,43 +95,93 @@ export function generateDailyTypst(config: TypstDailyConfig): string {
|
||||
paper: "${paperConfig.typstName}",
|
||||
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
|
||||
)[
|
||||
// Header: Year
|
||||
#align(center)[
|
||||
#v(1em)
|
||||
#image("year.svg", width: 30%)
|
||||
#set text(font: "Georgia")
|
||||
|
||||
// Decorative borders
|
||||
#rect(
|
||||
width: 100%,
|
||||
height: 100%,
|
||||
stroke: (paint: rgb("#2563eb"), thickness: 3pt),
|
||||
radius: 8pt,
|
||||
inset: 0pt,
|
||||
)[
|
||||
#rect(
|
||||
width: 100%,
|
||||
height: 100%,
|
||||
stroke: (paint: rgb("#2563eb"), thickness: 1pt),
|
||||
radius: 4pt,
|
||||
inset: 10pt,
|
||||
)[
|
||||
#v(10pt)
|
||||
|
||||
// Header section with background
|
||||
#rect(
|
||||
width: 100%,
|
||||
height: 90pt,
|
||||
fill: rgb("#eff6ff"),
|
||||
stroke: (paint: rgb("#2563eb"), thickness: 2pt),
|
||||
radius: 6pt,
|
||||
)[
|
||||
#align(center)[
|
||||
#v(15pt)
|
||||
#text(size: 32pt, weight: "bold", fill: rgb("#1e40af"), tracking: 2pt)[
|
||||
${monthName.toUpperCase()}
|
||||
]
|
||||
#v(5pt)
|
||||
#image("year.svg", width: 15%)
|
||||
]
|
||||
]
|
||||
|
||||
#v(15pt)
|
||||
|
||||
// Day of week (large and prominent)
|
||||
#align(center)[
|
||||
#text(size: 28pt, weight: "bold", fill: rgb("#1e3a8a"))[
|
||||
${dayOfWeek}
|
||||
]
|
||||
]
|
||||
|
||||
#v(10pt)
|
||||
|
||||
// Day abacus (main focus, large)
|
||||
#align(center)[
|
||||
#image("day-${day}.svg", width: 45%)
|
||||
]
|
||||
|
||||
#v(10pt)
|
||||
|
||||
// Full date
|
||||
#align(center)[
|
||||
#text(size: 18pt, weight: 500, fill: rgb("#475569"))[
|
||||
${monthName} ${day}, ${year}
|
||||
]
|
||||
]
|
||||
|
||||
#v(1fr)
|
||||
|
||||
// Notes section with decorative box
|
||||
#rect(
|
||||
width: 100%,
|
||||
height: 90pt,
|
||||
fill: rgb("#fefce8"),
|
||||
stroke: (paint: rgb("#ca8a04"), thickness: 2pt),
|
||||
radius: 4pt,
|
||||
)[
|
||||
#v(8pt)
|
||||
#text(size: 14pt, weight: "bold", fill: rgb("#854d0e"))[
|
||||
#h(10pt) Notes:
|
||||
]
|
||||
#v(8pt)
|
||||
#line(length: 95%, stroke: (paint: rgb("#ca8a04"), thickness: 1pt))
|
||||
#v(8pt)
|
||||
#line(length: 95%, stroke: (paint: rgb("#ca8a04"), thickness: 1pt))
|
||||
#v(8pt)
|
||||
#line(length: 95%, stroke: (paint: rgb("#ca8a04"), thickness: 1pt))
|
||||
]
|
||||
|
||||
#v(10pt)
|
||||
]
|
||||
]
|
||||
|
||||
#v(2em)
|
||||
|
||||
// Main: Day number as large abacus
|
||||
#align(center + horizon)[
|
||||
#image("day-${day}.svg", width: 50%)
|
||||
]
|
||||
|
||||
#v(2em)
|
||||
|
||||
// Footer: Day of week and date
|
||||
#align(center)[
|
||||
#text(size: 18pt, weight: "bold")[${dayOfWeek}]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#text(size: 14pt)[${monthName} ${day}, ${year}]
|
||||
]
|
||||
|
||||
// Notes section
|
||||
#v(3em)
|
||||
#line(length: 100%, stroke: 0.5pt)
|
||||
#v(0.5em)
|
||||
#text(size: 10pt, fill: gray)[Notes:]
|
||||
#v(0.5em)
|
||||
#line(length: 100%, stroke: 0.5pt)
|
||||
#v(1em)
|
||||
#line(length: 100%, stroke: 0.5pt)
|
||||
#v(1em)
|
||||
#line(length: 100%, stroke: 0.5pt)
|
||||
#v(1em)
|
||||
#line(length: 100%, stroke: 0.5pt)
|
||||
]
|
||||
|
||||
${day < daysInMonth ? '' : ''}`
|
||||
@@ -141,7 +191,5 @@ ${day < daysInMonth ? '' : ''}`
|
||||
}
|
||||
}
|
||||
|
||||
return `#set text(font: "Arial")
|
||||
${pages}
|
||||
`
|
||||
return pages
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { JobMonitor } from '@/components/3d-print/JobMonitor'
|
||||
import { STLPreview } from '@/components/3d-print/STLPreview'
|
||||
import { useState } from 'react'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
export default function ThreeDPrintPage() {
|
||||
const t = useTranslations('create.abacus')
|
||||
// New unified parameter system
|
||||
const [columns, setColumns] = useState(13)
|
||||
const [columns, setColumns] = useState(4)
|
||||
const [scaleFactor, setScaleFactor] = useState(1.5)
|
||||
const [widthMm, setWidthMm] = useState<number | undefined>(undefined)
|
||||
const [format, setFormat] = useState<'stl' | '3mf' | 'scad'>('stl')
|
||||
@@ -86,13 +88,10 @@ export default function ThreeDPrintPage() {
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
Customize Your 3D Printable Abacus
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
|
||||
<p className={css({ mb: 6, color: 'gray.600' })}>
|
||||
Adjust the parameters below to customize your abacus, then generate and download the file
|
||||
for 3D printing.
|
||||
</p>
|
||||
<p className={css({ mb: 6, color: 'gray.600' })}>{t('pageSubtitle')}</p>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
@@ -118,7 +117,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 4,
|
||||
})}
|
||||
>
|
||||
Customization Parameters
|
||||
{t('customizationTitle')}
|
||||
</h2>
|
||||
|
||||
{/* Number of Columns */}
|
||||
@@ -130,7 +129,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
Number of Columns: {columns}
|
||||
{t('columns.label', { count: columns })}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
@@ -148,7 +147,7 @@ export default function ThreeDPrintPage() {
|
||||
mt: 1,
|
||||
})}
|
||||
>
|
||||
Total number of columns in the abacus (1-13)
|
||||
{t('columns.help')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,7 +160,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
Scale Factor: {scaleFactor.toFixed(1)}x
|
||||
{t('scaleFactor.label', { factor: scaleFactor.toFixed(1) })}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
@@ -179,7 +178,7 @@ export default function ThreeDPrintPage() {
|
||||
mt: 1,
|
||||
})}
|
||||
>
|
||||
Overall size multiplier (preserves aspect ratio, larger values = bigger file size)
|
||||
{t('scaleFactor.help')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +191,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
Width in mm (optional)
|
||||
{t('widthMm.label')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -204,7 +203,7 @@ export default function ThreeDPrintPage() {
|
||||
const value = e.target.value
|
||||
setWidthMm(value ? Number.parseFloat(value) : undefined)
|
||||
}}
|
||||
placeholder="Leave empty to use scale factor"
|
||||
placeholder={t('widthMm.placeholder')}
|
||||
className={css({
|
||||
width: '100%',
|
||||
px: 3,
|
||||
@@ -225,7 +224,7 @@ export default function ThreeDPrintPage() {
|
||||
mt: 1,
|
||||
})}
|
||||
>
|
||||
Specify exact width in millimeters (overrides scale factor)
|
||||
{t('widthMm.help')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +237,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
Output Format
|
||||
{t('format.label')}
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: 2, flexWrap: 'wrap' })}>
|
||||
<button
|
||||
@@ -308,7 +307,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 3,
|
||||
})}
|
||||
>
|
||||
3MF Color Customization
|
||||
{t('colors.title')}
|
||||
</h3>
|
||||
|
||||
{/* Frame Color */}
|
||||
@@ -320,7 +319,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 1,
|
||||
})}
|
||||
>
|
||||
Frame Color
|
||||
{t('colors.frame')}
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
|
||||
<input
|
||||
@@ -356,7 +355,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 1,
|
||||
})}
|
||||
>
|
||||
Heaven Bead Color
|
||||
{t('colors.heavenBead')}
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
|
||||
<input
|
||||
@@ -392,7 +391,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 1,
|
||||
})}
|
||||
>
|
||||
Earth Bead Color
|
||||
{t('colors.earthBead')}
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
|
||||
<input
|
||||
@@ -428,7 +427,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 1,
|
||||
})}
|
||||
>
|
||||
Decoration Color
|
||||
{t('colors.decoration')}
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
|
||||
<input
|
||||
@@ -476,7 +475,7 @@ export default function ThreeDPrintPage() {
|
||||
_hover: { bg: isGenerating ? 'blue.600' : 'blue.700' },
|
||||
})}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate File'}
|
||||
{isGenerating ? t('generate.generating') : t('generate.button')}
|
||||
</button>
|
||||
|
||||
{/* Job Status */}
|
||||
@@ -505,7 +504,7 @@ export default function ThreeDPrintPage() {
|
||||
_hover: { bg: 'green.700' },
|
||||
})}
|
||||
>
|
||||
Download {format.toUpperCase()}
|
||||
{t('download', { format: format.toUpperCase() })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -544,7 +543,7 @@ export default function ThreeDPrintPage() {
|
||||
mb: 4,
|
||||
})}
|
||||
>
|
||||
Preview
|
||||
{t('preview.title')}
|
||||
</h2>
|
||||
<STLPreview columns={columns} scaleFactor={scaleFactor} />
|
||||
<div
|
||||
@@ -554,17 +553,9 @@ export default function ThreeDPrintPage() {
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
<p className={css({ mb: 2 })}>
|
||||
<strong>Live Preview:</strong> The preview updates automatically as you adjust
|
||||
parameters (with a 1-second delay). This shows the exact mirrored book-fold design
|
||||
that will be generated.
|
||||
</p>
|
||||
<p className={css({ mb: 2 })}>
|
||||
<strong>Note:</strong> Preview generation requires OpenSCAD. If you see an error,
|
||||
the preview feature only works in production (Docker). The download functionality
|
||||
will still work when deployed.
|
||||
</p>
|
||||
<p>Use your mouse to rotate and zoom the 3D model.</p>
|
||||
<p className={css({ mb: 2 })}>{t('preview.liveDescription')}</p>
|
||||
<p className={css({ mb: 2 })}>{t('preview.note')}</p>
|
||||
<p>{t('preview.instructions')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { AbacusDisplayDropdown } from '@/components/AbacusDisplayDropdown'
|
||||
@@ -17,21 +18,6 @@ interface CalendarConfigPanelProps {
|
||||
onGenerate: () => void
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
]
|
||||
|
||||
export function CalendarConfigPanel({
|
||||
month,
|
||||
year,
|
||||
@@ -44,8 +30,24 @@ export function CalendarConfigPanel({
|
||||
onPaperSizeChange,
|
||||
onGenerate,
|
||||
}: CalendarConfigPanelProps) {
|
||||
const t = useTranslations('calendar')
|
||||
const abacusConfig = useAbacusConfig()
|
||||
|
||||
const MONTHS = [
|
||||
t('months.january'),
|
||||
t('months.february'),
|
||||
t('months.march'),
|
||||
t('months.april'),
|
||||
t('months.may'),
|
||||
t('months.june'),
|
||||
t('months.july'),
|
||||
t('months.august'),
|
||||
t('months.september'),
|
||||
t('months.october'),
|
||||
t('months.november'),
|
||||
t('months.december'),
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="calendar-config-panel"
|
||||
@@ -75,7 +77,7 @@ export function CalendarConfigPanel({
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
Calendar Format
|
||||
{t('format.title')}
|
||||
</legend>
|
||||
<div
|
||||
className={css({
|
||||
@@ -104,7 +106,7 @@ export function CalendarConfigPanel({
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
<span>Monthly Calendar (one page per month)</span>
|
||||
<span>{t('format.monthly')}</span>
|
||||
</label>
|
||||
<label
|
||||
className={css({
|
||||
@@ -126,7 +128,7 @@ export function CalendarConfigPanel({
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
<span>Daily Calendar (one page per day)</span>
|
||||
<span>{t('format.daily')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -148,7 +150,7 @@ export function CalendarConfigPanel({
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
Date
|
||||
{t('date.title')}
|
||||
</legend>
|
||||
<div
|
||||
className={css({
|
||||
@@ -216,7 +218,7 @@ export function CalendarConfigPanel({
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
Paper Size
|
||||
{t('paperSize.title')}
|
||||
</legend>
|
||||
<select
|
||||
data-element="paper-size-select"
|
||||
@@ -236,10 +238,10 @@ export function CalendarConfigPanel({
|
||||
_hover: { borderColor: 'gray.500' },
|
||||
})}
|
||||
>
|
||||
<option value="us-letter">US Letter (8.5" × 11")</option>
|
||||
<option value="a4">A4 (210mm × 297mm)</option>
|
||||
<option value="a3">A3 (297mm × 420mm)</option>
|
||||
<option value="tabloid">Tabloid (11" × 17")</option>
|
||||
<option value="us-letter">{t('paperSize.usLetter')}</option>
|
||||
<option value="a4">{t('paperSize.a4')}</option>
|
||||
<option value="a3">{t('paperSize.a3')}</option>
|
||||
<option value="tabloid">{t('paperSize.tabloid')}</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
@@ -259,7 +261,7 @@ export function CalendarConfigPanel({
|
||||
color: 'gray.300',
|
||||
})}
|
||||
>
|
||||
Calendar abacus style preview:
|
||||
{t('styling.preview')}
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
@@ -312,7 +314,7 @@ export function CalendarConfigPanel({
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isGenerating ? 'Generating PDF...' : 'Generate PDF Calendar'}
|
||||
{isGenerating ? t('generate.generating') : t('generate.button')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
interface CalendarPreviewProps {
|
||||
@@ -10,7 +11,11 @@ interface CalendarPreviewProps {
|
||||
previewSvg: string | null
|
||||
}
|
||||
|
||||
async function fetchTypstPreview(month: number, year: number, format: string): Promise<string | null> {
|
||||
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' },
|
||||
@@ -18,7 +23,8 @@ async function fetchTypstPreview(month: number, year: number, format: string): P
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch preview')
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || errorData.message || 'Failed to fetch preview')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -26,16 +32,19 @@ async function fetchTypstPreview(month: number, year: number, format: string): P
|
||||
}
|
||||
|
||||
export function CalendarPreview({ month, year, format, previewSvg }: CalendarPreviewProps) {
|
||||
// Use React Query with Suspense to fetch Typst-generated preview
|
||||
const { data: typstPreviewSvg } = useSuspenseQuery({
|
||||
const t = useTranslations('calendar')
|
||||
// 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', // Run on client for both formats
|
||||
})
|
||||
|
||||
// Use generated PDF SVG if available, otherwise use Typst live preview
|
||||
const displaySvg = previewSvg || typstPreviewSvg
|
||||
|
||||
if (!displaySvg) {
|
||||
// Show loading state while fetching preview
|
||||
if (isLoading || !displaySvg) {
|
||||
return (
|
||||
<div
|
||||
data-component="calendar-preview"
|
||||
@@ -57,7 +66,7 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{format === 'daily' ? 'Daily format - preview after generation' : 'No preview available'}
|
||||
{isLoading ? t('preview.loading') : t('preview.noPreview')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -84,7 +93,11 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{previewSvg ? 'Generated PDF' : 'Live Preview'}
|
||||
{previewSvg
|
||||
? t('preview.generatedPdf')
|
||||
: format === 'daily'
|
||||
? t('preview.livePreviewFirstDay')
|
||||
: t('preview.livePreview')}
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
@@ -8,6 +9,7 @@ import { CalendarConfigPanel } from './components/CalendarConfigPanel'
|
||||
import { CalendarPreview } from './components/CalendarPreview'
|
||||
|
||||
export default function CalendarCreatorPage() {
|
||||
const t = useTranslations('calendar')
|
||||
const currentDate = new Date()
|
||||
const abacusConfig = useAbacusConfig()
|
||||
const [month, setMonth] = useState(currentDate.getMonth() + 1) // 1-12
|
||||
@@ -17,6 +19,19 @@ export default function CalendarCreatorPage() {
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [previewSvg, setPreviewSvg] = useState<string | null>(null)
|
||||
|
||||
// Detect default paper size based on user's locale (client-side only)
|
||||
useEffect(() => {
|
||||
// Get user's locale
|
||||
const locale = navigator.language || navigator.languages?.[0] || 'en-US'
|
||||
const country = locale.split('-')[1]?.toUpperCase()
|
||||
|
||||
// Countries that use US Letter (8.5" × 11")
|
||||
const letterCountries = ['US', 'CA', 'MX', 'GT', 'PA', 'DO', 'PR', 'PH']
|
||||
|
||||
const detectedSize = letterCountries.includes(country || '') ? 'us-letter' : 'a4'
|
||||
setPaperSize(detectedSize)
|
||||
}, [])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
@@ -42,7 +57,7 @@ export default function CalendarCreatorPage() {
|
||||
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 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')
|
||||
@@ -54,7 +69,9 @@ export default function CalendarCreatorPage() {
|
||||
document.body.removeChild(a)
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar:', error)
|
||||
alert(`Failed to generate calendar: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
alert(
|
||||
`Failed to generate calendar: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
@@ -64,12 +81,12 @@ export default function CalendarCreatorPage() {
|
||||
<PageWithNav navTitle="Create" navEmoji="📅">
|
||||
<div
|
||||
data-component="calendar-creator"
|
||||
className={css({
|
||||
className={`with-fixed-nav ${css({
|
||||
minHeight: '100vh',
|
||||
bg: 'gray.900',
|
||||
color: 'white',
|
||||
padding: '2rem',
|
||||
})}
|
||||
})}`}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -93,7 +110,7 @@ export default function CalendarCreatorPage() {
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
Create Abacus Calendar
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
@@ -101,7 +118,7 @@ export default function CalendarCreatorPage() {
|
||||
color: 'gray.300',
|
||||
})}
|
||||
>
|
||||
Generate printable calendars with abacus date numbers
|
||||
{t('pageSubtitle')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -128,35 +145,7 @@ export default function CalendarCreatorPage() {
|
||||
/>
|
||||
|
||||
{/* Preview */}
|
||||
<Suspense
|
||||
fallback={
|
||||
<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',
|
||||
})}
|
||||
>
|
||||
Loading preview...
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CalendarPreview month={month} year={year} format={format} previewSvg={previewSvg} />
|
||||
</Suspense>
|
||||
<CalendarPreview month={month} year={year} format={format} previewSvg={previewSvg} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useForm } from '@tanstack/react-form'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { ConfigurationFormWithoutGenerate } from '@/components/ConfigurationFormWithoutGenerate'
|
||||
import { GenerationProgress } from '@/components/GenerationProgress'
|
||||
@@ -104,6 +105,7 @@ function validateAndCompleteConfig(formState: FlashcardFormState): FlashcardConf
|
||||
type GenerationStatus = 'idle' | 'generating' | 'error'
|
||||
|
||||
export default function CreatePage() {
|
||||
const t = useTranslations('create.flashcards')
|
||||
const [generationStatus, setGenerationStatus] = useState<GenerationStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const globalConfig = useAbacusConfig()
|
||||
@@ -184,7 +186,7 @@ export default function CreatePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle="Create Flashcards" navEmoji="✨">
|
||||
<PageWithNav navTitle={t('navTitle')} navEmoji="✨">
|
||||
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
|
||||
{/* Main Content */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '8' })}>
|
||||
@@ -197,7 +199,7 @@ export default function CreatePage() {
|
||||
color: 'gray.900',
|
||||
})}
|
||||
>
|
||||
Create Your Flashcards
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
@@ -205,7 +207,7 @@ export default function CreatePage() {
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Configure content and style, preview instantly, then generate your flashcards
|
||||
{t('pageSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,7 +250,7 @@ export default function CreatePage() {
|
||||
color: 'gray.900',
|
||||
})}
|
||||
>
|
||||
🎨 Visual Style
|
||||
{t('stylePanel.title')}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
@@ -256,7 +258,7 @@ export default function CreatePage() {
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
See changes instantly in the preview
|
||||
{t('stylePanel.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -337,12 +339,12 @@ export default function CreatePage() {
|
||||
animation: 'spin 1s linear infinite',
|
||||
})}
|
||||
/>
|
||||
Generating Your Flashcards...
|
||||
{t('generate.generating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={css({ fontSize: 'xl' })}>✨</div>
|
||||
Generate Flashcards
|
||||
{t('generate.button')}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -374,7 +376,7 @@ export default function CreatePage() {
|
||||
color: 'red.800',
|
||||
})}
|
||||
>
|
||||
Generation Failed
|
||||
{t('error.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p
|
||||
@@ -399,7 +401,7 @@ export default function CreatePage() {
|
||||
_hover: { bg: 'red.700' },
|
||||
})}
|
||||
>
|
||||
Try Again
|
||||
{t('error.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
export default function CreateHubPage() {
|
||||
const t = useTranslations('create.hub')
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle="Create" navEmoji="✨">
|
||||
<div
|
||||
@@ -81,7 +84,7 @@ export default function CreateHubPage() {
|
||||
letterSpacing: 'tight',
|
||||
})}
|
||||
>
|
||||
Create Your Learning Tools
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
@@ -93,8 +96,7 @@ export default function CreateHubPage() {
|
||||
textShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
})}
|
||||
>
|
||||
Design custom flashcards or 3D printable abacus models to enhance your learning
|
||||
experience
|
||||
{t('pageSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +164,7 @@ export default function CreateHubPage() {
|
||||
letterSpacing: 'tight',
|
||||
})}
|
||||
>
|
||||
Flashcard Creator
|
||||
{t('flashcards.title')}
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
@@ -174,8 +176,7 @@ export default function CreateHubPage() {
|
||||
lineHeight: '1.7',
|
||||
})}
|
||||
>
|
||||
Design custom flashcards with abacus visualizations. Perfect for learning and
|
||||
teaching number recognition and arithmetic.
|
||||
{t('flashcards.description')}
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
@@ -212,7 +213,7 @@ export default function CreateHubPage() {
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
Customizable number ranges
|
||||
{t('flashcards.feature1')}
|
||||
</li>
|
||||
<li
|
||||
className={css({
|
||||
@@ -239,7 +240,7 @@ export default function CreateHubPage() {
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
Multiple styles and layouts
|
||||
{t('flashcards.feature2')}
|
||||
</li>
|
||||
<li
|
||||
className={css({
|
||||
@@ -266,7 +267,7 @@ export default function CreateHubPage() {
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
Print-ready PDF generation
|
||||
{t('flashcards.feature3')}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -296,7 +297,7 @@ export default function CreateHubPage() {
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>Create Flashcards</span>
|
||||
<span>{t('flashcards.button')}</span>
|
||||
<span className={css({ fontSize: 'lg' })}>→</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,7 +360,7 @@ export default function CreateHubPage() {
|
||||
letterSpacing: 'tight',
|
||||
})}
|
||||
>
|
||||
3D Abacus Creator
|
||||
{t('abacus.title')}
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
@@ -371,8 +372,7 @@ export default function CreateHubPage() {
|
||||
lineHeight: '1.7',
|
||||
})}
|
||||
>
|
||||
Customize and download 3D printable abacus models. Choose your size, columns, and
|
||||
colors for the perfect learning tool.
|
||||
{t('abacus.description')}
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
@@ -409,7 +409,7 @@ export default function CreateHubPage() {
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
Adjustable size and columns
|
||||
{t('abacus.feature1')}
|
||||
</li>
|
||||
<li
|
||||
className={css({
|
||||
@@ -436,7 +436,7 @@ export default function CreateHubPage() {
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
3MF color customization
|
||||
{t('abacus.feature2')}
|
||||
</li>
|
||||
<li
|
||||
className={css({
|
||||
@@ -463,7 +463,7 @@ export default function CreateHubPage() {
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
Live 3D preview
|
||||
{t('abacus.feature3')}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -493,7 +493,7 @@ export default function CreateHubPage() {
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>Create 3D Model</span>
|
||||
<span>{t('abacus.button')}</span>
|
||||
<span className={css({ fontSize: 'lg' })}>→</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -556,7 +556,7 @@ export default function CreateHubPage() {
|
||||
letterSpacing: 'tight',
|
||||
})}
|
||||
>
|
||||
Abacus Calendar
|
||||
{t('calendar.title')}
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
@@ -568,8 +568,7 @@ export default function CreateHubPage() {
|
||||
lineHeight: '1.7',
|
||||
})}
|
||||
>
|
||||
Generate printable calendars where every date is shown as an abacus. Perfect for
|
||||
teaching number representation.
|
||||
{t('calendar.description')}
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
@@ -606,7 +605,7 @@ export default function CreateHubPage() {
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
Monthly or daily formats
|
||||
{t('calendar.feature1')}
|
||||
</li>
|
||||
<li
|
||||
className={css({
|
||||
@@ -633,7 +632,7 @@ export default function CreateHubPage() {
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
Multiple paper sizes
|
||||
{t('calendar.feature2')}
|
||||
</li>
|
||||
<li
|
||||
className={css({
|
||||
@@ -660,7 +659,7 @@ export default function CreateHubPage() {
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
Uses your abacus styling
|
||||
{t('calendar.feature3')}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -690,7 +689,7 @@ export default function CreateHubPage() {
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>Create Calendar</span>
|
||||
<span>{t('calendar.button')}</span>
|
||||
<span className={css({ fontSize: 'lg' })}>→</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
/* Import Panda CSS generated styles */
|
||||
@import "../../styled-system/styles.css";
|
||||
|
||||
/* Layout variables */
|
||||
:root {
|
||||
/* Navigation bar heights - used by both the nav itself and content padding */
|
||||
--app-nav-height-full: 72px;
|
||||
--app-nav-height-minimal: 92px;
|
||||
}
|
||||
|
||||
/* Utility class for pages with fixed nav */
|
||||
.with-fixed-nav {
|
||||
padding-top: var(--app-nav-height, 80px);
|
||||
}
|
||||
|
||||
/* Custom global styles */
|
||||
body {
|
||||
font-family:
|
||||
|
||||
@@ -245,7 +245,7 @@ export function ReadingNumbersGuide() {
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
p: '2',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -257,27 +257,19 @@ export function ReadingNumbersGuide() {
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.600',
|
||||
mb: '3',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{example.num}
|
||||
</div>
|
||||
|
||||
{/* Aspect ratio container for soroban - roughly 1:3 ratio */}
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
aspectRatio: '1/2.8',
|
||||
maxW: '120px',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
flex: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
my: '2',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
@@ -286,7 +278,7 @@ export function ReadingNumbersGuide() {
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={0.8}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
@@ -299,7 +291,7 @@ export function ReadingNumbersGuide() {
|
||||
color: 'gray.600',
|
||||
lineHeight: 'tight',
|
||||
textAlign: 'center',
|
||||
mt: 'auto',
|
||||
mt: '2',
|
||||
})}
|
||||
>
|
||||
{t(`singleDigits.examples.${example.descKey}`)}
|
||||
@@ -469,7 +461,7 @@ export function ReadingNumbersGuide() {
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.300',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
p: '2',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -481,27 +473,19 @@ export function ReadingNumbersGuide() {
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.600',
|
||||
mb: '3',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{example.num}
|
||||
</div>
|
||||
|
||||
{/* Larger container for multi-digit numbers */}
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
aspectRatio: '3/4',
|
||||
maxW: '180px',
|
||||
bg: 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
flex: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
my: '2',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
@@ -510,7 +494,7 @@ export function ReadingNumbersGuide() {
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={0.9}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
@@ -523,6 +507,7 @@ export function ReadingNumbersGuide() {
|
||||
color: 'blue.700',
|
||||
lineHeight: 'relaxed',
|
||||
textAlign: 'center',
|
||||
mt: '2',
|
||||
})}
|
||||
>
|
||||
{t(`multiDigit.examples.${example.descKey}`)}
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function GuidePage() {
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle={t('navTitle')} navEmoji="📖">
|
||||
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
|
||||
<div className={`with-fixed-nav ${css({ minHeight: '100vh', bg: 'gray.50' })}`}>
|
||||
{/* Hero Section */}
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -26,8 +26,24 @@ function generateDayIcon(day: number): string {
|
||||
return svg
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const dayOfMonth = getDayOfMonth()
|
||||
export async function GET(request: Request) {
|
||||
// Parse query parameters for testing
|
||||
const { searchParams } = new URL(request.url)
|
||||
const dayParam = searchParams.get('day')
|
||||
|
||||
// Use override day if provided (for testing), otherwise use current day
|
||||
let dayOfMonth: number
|
||||
if (dayParam) {
|
||||
const parsedDay = parseInt(dayParam, 10)
|
||||
// Validate day is 1-31
|
||||
if (parsedDay >= 1 && parsedDay <= 31) {
|
||||
dayOfMonth = parsedDay
|
||||
} else {
|
||||
return new Response('Invalid day parameter. Must be 1-31.', { status: 400 })
|
||||
}
|
||||
} else {
|
||||
dayOfMonth = getDayOfMonth()
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
let svg = iconCache.get(dayOfMonth)
|
||||
@@ -37,10 +53,12 @@ export async function GET() {
|
||||
svg = generateDayIcon(dayOfMonth)
|
||||
iconCache.set(dayOfMonth, svg)
|
||||
|
||||
// Clear old cache entries (keep only current day)
|
||||
for (const [cachedDay] of iconCache) {
|
||||
if (cachedDay !== dayOfMonth) {
|
||||
iconCache.delete(cachedDay)
|
||||
// Clear old cache entries (keep only current day, unless testing with override)
|
||||
if (!dayParam) {
|
||||
for (const [cachedDay] of iconCache) {
|
||||
if (cachedDay !== dayOfMonth) {
|
||||
iconCache.delete(cachedDay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,8 +66,10 @@ export async function GET() {
|
||||
return new Response(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
// Cache for 1 hour so it updates throughout the day
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||
// Cache for 1 hour for current day, shorter cache for test overrides
|
||||
'Cache-Control': dayParam
|
||||
? 'public, max-age=60, s-maxage=60'
|
||||
: 'public, max-age=3600, s-maxage=3600',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ export default function TestStaticAbacusPage() {
|
||||
<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.
|
||||
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
|
||||
@@ -39,26 +39,20 @@ export default function TestStaticAbacusPage() {
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<AbacusStatic
|
||||
value={num}
|
||||
columns="auto"
|
||||
hideInactiveBeads
|
||||
compact
|
||||
scaleFactor={0.9}
|
||||
/>
|
||||
<span style={{ fontSize: '20px', fontWeight: 'bold', color: '#475569' }}>
|
||||
{num}
|
||||
</span>
|
||||
<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' }}>
|
||||
<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!
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { OrbitControls, Stage } from '@react-three/drei'
|
||||
import { Canvas, useLoader } from '@react-three/fiber'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Suspense, useEffect, useRef, useState } from 'react'
|
||||
// @ts-expect-error - STLLoader doesn't have TypeScript declarations
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
|
||||
import { css } from '../../../styled-system/css'
|
||||
@@ -30,68 +30,86 @@ export function STLPreview({ columns, scaleFactor }: STLPreviewProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('/3d-models/simplified.abacus.stl')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const workerRef = useRef<Worker | null>(null)
|
||||
|
||||
// Initialize worker
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const worker = new Worker(new URL('../../workers/openscad.worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
})
|
||||
|
||||
const generatePreview = async () => {
|
||||
setIsGenerating(true)
|
||||
setError(null)
|
||||
worker.onmessage = (event: MessageEvent) => {
|
||||
const { data } = event
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/abacus/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ columns, scaleFactor }),
|
||||
})
|
||||
switch (data.type) {
|
||||
case 'ready':
|
||||
console.log('[STLPreview] Worker ready')
|
||||
break
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate preview')
|
||||
}
|
||||
case 'result': {
|
||||
// Create blob from STL data
|
||||
const blob = new Blob([data.stl], { type: 'application/octet-stream' })
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
// Convert response to blob and create object URL
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
if (mounted) {
|
||||
// Revoke old URL if it exists
|
||||
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
setPreviewUrl(objectUrl)
|
||||
} else {
|
||||
// Component unmounted, clean up the URL
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to generate preview'
|
||||
|
||||
// Check if this is an OpenSCAD not found error
|
||||
if (
|
||||
errorMessage.includes('openscad: command not found') ||
|
||||
errorMessage.includes('Command failed: openscad')
|
||||
) {
|
||||
setError('OpenSCAD not installed (preview only available in production/Docker)')
|
||||
// Fallback to showing the base STL
|
||||
setPreviewUrl('/3d-models/simplified.abacus.stl')
|
||||
} else {
|
||||
setError(errorMessage)
|
||||
}
|
||||
console.error('Preview generation error:', err)
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setPreviewUrl(objectUrl)
|
||||
setIsGenerating(false)
|
||||
setError(null)
|
||||
break
|
||||
}
|
||||
|
||||
case 'error':
|
||||
console.error('[STLPreview] Worker error:', data.error)
|
||||
setError(data.error)
|
||||
setIsGenerating(false)
|
||||
// Fallback to showing the base STL
|
||||
setPreviewUrl('/3d-models/simplified.abacus.stl')
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('[STLPreview] Unknown message type:', data)
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce: Wait 1 second after parameters change before regenerating
|
||||
const timeoutId = setTimeout(generatePreview, 1000)
|
||||
worker.onerror = (error) => {
|
||||
console.error('[STLPreview] Worker error:', error)
|
||||
setError('Worker failed to load')
|
||||
setIsGenerating(false)
|
||||
}
|
||||
|
||||
workerRef.current = worker
|
||||
|
||||
return () => {
|
||||
worker.terminate()
|
||||
workerRef.current = null
|
||||
// Clean up any blob URLs
|
||||
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Trigger rendering when parameters change
|
||||
useEffect(() => {
|
||||
if (!workerRef.current) return
|
||||
|
||||
setIsGenerating(true)
|
||||
setError(null)
|
||||
|
||||
// Debounce: Wait 500ms after parameters change before regenerating
|
||||
const timeoutId = setTimeout(() => {
|
||||
workerRef.current?.postMessage({
|
||||
type: 'render',
|
||||
columns,
|
||||
scaleFactor,
|
||||
})
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [columns, scaleFactor])
|
||||
|
||||
@@ -495,6 +495,10 @@ function MinimalNav({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
pointerEvents: 'none',
|
||||
// Set active nav height for content to use
|
||||
['--app-nav-height' as any]: 'var(--app-nav-height-minimal)',
|
||||
// Use the variable for min-height to ensure consistency
|
||||
minHeight: 'var(--app-nav-height-minimal)',
|
||||
}}
|
||||
>
|
||||
{/* Hamburger Menu - positioned absolutely on left */}
|
||||
@@ -568,6 +572,7 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const isArcadePage = pathname?.startsWith('/arcade')
|
||||
const isHomePage = pathname === '/'
|
||||
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
|
||||
|
||||
// Try to get home hero context (if on homepage)
|
||||
@@ -579,7 +584,7 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const subtitle = homeHero?.subtitle || fallbackSubtitle
|
||||
|
||||
// Show branding unless we're on homepage with visible hero
|
||||
const showBranding = !homeHero || !homeHero.isHeroVisible
|
||||
const showBranding = !isHomePage || !homeHero || !homeHero.isHeroVisible
|
||||
|
||||
// Auto-detect variant based on context
|
||||
// Only arcade pages (not /games) should use minimal nav
|
||||
@@ -600,12 +605,18 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if we should use transparent styling (when hero is visible)
|
||||
const isTransparent = homeHero?.isHeroVisible
|
||||
// Check if we should use transparent styling (when hero is visible on home page)
|
||||
const isTransparent = isHomePage && homeHero?.isHeroVisible
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<header
|
||||
style={{
|
||||
// Set active nav height for content to use
|
||||
['--app-nav-height' as any]: 'var(--app-nav-height-full)',
|
||||
// Use the variable for min-height to ensure consistency
|
||||
minHeight: 'var(--app-nav-height-full)',
|
||||
}}
|
||||
className={css({
|
||||
bg: isTransparent ? 'transparent' : 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: isTransparent ? 'none' : 'blur(12px)',
|
||||
|
||||
@@ -66,32 +66,6 @@ export function InteractiveAbacus({
|
||||
const beadPosition = beadElement.getAttribute('data-bead-position')
|
||||
const isActive = beadElement.getAttribute('data-bead-active') === '1'
|
||||
|
||||
console.log('Bead clicked:', {
|
||||
beadType,
|
||||
beadColumn,
|
||||
beadPosition,
|
||||
isActive,
|
||||
})
|
||||
console.log('Current value before click:', currentValue)
|
||||
|
||||
if (beadType === 'earth') {
|
||||
const position = parseInt(beadPosition || '0', 10)
|
||||
const placeValue = beadColumn
|
||||
const columnPower = 10 ** placeValue
|
||||
const currentDigit = Math.floor(currentValue / columnPower) % 10
|
||||
const heavenContribution = Math.floor(currentDigit / 5) * 5
|
||||
const earthContribution = currentDigit % 5
|
||||
console.log('Earth bead analysis:', {
|
||||
position,
|
||||
beadColumn,
|
||||
placeValue,
|
||||
columnPower,
|
||||
currentDigit,
|
||||
heavenContribution,
|
||||
earthContribution,
|
||||
})
|
||||
}
|
||||
|
||||
if (beadType === 'heaven') {
|
||||
// Toggle heaven bead (worth 5)
|
||||
// Now using place-value based column numbering: 0=ones, 1=tens, 2=hundreds
|
||||
@@ -140,13 +114,6 @@ export function InteractiveAbacus({
|
||||
newEarthContribution = position + 1
|
||||
}
|
||||
|
||||
console.log('Earth bead calculation:', {
|
||||
position,
|
||||
isActive,
|
||||
currentEarthContribution: earthContribution,
|
||||
newEarthContribution,
|
||||
})
|
||||
|
||||
// Calculate the new digit for this column
|
||||
const newDigit = heavenContribution + newEarthContribution
|
||||
|
||||
|
||||
@@ -226,7 +226,6 @@ export function MyAbacus() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Keyframes for animations */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -53,49 +53,28 @@ export function HomeHeroProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Load from sessionStorage after mount (client-only, no hydration mismatch)
|
||||
useEffect(() => {
|
||||
console.log('[HeroAbacus] Loading from sessionStorage...')
|
||||
isLoadingFromStorage.current = true // Block saves during load
|
||||
|
||||
const saved = sessionStorage.getItem('heroAbacusValue')
|
||||
console.log('[HeroAbacus] Saved value from storage:', saved)
|
||||
|
||||
if (saved) {
|
||||
const parsedValue = parseInt(saved, 10)
|
||||
console.log('[HeroAbacus] Parsed value:', parsedValue)
|
||||
if (!Number.isNaN(parsedValue)) {
|
||||
console.log('[HeroAbacus] Setting abacus value to:', parsedValue)
|
||||
setAbacusValue(parsedValue)
|
||||
}
|
||||
} else {
|
||||
console.log('[HeroAbacus] No saved value found, staying at 0')
|
||||
}
|
||||
|
||||
// Use setTimeout to ensure the value has been set before we allow saves
|
||||
setTimeout(() => {
|
||||
isLoadingFromStorage.current = false
|
||||
setIsAbacusLoaded(true)
|
||||
console.log('[HeroAbacus] Load complete, allowing saves now and fading in')
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
// Persist value to sessionStorage when it changes (but skip during load)
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'[HeroAbacus] Save effect triggered. Value:',
|
||||
abacusValue,
|
||||
'isLoadingFromStorage:',
|
||||
isLoadingFromStorage.current
|
||||
)
|
||||
|
||||
if (!isLoadingFromStorage.current) {
|
||||
console.log('[HeroAbacus] Saving to sessionStorage:', abacusValue)
|
||||
sessionStorage.setItem('heroAbacusValue', abacusValue.toString())
|
||||
console.log(
|
||||
'[HeroAbacus] Saved successfully. Storage now contains:',
|
||||
sessionStorage.getItem('heroAbacusValue')
|
||||
)
|
||||
} else {
|
||||
console.log('[HeroAbacus] Skipping save (currently loading from storage)')
|
||||
}
|
||||
}, [abacusValue])
|
||||
|
||||
|
||||
61
apps/web/src/i18n/locales/calendar/de.json
Normal file
61
apps/web/src/i18n/locales/calendar/de.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"calendar": {
|
||||
"pageTitle": "Abakus-Kalender erstellen",
|
||||
"pageSubtitle": "Druckbare Kalender mit Abakus-Datumszahlen erstellen",
|
||||
"format": {
|
||||
"title": "Kalenderformat",
|
||||
"monthly": "Monatskalender (eine Seite pro Monat)",
|
||||
"daily": "Tageskalender (eine Seite pro Tag)"
|
||||
},
|
||||
"date": {
|
||||
"title": "Datum",
|
||||
"month": "Monat",
|
||||
"year": "Jahr"
|
||||
},
|
||||
"paperSize": {
|
||||
"title": "Papiergröße",
|
||||
"usLetter": "US Letter (8,5\" × 11\")",
|
||||
"a4": "A4 (210mm × 297mm)",
|
||||
"a3": "A3 (297mm × 420mm)",
|
||||
"tabloid": "Tabloid (11\" × 17\")"
|
||||
},
|
||||
"styling": {
|
||||
"preview": "Kalender-Abakus-Stil Vorschau:"
|
||||
},
|
||||
"generate": {
|
||||
"button": "PDF-Kalender erstellen",
|
||||
"generating": "PDF wird erstellt..."
|
||||
},
|
||||
"preview": {
|
||||
"loading": "Vorschau wird geladen...",
|
||||
"noPreview": "Keine Vorschau verfügbar",
|
||||
"generatedPdf": "Erstelltes PDF",
|
||||
"livePreview": "Live-Vorschau",
|
||||
"livePreviewFirstDay": "Live-Vorschau (Erster Tag)"
|
||||
},
|
||||
"months": {
|
||||
"january": "Januar",
|
||||
"february": "Februar",
|
||||
"march": "März",
|
||||
"april": "April",
|
||||
"may": "Mai",
|
||||
"june": "Juni",
|
||||
"july": "Juli",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "Oktober",
|
||||
"november": "November",
|
||||
"december": "Dezember"
|
||||
},
|
||||
"weekdays": {
|
||||
"sunday": "Sonntag",
|
||||
"monday": "Montag",
|
||||
"tuesday": "Dienstag",
|
||||
"wednesday": "Mittwoch",
|
||||
"thursday": "Donnerstag",
|
||||
"friday": "Freitag",
|
||||
"saturday": "Samstag"
|
||||
},
|
||||
"notes": "Notizen:"
|
||||
}
|
||||
}
|
||||
61
apps/web/src/i18n/locales/calendar/en.json
Normal file
61
apps/web/src/i18n/locales/calendar/en.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"calendar": {
|
||||
"pageTitle": "Create Abacus Calendar",
|
||||
"pageSubtitle": "Generate printable calendars with abacus date numbers",
|
||||
"format": {
|
||||
"title": "Calendar Format",
|
||||
"monthly": "Monthly Calendar (one page per month)",
|
||||
"daily": "Daily Calendar (one page per day)"
|
||||
},
|
||||
"date": {
|
||||
"title": "Date",
|
||||
"month": "Month",
|
||||
"year": "Year"
|
||||
},
|
||||
"paperSize": {
|
||||
"title": "Paper Size",
|
||||
"usLetter": "US Letter (8.5\" × 11\")",
|
||||
"a4": "A4 (210mm × 297mm)",
|
||||
"a3": "A3 (297mm × 420mm)",
|
||||
"tabloid": "Tabloid (11\" × 17\")"
|
||||
},
|
||||
"styling": {
|
||||
"preview": "Calendar abacus style preview:"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Generate PDF Calendar",
|
||||
"generating": "Generating PDF..."
|
||||
},
|
||||
"preview": {
|
||||
"loading": "Loading preview...",
|
||||
"noPreview": "No preview available",
|
||||
"generatedPdf": "Generated PDF",
|
||||
"livePreview": "Live Preview",
|
||||
"livePreviewFirstDay": "Live Preview (First Day)"
|
||||
},
|
||||
"months": {
|
||||
"january": "January",
|
||||
"february": "February",
|
||||
"march": "March",
|
||||
"april": "April",
|
||||
"may": "May",
|
||||
"june": "June",
|
||||
"july": "July",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "October",
|
||||
"november": "November",
|
||||
"december": "December"
|
||||
},
|
||||
"weekdays": {
|
||||
"sunday": "Sunday",
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday"
|
||||
},
|
||||
"notes": "Notes:"
|
||||
}
|
||||
}
|
||||
61
apps/web/src/i18n/locales/calendar/es.json
Normal file
61
apps/web/src/i18n/locales/calendar/es.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"calendar": {
|
||||
"pageTitle": "Crear Calendario de Ábaco",
|
||||
"pageSubtitle": "Generar calendarios imprimibles con números de fecha de ábaco",
|
||||
"format": {
|
||||
"title": "Formato de Calendario",
|
||||
"monthly": "Calendario Mensual (una página por mes)",
|
||||
"daily": "Calendario Diario (una página por día)"
|
||||
},
|
||||
"date": {
|
||||
"title": "Fecha",
|
||||
"month": "Mes",
|
||||
"year": "Año"
|
||||
},
|
||||
"paperSize": {
|
||||
"title": "Tamaño de Papel",
|
||||
"usLetter": "Carta US (8.5\" × 11\")",
|
||||
"a4": "A4 (210mm × 297mm)",
|
||||
"a3": "A3 (297mm × 420mm)",
|
||||
"tabloid": "Tabloide (11\" × 17\")"
|
||||
},
|
||||
"styling": {
|
||||
"preview": "Vista previa del estilo de ábaco del calendario:"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Generar Calendario PDF",
|
||||
"generating": "Generando PDF..."
|
||||
},
|
||||
"preview": {
|
||||
"loading": "Cargando vista previa...",
|
||||
"noPreview": "No hay vista previa disponible",
|
||||
"generatedPdf": "PDF Generado",
|
||||
"livePreview": "Vista Previa en Vivo",
|
||||
"livePreviewFirstDay": "Vista Previa en Vivo (Primer Día)"
|
||||
},
|
||||
"months": {
|
||||
"january": "Enero",
|
||||
"february": "Febrero",
|
||||
"march": "Marzo",
|
||||
"april": "Abril",
|
||||
"may": "Mayo",
|
||||
"june": "Junio",
|
||||
"july": "Julio",
|
||||
"august": "Agosto",
|
||||
"september": "Septiembre",
|
||||
"october": "Octubre",
|
||||
"november": "Noviembre",
|
||||
"december": "Diciembre"
|
||||
},
|
||||
"weekdays": {
|
||||
"sunday": "Domingo",
|
||||
"monday": "Lunes",
|
||||
"tuesday": "Martes",
|
||||
"wednesday": "Miércoles",
|
||||
"thursday": "Jueves",
|
||||
"friday": "Viernes",
|
||||
"saturday": "Sábado"
|
||||
},
|
||||
"notes": "Notas:"
|
||||
}
|
||||
}
|
||||
61
apps/web/src/i18n/locales/calendar/goh.json
Normal file
61
apps/web/src/i18n/locales/calendar/goh.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"calendar": {
|
||||
"pageTitle": "Abacus Kalender Giscaffan",
|
||||
"pageSubtitle": "Drucchāri kalendera mit abacus tagozalun giskaffen",
|
||||
"format": {
|
||||
"title": "Kalenderart",
|
||||
"monthly": "Mānoðkalender (ein sīta per mānoð)",
|
||||
"daily": "Tagkalender (ein sīta per tag)"
|
||||
},
|
||||
"date": {
|
||||
"title": "Tag",
|
||||
"month": "Mānoð",
|
||||
"year": "Jār"
|
||||
},
|
||||
"paperSize": {
|
||||
"title": "Papiergrōzi",
|
||||
"usLetter": "US Brief (8.5\" × 11\")",
|
||||
"a4": "A4 (210mm × 297mm)",
|
||||
"a3": "A3 (297mm × 420mm)",
|
||||
"tabloid": "Tabloid (11\" × 17\")"
|
||||
},
|
||||
"styling": {
|
||||
"preview": "Kalender abacus stil forasihti:"
|
||||
},
|
||||
"generate": {
|
||||
"button": "PDF Kalender Giskaffen",
|
||||
"generating": "PDF wirdit giskaffan..."
|
||||
},
|
||||
"preview": {
|
||||
"loading": "Forasihti ladet...",
|
||||
"noPreview": "Nein forasihti ferfuogbar",
|
||||
"generatedPdf": "Giskaffan PDF",
|
||||
"livePreview": "Lebenti Forasihti",
|
||||
"livePreviewFirstDay": "Lebenti Forasihti (Ēristo Tag)"
|
||||
},
|
||||
"months": {
|
||||
"january": "Hartmānot",
|
||||
"february": "Hornung",
|
||||
"march": "Lentzinmānot",
|
||||
"april": "Ōstarmānot",
|
||||
"may": "Winnemānot",
|
||||
"june": "Brāhmānot",
|
||||
"july": "Hewimānot",
|
||||
"august": "Aranmānot",
|
||||
"september": "Witumanot",
|
||||
"october": "Windurmānot",
|
||||
"november": "Herbistmānot",
|
||||
"december": "Heilagmānot"
|
||||
},
|
||||
"weekdays": {
|
||||
"sunday": "Sunnūntag",
|
||||
"monday": "Mānetag",
|
||||
"tuesday": "Ziostag",
|
||||
"wednesday": "Mittawehha",
|
||||
"thursday": "Donarestag",
|
||||
"friday": "Frīatag",
|
||||
"saturday": "Sambaztag"
|
||||
},
|
||||
"notes": "Notiziun:"
|
||||
}
|
||||
}
|
||||
61
apps/web/src/i18n/locales/calendar/hi.json
Normal file
61
apps/web/src/i18n/locales/calendar/hi.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"calendar": {
|
||||
"pageTitle": "अबेकस कैलेंडर बनाएं",
|
||||
"pageSubtitle": "अबेकस तिथि संख्याओं के साथ मुद्रण योग्य कैलेंडर उत्पन्न करें",
|
||||
"format": {
|
||||
"title": "कैलेंडर प्रारूप",
|
||||
"monthly": "मासिक कैलेंडर (प्रति माह एक पृष्ठ)",
|
||||
"daily": "दैनिक कैलेंडर (प्रति दिन एक पृष्ठ)"
|
||||
},
|
||||
"date": {
|
||||
"title": "तिथि",
|
||||
"month": "महीना",
|
||||
"year": "वर्ष"
|
||||
},
|
||||
"paperSize": {
|
||||
"title": "कागज का आकार",
|
||||
"usLetter": "यूएस लेटर (8.5\" × 11\")",
|
||||
"a4": "A4 (210mm × 297mm)",
|
||||
"a3": "A3 (297mm × 420mm)",
|
||||
"tabloid": "टैबलॉइड (11\" × 17\")"
|
||||
},
|
||||
"styling": {
|
||||
"preview": "कैलेंडर अबेकस शैली पूर्वावलोकन:"
|
||||
},
|
||||
"generate": {
|
||||
"button": "पीडीएफ कैलेंडर उत्पन्न करें",
|
||||
"generating": "पीडीएफ उत्पन्न हो रहा है..."
|
||||
},
|
||||
"preview": {
|
||||
"loading": "पूर्वावलोकन लोड हो रहा है...",
|
||||
"noPreview": "कोई पूर्वावलोकन उपलब्ध नहीं",
|
||||
"generatedPdf": "उत्पन्न पीडीएफ",
|
||||
"livePreview": "लाइव पूर्वावलोकन",
|
||||
"livePreviewFirstDay": "लाइव पूर्वावलोकन (पहला दिन)"
|
||||
},
|
||||
"months": {
|
||||
"january": "जनवरी",
|
||||
"february": "फरवरी",
|
||||
"march": "मार्च",
|
||||
"april": "अप्रैल",
|
||||
"may": "मई",
|
||||
"june": "जून",
|
||||
"july": "जुलाई",
|
||||
"august": "अगस्त",
|
||||
"september": "सितंबर",
|
||||
"october": "अक्टूबर",
|
||||
"november": "नवंबर",
|
||||
"december": "दिसंबर"
|
||||
},
|
||||
"weekdays": {
|
||||
"sunday": "रविवार",
|
||||
"monday": "सोमवार",
|
||||
"tuesday": "मंगलवार",
|
||||
"wednesday": "बुधवार",
|
||||
"thursday": "गुरुवार",
|
||||
"friday": "शुक्रवार",
|
||||
"saturday": "शनिवार"
|
||||
},
|
||||
"notes": "नोट्स:"
|
||||
}
|
||||
}
|
||||
61
apps/web/src/i18n/locales/calendar/ja.json
Normal file
61
apps/web/src/i18n/locales/calendar/ja.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"calendar": {
|
||||
"pageTitle": "そろばんカレンダーを作成",
|
||||
"pageSubtitle": "そろばんの日付番号付き印刷可能なカレンダーを生成",
|
||||
"format": {
|
||||
"title": "カレンダー形式",
|
||||
"monthly": "月間カレンダー(月ごとに1ページ)",
|
||||
"daily": "日めくりカレンダー(日ごとに1ページ)"
|
||||
},
|
||||
"date": {
|
||||
"title": "日付",
|
||||
"month": "月",
|
||||
"year": "年"
|
||||
},
|
||||
"paperSize": {
|
||||
"title": "用紙サイズ",
|
||||
"usLetter": "USレター (8.5\" × 11\")",
|
||||
"a4": "A4 (210mm × 297mm)",
|
||||
"a3": "A3 (297mm × 420mm)",
|
||||
"tabloid": "タブロイド (11\" × 17\")"
|
||||
},
|
||||
"styling": {
|
||||
"preview": "カレンダーそろばんスタイルプレビュー:"
|
||||
},
|
||||
"generate": {
|
||||
"button": "PDFカレンダーを生成",
|
||||
"generating": "PDFを生成中..."
|
||||
},
|
||||
"preview": {
|
||||
"loading": "プレビューを読み込み中...",
|
||||
"noPreview": "プレビューがありません",
|
||||
"generatedPdf": "生成されたPDF",
|
||||
"livePreview": "ライブプレビュー",
|
||||
"livePreviewFirstDay": "ライブプレビュー(初日)"
|
||||
},
|
||||
"months": {
|
||||
"january": "1月",
|
||||
"february": "2月",
|
||||
"march": "3月",
|
||||
"april": "4月",
|
||||
"may": "5月",
|
||||
"june": "6月",
|
||||
"july": "7月",
|
||||
"august": "8月",
|
||||
"september": "9月",
|
||||
"october": "10月",
|
||||
"november": "11月",
|
||||
"december": "12月"
|
||||
},
|
||||
"weekdays": {
|
||||
"sunday": "日曜日",
|
||||
"monday": "月曜日",
|
||||
"tuesday": "火曜日",
|
||||
"wednesday": "水曜日",
|
||||
"thursday": "木曜日",
|
||||
"friday": "金曜日",
|
||||
"saturday": "土曜日"
|
||||
},
|
||||
"notes": "メモ:"
|
||||
}
|
||||
}
|
||||
61
apps/web/src/i18n/locales/calendar/la.json
Normal file
61
apps/web/src/i18n/locales/calendar/la.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"calendar": {
|
||||
"pageTitle": "Calendarium Abaci Creare",
|
||||
"pageSubtitle": "Calendaria imprimibilia cum numeris diei abaci generare",
|
||||
"format": {
|
||||
"title": "Forma Calendarii",
|
||||
"monthly": "Calendarium Mensuale (una pagina per mensem)",
|
||||
"daily": "Calendarium Diurnum (una pagina per diem)"
|
||||
},
|
||||
"date": {
|
||||
"title": "Dies",
|
||||
"month": "Mensis",
|
||||
"year": "Annus"
|
||||
},
|
||||
"paperSize": {
|
||||
"title": "Magnitudo Chartae",
|
||||
"usLetter": "US Epistula (8.5\" × 11\")",
|
||||
"a4": "A4 (210mm × 297mm)",
|
||||
"a3": "A3 (297mm × 420mm)",
|
||||
"tabloid": "Tabloid (11\" × 17\")"
|
||||
},
|
||||
"styling": {
|
||||
"preview": "Praevisio stili abaci calendarii:"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Calendarium PDF Generare",
|
||||
"generating": "PDF Generatur..."
|
||||
},
|
||||
"preview": {
|
||||
"loading": "Praevisio cargatur...",
|
||||
"noPreview": "Nulla praevisio disponibilis",
|
||||
"generatedPdf": "PDF Generatum",
|
||||
"livePreview": "Praevisio Viva",
|
||||
"livePreviewFirstDay": "Praevisio Viva (Primus Dies)"
|
||||
},
|
||||
"months": {
|
||||
"january": "Ianuarius",
|
||||
"february": "Februarius",
|
||||
"march": "Martius",
|
||||
"april": "Aprilis",
|
||||
"may": "Maius",
|
||||
"june": "Iunius",
|
||||
"july": "Iulius",
|
||||
"august": "Augustus",
|
||||
"september": "September",
|
||||
"october": "October",
|
||||
"november": "November",
|
||||
"december": "December"
|
||||
},
|
||||
"weekdays": {
|
||||
"sunday": "Dies Solis",
|
||||
"monday": "Dies Lunae",
|
||||
"tuesday": "Dies Martis",
|
||||
"wednesday": "Dies Mercurii",
|
||||
"thursday": "Dies Iovis",
|
||||
"friday": "Dies Veneris",
|
||||
"saturday": "Dies Saturni"
|
||||
},
|
||||
"notes": "Notae:"
|
||||
}
|
||||
}
|
||||
17
apps/web/src/i18n/locales/calendar/messages.ts
Normal file
17
apps/web/src/i18n/locales/calendar/messages.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import de from './de.json'
|
||||
import en from './en.json'
|
||||
import es from './es.json'
|
||||
import goh from './goh.json'
|
||||
import hi from './hi.json'
|
||||
import ja from './ja.json'
|
||||
import la from './la.json'
|
||||
|
||||
export const calendarMessages = {
|
||||
en: en.calendar,
|
||||
de: de.calendar,
|
||||
ja: ja.calendar,
|
||||
hi: hi.calendar,
|
||||
es: es.calendar,
|
||||
la: la.calendar,
|
||||
goh: goh.calendar,
|
||||
} as const
|
||||
88
apps/web/src/i18n/locales/create/de.json
Normal file
88
apps/web/src/i18n/locales/create/de.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"create": {
|
||||
"hub": {
|
||||
"pageTitle": "Erstellen Sie Ihre Lernwerkzeuge",
|
||||
"pageSubtitle": "Entwerfen Sie individuelle Lernkarten oder 3D-druckbare Abakus-Modelle, um Ihr Lernerlebnis zu verbessern",
|
||||
"flashcards": {
|
||||
"title": "Lernkarten-Creator",
|
||||
"description": "Entwerfen Sie individuelle Lernkarten mit Abakus-Visualisierungen. Perfekt zum Lernen und Lehren von Zahlenerkennung und Arithmetik.",
|
||||
"feature1": "Anpassbare Zahlenbereiche",
|
||||
"feature2": "Mehrere Stile und Layouts",
|
||||
"feature3": "Druckfertige PDF-Generierung",
|
||||
"button": "Lernkarten erstellen"
|
||||
},
|
||||
"abacus": {
|
||||
"title": "3D-Abakus-Creator",
|
||||
"description": "Passen Sie 3D-druckbare Abakus-Modelle an und laden Sie sie herunter. Wählen Sie Größe, Spalten und Farben für das perfekte Lernwerkzeug.",
|
||||
"feature1": "Einstellbare Größe und Spalten",
|
||||
"feature2": "3MF-Farbanpassung",
|
||||
"feature3": "Live-3D-Vorschau",
|
||||
"button": "3D-Modell erstellen"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Abakus-Kalender",
|
||||
"description": "Erstellen Sie druckbare Kalender, bei denen jedes Datum als Abakus angezeigt wird. Perfekt zum Lehren der Zahlendarstellung.",
|
||||
"feature1": "Monatliche oder tägliche Formate",
|
||||
"feature2": "Mehrere Papiergrößen",
|
||||
"feature3": "Verwendet Ihren Abakus-Stil",
|
||||
"button": "Kalender erstellen"
|
||||
}
|
||||
},
|
||||
"abacus": {
|
||||
"pageTitle": "Passen Sie Ihren 3D-druckbaren Abakus an",
|
||||
"pageSubtitle": "Passen Sie die Parameter unten an, um Ihren Abakus anzupassen, und generieren und laden Sie dann die Datei für den 3D-Druck herunter.",
|
||||
"customizationTitle": "Anpassungsparameter",
|
||||
"columns": {
|
||||
"label": "Anzahl der Spalten: {{count}}",
|
||||
"help": "Gesamtanzahl der Spalten im Abakus (1-13)"
|
||||
},
|
||||
"scaleFactor": {
|
||||
"label": "Skalierungsfaktor: {{factor}}x",
|
||||
"help": "Gesamtgrößenmultiplikator (behält Seitenverhältnis bei, größere Werte = größere Dateigröße)"
|
||||
},
|
||||
"widthMm": {
|
||||
"label": "Breite in mm (optional)",
|
||||
"placeholder": "Leer lassen, um Skalierungsfaktor zu verwenden",
|
||||
"help": "Geben Sie die genaue Breite in Millimetern an (überschreibt Skalierungsfaktor)"
|
||||
},
|
||||
"format": {
|
||||
"label": "Ausgabeformat"
|
||||
},
|
||||
"colors": {
|
||||
"title": "3MF-Farbanpassung",
|
||||
"frame": "Rahmenfarbe",
|
||||
"heavenBead": "Himmelsperlenfarbe",
|
||||
"earthBead": "Erdperlenfarbe",
|
||||
"decoration": "Dekorationsfarbe"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Datei generieren",
|
||||
"generating": "Wird generiert..."
|
||||
},
|
||||
"download": "{{format}} herunterladen",
|
||||
"preview": {
|
||||
"title": "Vorschau",
|
||||
"liveDescription": "Live-Vorschau: Die Vorschau wird automatisch aktualisiert, wenn Sie Parameter anpassen (mit 1 Sekunde Verzögerung). Dies zeigt das genaue gespiegelte Buchfalz-Design, das generiert wird.",
|
||||
"note": "Hinweis: Die Vorschaugenerierung erfordert OpenSCAD. Wenn Sie einen Fehler sehen, funktioniert die Vorschaufunktion nur in der Produktion (Docker). Die Download-Funktionalität funktioniert weiterhin, wenn sie bereitgestellt wird.",
|
||||
"instructions": "Verwenden Sie Ihre Maus, um das 3D-Modell zu drehen und zu zoomen."
|
||||
}
|
||||
},
|
||||
"flashcards": {
|
||||
"navTitle": "Lernkarten erstellen",
|
||||
"pageTitle": "Erstellen Sie Ihre Lernkarten",
|
||||
"pageSubtitle": "Konfigurieren Sie Inhalt und Stil, sehen Sie die Vorschau sofort und generieren Sie dann Ihre Lernkarten",
|
||||
"stylePanel": {
|
||||
"title": "🎨 Visueller Stil",
|
||||
"subtitle": "Sehen Sie Änderungen sofort in der Vorschau"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Lernkarten generieren",
|
||||
"generating": "Ihre Lernkarten werden generiert..."
|
||||
},
|
||||
"error": {
|
||||
"title": "Generierung fehlgeschlagen",
|
||||
"tryAgain": "Erneut versuchen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
apps/web/src/i18n/locales/create/en.json
Normal file
88
apps/web/src/i18n/locales/create/en.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"create": {
|
||||
"hub": {
|
||||
"pageTitle": "Create Your Learning Tools",
|
||||
"pageSubtitle": "Design custom flashcards or 3D printable abacus models to enhance your learning experience",
|
||||
"flashcards": {
|
||||
"title": "Flashcard Creator",
|
||||
"description": "Design custom flashcards with abacus visualizations. Perfect for learning and teaching number recognition and arithmetic.",
|
||||
"feature1": "Customizable number ranges",
|
||||
"feature2": "Multiple styles and layouts",
|
||||
"feature3": "Print-ready PDF generation",
|
||||
"button": "Create Flashcards"
|
||||
},
|
||||
"abacus": {
|
||||
"title": "3D Abacus Creator",
|
||||
"description": "Customize and download 3D printable abacus models. Choose your size, columns, and colors for the perfect learning tool.",
|
||||
"feature1": "Adjustable size and columns",
|
||||
"feature2": "3MF color customization",
|
||||
"feature3": "Live 3D preview",
|
||||
"button": "Create 3D Model"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Abacus Calendar",
|
||||
"description": "Generate printable calendars where every date is shown as an abacus. Perfect for teaching number representation.",
|
||||
"feature1": "Monthly or daily formats",
|
||||
"feature2": "Multiple paper sizes",
|
||||
"feature3": "Uses your abacus styling",
|
||||
"button": "Create Calendar"
|
||||
}
|
||||
},
|
||||
"abacus": {
|
||||
"pageTitle": "Customize Your 3D Printable Abacus",
|
||||
"pageSubtitle": "Adjust the parameters below to customize your abacus, then generate and download the file for 3D printing.",
|
||||
"customizationTitle": "Customization Parameters",
|
||||
"columns": {
|
||||
"label": "Number of Columns: {{count}}",
|
||||
"help": "Total number of columns in the abacus (1-13)"
|
||||
},
|
||||
"scaleFactor": {
|
||||
"label": "Scale Factor: {{factor}}x",
|
||||
"help": "Overall size multiplier (preserves aspect ratio, larger values = bigger file size)"
|
||||
},
|
||||
"widthMm": {
|
||||
"label": "Width in mm (optional)",
|
||||
"placeholder": "Leave empty to use scale factor",
|
||||
"help": "Specify exact width in millimeters (overrides scale factor)"
|
||||
},
|
||||
"format": {
|
||||
"label": "Output Format"
|
||||
},
|
||||
"colors": {
|
||||
"title": "3MF Color Customization",
|
||||
"frame": "Frame Color",
|
||||
"heavenBead": "Heaven Bead Color",
|
||||
"earthBead": "Earth Bead Color",
|
||||
"decoration": "Decoration Color"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Generate File",
|
||||
"generating": "Generating..."
|
||||
},
|
||||
"download": "Download {{format}}",
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
"liveDescription": "Live Preview: The preview updates automatically as you adjust parameters (with a 1-second delay). This shows the exact mirrored book-fold design that will be generated.",
|
||||
"note": "Note: Preview generation requires OpenSCAD. If you see an error, the preview feature only works in production (Docker). The download functionality will still work when deployed.",
|
||||
"instructions": "Use your mouse to rotate and zoom the 3D model."
|
||||
}
|
||||
},
|
||||
"flashcards": {
|
||||
"navTitle": "Create Flashcards",
|
||||
"pageTitle": "Create Your Flashcards",
|
||||
"pageSubtitle": "Configure content and style, preview instantly, then generate your flashcards",
|
||||
"stylePanel": {
|
||||
"title": "🎨 Visual Style",
|
||||
"subtitle": "See changes instantly in the preview"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Generate Flashcards",
|
||||
"generating": "Generating Your Flashcards..."
|
||||
},
|
||||
"error": {
|
||||
"title": "Generation Failed",
|
||||
"tryAgain": "Try Again"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
apps/web/src/i18n/locales/create/es.json
Normal file
88
apps/web/src/i18n/locales/create/es.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"create": {
|
||||
"hub": {
|
||||
"pageTitle": "Crea Tus Herramientas de Aprendizaje",
|
||||
"pageSubtitle": "Diseña tarjetas didácticas personalizadas o modelos de ábaco imprimibles en 3D para mejorar tu experiencia de aprendizaje",
|
||||
"flashcards": {
|
||||
"title": "Creador de Tarjetas Didácticas",
|
||||
"description": "Diseña tarjetas didácticas personalizadas con visualizaciones de ábaco. Perfecto para aprender y enseñar reconocimiento de números y aritmética.",
|
||||
"feature1": "Rangos de números personalizables",
|
||||
"feature2": "Múltiples estilos y diseños",
|
||||
"feature3": "Generación de PDF listo para imprimir",
|
||||
"button": "Crear Tarjetas Didácticas"
|
||||
},
|
||||
"abacus": {
|
||||
"title": "Creador de Ábaco 3D",
|
||||
"description": "Personaliza y descarga modelos de ábaco imprimibles en 3D. Elige tu tamaño, columnas y colores para la herramienta de aprendizaje perfecta.",
|
||||
"feature1": "Tamaño y columnas ajustables",
|
||||
"feature2": "Personalización de color 3MF",
|
||||
"feature3": "Vista previa 3D en vivo",
|
||||
"button": "Crear Modelo 3D"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendario de Ábaco",
|
||||
"description": "Genera calendarios imprimibles donde cada fecha se muestra como un ábaco. Perfecto para enseñar representación numérica.",
|
||||
"feature1": "Formatos mensuales o diarios",
|
||||
"feature2": "Múltiples tamaños de papel",
|
||||
"feature3": "Usa tu estilo de ábaco",
|
||||
"button": "Crear Calendario"
|
||||
}
|
||||
},
|
||||
"abacus": {
|
||||
"pageTitle": "Personaliza Tu Ábaco Imprimible en 3D",
|
||||
"pageSubtitle": "Ajusta los parámetros a continuación para personalizar tu ábaco, luego genera y descarga el archivo para impresión 3D.",
|
||||
"customizationTitle": "Parámetros de Personalización",
|
||||
"columns": {
|
||||
"label": "Número de Columnas: {{count}}",
|
||||
"help": "Número total de columnas en el ábaco (1-13)"
|
||||
},
|
||||
"scaleFactor": {
|
||||
"label": "Factor de Escala: {{factor}}x",
|
||||
"help": "Multiplicador de tamaño general (preserva la relación de aspecto, valores más grandes = mayor tamaño de archivo)"
|
||||
},
|
||||
"widthMm": {
|
||||
"label": "Ancho en mm (opcional)",
|
||||
"placeholder": "Dejar vacío para usar factor de escala",
|
||||
"help": "Especificar ancho exacto en milímetros (anula el factor de escala)"
|
||||
},
|
||||
"format": {
|
||||
"label": "Formato de Salida"
|
||||
},
|
||||
"colors": {
|
||||
"title": "Personalización de Color 3MF",
|
||||
"frame": "Color del Marco",
|
||||
"heavenBead": "Color de Cuenta Celestial",
|
||||
"earthBead": "Color de Cuenta Terrestre",
|
||||
"decoration": "Color de Decoración"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Generar Archivo",
|
||||
"generating": "Generando..."
|
||||
},
|
||||
"download": "Descargar {{format}}",
|
||||
"preview": {
|
||||
"title": "Vista Previa",
|
||||
"liveDescription": "Vista previa en vivo: La vista previa se actualiza automáticamente a medida que ajustas los parámetros (con un retraso de 1 segundo). Esto muestra el diseño exacto de pliegue de libro espejo que se generará.",
|
||||
"note": "Nota: La generación de vista previa requiere OpenSCAD. Si ves un error, la función de vista previa solo funciona en producción (Docker). La funcionalidad de descarga seguirá funcionando cuando se implemente.",
|
||||
"instructions": "Usa tu ratón para rotar y hacer zoom en el modelo 3D."
|
||||
}
|
||||
},
|
||||
"flashcards": {
|
||||
"navTitle": "Crear Tarjetas Didácticas",
|
||||
"pageTitle": "Crea Tus Tarjetas Didácticas",
|
||||
"pageSubtitle": "Configura contenido y estilo, previsualiza instantáneamente, luego genera tus tarjetas didácticas",
|
||||
"stylePanel": {
|
||||
"title": "🎨 Estilo Visual",
|
||||
"subtitle": "Ver cambios instantáneamente en la vista previa"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Generar Tarjetas Didácticas",
|
||||
"generating": "Generando Tus Tarjetas Didácticas..."
|
||||
},
|
||||
"error": {
|
||||
"title": "Generación Fallida",
|
||||
"tryAgain": "Intentar de Nuevo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
apps/web/src/i18n/locales/create/goh.json
Normal file
88
apps/web/src/i18n/locales/create/goh.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"create": {
|
||||
"hub": {
|
||||
"pageTitle": "Thīna Lernawerkzūg Giskaffen",
|
||||
"pageSubtitle": "Anamahhōn fleissachartun odo 3D drucchāri abacusmodellun ze firbezzirungu thīnēro lernunga",
|
||||
"flashcards": {
|
||||
"title": "Fleissachartun Giskaffari",
|
||||
"description": "Anamahhōn fleissachartun mit abacussihti. Folkomano ze lernenne inti lerenne zalakennunga inti rehnunga.",
|
||||
"feature1": "Anamahhōn zalafristu",
|
||||
"feature2": "Managfalti stili inti legari",
|
||||
"feature3": "Drucchgareiti PDF giskaffunga",
|
||||
"button": "Fleissachartun Giskaffen"
|
||||
},
|
||||
"abacus": {
|
||||
"title": "3D Abacus Giskaffari",
|
||||
"description": "Anamahhōn inti hladan 3D drucchāri abacusmodellun. Giweli thīna grōzi, spaltunga inti farawa fora folkomano lernawerkzūg.",
|
||||
"feature1": "Gistellbāri grōzi inti spaltunga",
|
||||
"feature2": "3MF farawaanamahhōn",
|
||||
"feature3": "Libenti 3D forasihti",
|
||||
"button": "3D Modellun Giskaffen"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Abacuskalendarium",
|
||||
"description": "Giskaffen drucchāri kalendariun wār iegilich tag als abacus gizeignit wirdit. Folkomano ze lerenne zalaforesteldunga.",
|
||||
"feature1": "Mānotlīchi odo taglīchi formen",
|
||||
"feature2": "Managfalti papirgrōzi",
|
||||
"feature3": "Nuzzit thīnan abacusstil",
|
||||
"button": "Kalendarium Giskaffen"
|
||||
}
|
||||
},
|
||||
"abacus": {
|
||||
"pageTitle": "Gianamahho Thīnan 3D Drucchāran Abacus",
|
||||
"pageSubtitle": "Gistellōn thie parameterōn untanafora ze gianamahhōnne thīnan abacus, thanne giskaffo inti hlado thia datei fora 3D drucch.",
|
||||
"customizationTitle": "Anamahhōnparameterōn",
|
||||
"columns": {
|
||||
"label": "Zala Spaltungo: {{count}}",
|
||||
"help": "Allu zala spaltungo in abaco (1-13)"
|
||||
},
|
||||
"scaleFactor": {
|
||||
"label": "Skālefaktor: {{factor}}x",
|
||||
"help": "Allugrōzimultiplikator (gihaltet aspektaferhāltnissa, grōziri wertē = grōziri dateigr ōzi)"
|
||||
},
|
||||
"widthMm": {
|
||||
"label": "Breiti in mm (wāllīg)",
|
||||
"placeholder": "Lāzzilosun fora skālefaktor ze nuzzenne",
|
||||
"help": "Spezifizieri genawe breiti in millimeterun (ūbarskrībit skālefaktor)"
|
||||
},
|
||||
"format": {
|
||||
"label": "Ūzgangaforma"
|
||||
},
|
||||
"colors": {
|
||||
"title": "3MF Farawaanamahhōn",
|
||||
"frame": "Rāmafarawa",
|
||||
"heavenBead": "Himilesglobulifarawa",
|
||||
"earthBead": "Erdusglobulifarawa",
|
||||
"decoration": "Zieratafarawa"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Datei Giskaffen",
|
||||
"generating": "Wirdit giskaffan..."
|
||||
},
|
||||
"download": "{{format}} Hladan",
|
||||
"preview": {
|
||||
"title": "Forasihti",
|
||||
"liveDescription": "Libenti forasihti: Thiu forasihti wirdit automatisch girēniwit sō du parameterōn gistellōst (mit 1 secunda firziagu). Thiz zeignit thaz genawe gespegelti buohfalzentuurf thaz giskaffan wirdit.",
|
||||
"note": "Nota: Forasihtigiskaffunga bidarfit OpenSCAD. Oba thu errorem gisihist, thiu forasihtikunst arbeitet nur in productione (Docker). Thiu hladukunst arbeitet noch immir wanne gistellit wirdit.",
|
||||
"instructions": "Nuzzi thīna mūs ze rōtenne inti zōmenne thaz 3D modellun."
|
||||
}
|
||||
},
|
||||
"flashcards": {
|
||||
"navTitle": "Fleissachartun Giskaffen",
|
||||
"pageTitle": "Giskaffen Thīna Fleissachartun",
|
||||
"pageSubtitle": "Gistellōn inhalt inti stil, forasih statim, thanne giskaffo thīna fleissachartun",
|
||||
"stylePanel": {
|
||||
"title": "🎨 Gisihtisstil",
|
||||
"subtitle": "Gisih wihsala statim in forasihti"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Fleissachartun Giskaffen",
|
||||
"generating": "Thīna Fleissachartun Werdent Giskaffan..."
|
||||
},
|
||||
"error": {
|
||||
"title": "Giskaffunga Firzōganit",
|
||||
"tryAgain": "Widar Firsuachen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
apps/web/src/i18n/locales/create/hi.json
Normal file
88
apps/web/src/i18n/locales/create/hi.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"create": {
|
||||
"hub": {
|
||||
"pageTitle": "अपने सीखने के उपकरण बनाएं",
|
||||
"pageSubtitle": "अपने सीखने के अनुभव को बढ़ाने के लिए कस्टम फ्लैशकार्ड या 3D प्रिंट करने योग्य अबेकस मॉडल डिजाइन करें",
|
||||
"flashcards": {
|
||||
"title": "फ्लैशकार्ड निर्माता",
|
||||
"description": "अबेकस विज़ुअलाइज़ेशन के साथ कस्टम फ्लैशकार्ड डिज़ाइन करें। संख्या पहचान और अंकगणित सीखने और पढ़ाने के लिए बिल्कुल सही।",
|
||||
"feature1": "अनुकूलन योग्य संख्या श्रेणियाँ",
|
||||
"feature2": "एकाधिक शैलियाँ और लेआउट",
|
||||
"feature3": "प्रिंट-रेडी पीडीएफ जनरेशन",
|
||||
"button": "फ्लैशकार्ड बनाएं"
|
||||
},
|
||||
"abacus": {
|
||||
"title": "3D अबेकस निर्माता",
|
||||
"description": "3D प्रिंट करने योग्य अबेकस मॉडल को अनुकूलित करें और डाउनलोड करें। सही सीखने के उपकरण के लिए अपना आकार, कॉलम और रंग चुनें।",
|
||||
"feature1": "समायोज्य आकार और कॉलम",
|
||||
"feature2": "3MF रंग अनुकूलन",
|
||||
"feature3": "लाइव 3D पूर्वावलोकन",
|
||||
"button": "3D मॉडल बनाएं"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "अबेकस कैलेंडर",
|
||||
"description": "प्रिंट करने योग्य कैलेंडर उत्पन्न करें जहां प्रत्येक तिथि को अबेकस के रूप में दिखाया गया है। संख्या प्रतिनिधित्व सिखाने के लिए बिल्कुल सही।",
|
||||
"feature1": "मासिक या दैनिक प्रारूप",
|
||||
"feature2": "एकाधिक कागज के आकार",
|
||||
"feature3": "आपकी अबेकस स्टाइलिंग का उपयोग करता है",
|
||||
"button": "कैलेंडर बनाएं"
|
||||
}
|
||||
},
|
||||
"abacus": {
|
||||
"pageTitle": "अपने 3D प्रिंट करने योग्य अबेकस को अनुकूलित करें",
|
||||
"pageSubtitle": "अपने अबेकस को अनुकूलित करने के लिए नीचे दिए गए पैरामीटर समायोजित करें, फिर 3D प्रिंटिंग के लिए फ़ाइल उत्पन्न करें और डाउनलोड करें।",
|
||||
"customizationTitle": "अनुकूलन पैरामीटर",
|
||||
"columns": {
|
||||
"label": "कॉलम की संख्या: {{count}}",
|
||||
"help": "अबेकस में कुल कॉलम की संख्या (1-13)"
|
||||
},
|
||||
"scaleFactor": {
|
||||
"label": "स्केल फैक्टर: {{factor}}x",
|
||||
"help": "कुल आकार गुणक (पहलू अनुपात को संरक्षित करता है, बड़े मान = बड़ी फ़ाइल आकार)"
|
||||
},
|
||||
"widthMm": {
|
||||
"label": "चौड़ाई मिमी में (वैकल्पिक)",
|
||||
"placeholder": "स्केल फैक्टर का उपयोग करने के लिए खाली छोड़ें",
|
||||
"help": "मिलीमीटर में सटीक चौड़ाई निर्दिष्ट करें (स्केल फैक्टर को ओवरराइड करता है)"
|
||||
},
|
||||
"format": {
|
||||
"label": "आउटपुट प्रारूप"
|
||||
},
|
||||
"colors": {
|
||||
"title": "3MF रंग अनुकूलन",
|
||||
"frame": "फ्रेम रंग",
|
||||
"heavenBead": "स्वर्ग मनका रंग",
|
||||
"earthBead": "पृथ्वी मनका रंग",
|
||||
"decoration": "सजावट रंग"
|
||||
},
|
||||
"generate": {
|
||||
"button": "फ़ाइल उत्पन्न करें",
|
||||
"generating": "उत्पन्न हो रहा है..."
|
||||
},
|
||||
"download": "{{format}} डाउनलोड करें",
|
||||
"preview": {
|
||||
"title": "पूर्वावलोकन",
|
||||
"liveDescription": "लाइव पूर्वावलोकन: जैसे ही आप पैरामीटर समायोजित करते हैं, पूर्वावलोकन स्वचालित रूप से अपडेट होता है (1-सेकंड की देरी के साथ)। यह उत्पन्न होने वाले सटीक मिरर बुक-फोल्ड डिज़ाइन को दिखाता है।",
|
||||
"note": "नोट: पूर्वावलोकन जनरेशन के लिए OpenSCAD की आवश्यकता होती है। यदि आप एक त्रुटि देखते हैं, तो पूर्वावलोकन सुविधा केवल उत्पादन (Docker) में काम करती है। डिप्लॉय होने पर डाउनलोड कार्यक्षमता अभी भी काम करेगी।",
|
||||
"instructions": "3D मॉडल को घुमाने और ज़ूम करने के लिए अपने माउस का उपयोग करें।"
|
||||
}
|
||||
},
|
||||
"flashcards": {
|
||||
"navTitle": "फ्लैशकार्ड बनाएं",
|
||||
"pageTitle": "अपने फ्लैशकार्ड बनाएं",
|
||||
"pageSubtitle": "सामग्री और शैली को कॉन्फ़िगर करें, तुरंत पूर्वावलोकन करें, फिर अपने फ्लैशकार्ड उत्पन्न करें",
|
||||
"stylePanel": {
|
||||
"title": "🎨 दृश्य शैली",
|
||||
"subtitle": "पूर्वावलोकन में तुरंत परिवर्तन देखें"
|
||||
},
|
||||
"generate": {
|
||||
"button": "फ्लैशकार्ड उत्पन्न करें",
|
||||
"generating": "आपके फ्लैशकार्ड उत्पन्न हो रहे हैं..."
|
||||
},
|
||||
"error": {
|
||||
"title": "जनरेशन विफल",
|
||||
"tryAgain": "फिर से कोशिश करें"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
apps/web/src/i18n/locales/create/ja.json
Normal file
88
apps/web/src/i18n/locales/create/ja.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"create": {
|
||||
"hub": {
|
||||
"pageTitle": "学習ツールを作成",
|
||||
"pageSubtitle": "学習体験を向上させるカスタムフラッシュカードまたは3Dプリント可能なそろばんモデルをデザイン",
|
||||
"flashcards": {
|
||||
"title": "フラッシュカード作成",
|
||||
"description": "そろばんの視覚化を使用したカスタムフラッシュカードをデザインします。数字の認識と算術の学習と教育に最適です。",
|
||||
"feature1": "カスタマイズ可能な数値範囲",
|
||||
"feature2": "複数のスタイルとレイアウト",
|
||||
"feature3": "印刷可能なPDF生成",
|
||||
"button": "フラッシュカードを作成"
|
||||
},
|
||||
"abacus": {
|
||||
"title": "3Dそろばん作成",
|
||||
"description": "3Dプリント可能なそろばんモデルをカスタマイズしてダウンロードします。完璧な学習ツールのためにサイズ、列、色を選択してください。",
|
||||
"feature1": "調整可能なサイズと列",
|
||||
"feature2": "3MF色のカスタマイズ",
|
||||
"feature3": "ライブ3Dプレビュー",
|
||||
"button": "3Dモデルを作成"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "そろばんカレンダー",
|
||||
"description": "各日付がそろばんとして表示される印刷可能なカレンダーを生成します。数字の表現を教えるのに最適です。",
|
||||
"feature1": "月次または日次形式",
|
||||
"feature2": "複数の用紙サイズ",
|
||||
"feature3": "そろばんスタイルを使用",
|
||||
"button": "カレンダーを作成"
|
||||
}
|
||||
},
|
||||
"abacus": {
|
||||
"pageTitle": "3Dプリント可能そろばんをカスタマイズ",
|
||||
"pageSubtitle": "以下のパラメータを調整してそろばんをカスタマイズし、3Dプリント用のファイルを生成してダウンロードします。",
|
||||
"customizationTitle": "カスタマイズパラメータ",
|
||||
"columns": {
|
||||
"label": "列数:{{count}}",
|
||||
"help": "そろばんの総列数(1-13)"
|
||||
},
|
||||
"scaleFactor": {
|
||||
"label": "スケール係数:{{factor}}x",
|
||||
"help": "全体サイズの乗数(アスペクト比を保持、値が大きいほどファイルサイズが大きくなります)"
|
||||
},
|
||||
"widthMm": {
|
||||
"label": "幅(mm)(オプション)",
|
||||
"placeholder": "空白のままでスケール係数を使用",
|
||||
"help": "ミリメートル単位で正確な幅を指定(スケール係数を上書き)"
|
||||
},
|
||||
"format": {
|
||||
"label": "出力形式"
|
||||
},
|
||||
"colors": {
|
||||
"title": "3MF色のカスタマイズ",
|
||||
"frame": "フレーム色",
|
||||
"heavenBead": "天珠の色",
|
||||
"earthBead": "地珠の色",
|
||||
"decoration": "装飾色"
|
||||
},
|
||||
"generate": {
|
||||
"button": "ファイルを生成",
|
||||
"generating": "生成中..."
|
||||
},
|
||||
"download": "{{format}}をダウンロード",
|
||||
"preview": {
|
||||
"title": "プレビュー",
|
||||
"liveDescription": "ライブプレビュー:パラメータを調整すると自動的に更新されます(1秒の遅延)。これにより、生成される正確なミラー折り返しデザインが表示されます。",
|
||||
"note": "注:プレビュー生成にはOpenSCADが必要です。エラーが表示される場合、プレビュー機能は本番環境(Docker)でのみ動作します。デプロイ時にダウンロード機能は引き続き機能します。",
|
||||
"instructions": "マウスを使用して3Dモデルを回転およびズームします。"
|
||||
}
|
||||
},
|
||||
"flashcards": {
|
||||
"navTitle": "フラッシュカードを作成",
|
||||
"pageTitle": "フラッシュカードを作成",
|
||||
"pageSubtitle": "コンテンツとスタイルを設定し、即座にプレビューして、フラッシュカードを生成します",
|
||||
"stylePanel": {
|
||||
"title": "🎨 ビジュアルスタイル",
|
||||
"subtitle": "プレビューで変更を即座に確認"
|
||||
},
|
||||
"generate": {
|
||||
"button": "フラッシュカードを生成",
|
||||
"generating": "フラッシュカードを生成中..."
|
||||
},
|
||||
"error": {
|
||||
"title": "生成失敗",
|
||||
"tryAgain": "再試行"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
apps/web/src/i18n/locales/create/la.json
Normal file
88
apps/web/src/i18n/locales/create/la.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"create": {
|
||||
"hub": {
|
||||
"pageTitle": "Instrumenta Discendi Tua Crea",
|
||||
"pageSubtitle": "Cartas memoriativas vel modulos abaci 3D imprimibiles designa ut experientiam discendi tuam augeas",
|
||||
"flashcards": {
|
||||
"title": "Creator Chartarum Memorativarum",
|
||||
"description": "Cartas memoriativas cum visualizationibus abaci designa. Perfectus ad discendum et docendum recognitionem numerorum et arithmeticam.",
|
||||
"feature1": "Limites numerorum configurabiles",
|
||||
"feature2": "Multi styli et dispositiones",
|
||||
"feature3": "Generatio PDF parata ad imprimendum",
|
||||
"button": "Cartas Crea"
|
||||
},
|
||||
"abacus": {
|
||||
"title": "Creator Abaci 3D",
|
||||
"description": "Modulos abaci 3D imprimibiles configura et depone. Magnitudinem, columnas, colores tuos elige pro instrumento discendi perfecto.",
|
||||
"feature1": "Magnitudo et columnae adaptabiles",
|
||||
"feature2": "Configuratio colorum 3MF",
|
||||
"feature3": "Praevisio 3D viva",
|
||||
"button": "Modulum 3D Crea"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendarium Abaci",
|
||||
"description": "Calendaria imprimibilia genera ubi omnis dies ut abacus monstratur. Perfectus ad docendam repraesentationem numerorum.",
|
||||
"feature1": "Formae mensuales vel diurnae",
|
||||
"feature2": "Magnitudines chartae multiplices",
|
||||
"feature3": "Stilum abaci tui utitur",
|
||||
"button": "Calendarium Crea"
|
||||
}
|
||||
},
|
||||
"abacus": {
|
||||
"pageTitle": "Abacum Tuum 3D Imprimibilem Configura",
|
||||
"pageSubtitle": "Parametros infra adapta ut abacum tuum configures, deinde fasciculum genera et depone pro impressione 3D.",
|
||||
"customizationTitle": "Parametri Configurationis",
|
||||
"columns": {
|
||||
"label": "Numerus Columnarum: {{count}}",
|
||||
"help": "Numerus totalis columnarum in abaco (1-13)"
|
||||
},
|
||||
"scaleFactor": {
|
||||
"label": "Factor Scalae: {{factor}}x",
|
||||
"help": "Multiplicator magnitudinis totalis (servat proportionem aspectus, valores maiores = magnitudo fasciculi maior)"
|
||||
},
|
||||
"widthMm": {
|
||||
"label": "Latitudo in mm (optionalis)",
|
||||
"placeholder": "Vacuum relinque ut factorem scalae utaris",
|
||||
"help": "Latitudinem exactam in millimetris specifica (factorem scalae superat)"
|
||||
},
|
||||
"format": {
|
||||
"label": "Forma Exitus"
|
||||
},
|
||||
"colors": {
|
||||
"title": "Configuratio Colorum 3MF",
|
||||
"frame": "Color Cornicis",
|
||||
"heavenBead": "Color Globuli Caelestis",
|
||||
"earthBead": "Color Globuli Terrae",
|
||||
"decoration": "Color Ornamenti"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Fasciculum Genera",
|
||||
"generating": "Generatur..."
|
||||
},
|
||||
"download": "{{format}} Depone",
|
||||
"preview": {
|
||||
"title": "Praevisio",
|
||||
"liveDescription": "Praevisio viva: Praevisio automatice renovatur dum parametros adaptas (cum mora 1 secundi). Hoc designum exactum libri specularis plicati quod generabitur monstrat.",
|
||||
"note": "Nota: Generatio praevisionis OpenSCAD requirit. Si errorem vides, facultas praevisionis solum in productione (Docker) operatur. Facultas deponendi adhuc operabitur cum explicatur.",
|
||||
"instructions": "Mure tuo utere ut modulum 3D rotas et augeas."
|
||||
}
|
||||
},
|
||||
"flashcards": {
|
||||
"navTitle": "Cartas Crea",
|
||||
"pageTitle": "Cartas Tuas Crea",
|
||||
"pageSubtitle": "Contentum et stylum configura, statim praevide, deinde cartas tuas genera",
|
||||
"stylePanel": {
|
||||
"title": "🎨 Stilus Visualis",
|
||||
"subtitle": "Mutationes statim in praevisione vide"
|
||||
},
|
||||
"generate": {
|
||||
"button": "Cartas Genera",
|
||||
"generating": "Cartae Tuae Generantur..."
|
||||
},
|
||||
"error": {
|
||||
"title": "Generatio Defecit",
|
||||
"tryAgain": "Iterum Tempta"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
apps/web/src/i18n/locales/create/messages.ts
Normal file
17
apps/web/src/i18n/locales/create/messages.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import de from './de.json'
|
||||
import en from './en.json'
|
||||
import es from './es.json'
|
||||
import goh from './goh.json'
|
||||
import hi from './hi.json'
|
||||
import ja from './ja.json'
|
||||
import la from './la.json'
|
||||
|
||||
export const createMessages = {
|
||||
en: en.create,
|
||||
de: de.create,
|
||||
ja: ja.create,
|
||||
hi: hi.create,
|
||||
es: es.create,
|
||||
la: la.create,
|
||||
goh: goh.create,
|
||||
} as const
|
||||
@@ -1,4 +1,6 @@
|
||||
import { rithmomachiaMessages } from '@/arcade-games/rithmomachia/messages'
|
||||
import { calendarMessages } from '@/i18n/locales/calendar/messages'
|
||||
import { createMessages } from '@/i18n/locales/create/messages'
|
||||
import { gamesMessages } from '@/i18n/locales/games/messages'
|
||||
import { guideMessages } from '@/i18n/locales/guide/messages'
|
||||
import { homeMessages } from '@/i18n/locales/home/messages'
|
||||
@@ -40,6 +42,8 @@ export async function getMessages(locale: Locale) {
|
||||
{ games: gamesMessages[locale] },
|
||||
{ guide: guideMessages[locale] },
|
||||
{ tutorial: tutorialMessages[locale] },
|
||||
{ calendar: calendarMessages[locale] },
|
||||
{ create: createMessages[locale] },
|
||||
rithmomachiaMessages[locale]
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
228
apps/web/src/utils/calendar/generateCalendarComposite.tsx
Normal file
228
apps/web/src/utils/calendar/generateCalendarComposite.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Generate a complete monthly calendar as a single SVG
|
||||
* This prevents multi-page overflow - one image scales to fit
|
||||
*/
|
||||
|
||||
import type 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}
|
||||
hideInactiveBeads={true}
|
||||
cropToActiveBeads={{
|
||||
padding: { top: 8, bottom: 2, left: 5, right: 5 }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Render cropped abacus SVG
|
||||
const abacusSVG = renderAbacusSVG(day, 2, 1)
|
||||
|
||||
// Extract viewBox and dimensions from the cropped SVG
|
||||
const viewBoxMatch = abacusSVG.match(/viewBox="([^"]*)"/)
|
||||
const widthMatch = abacusSVG.match(/width="?([0-9.]+)"?/)
|
||||
const heightMatch = abacusSVG.match(/height="?([0-9.]+)"?/)
|
||||
|
||||
const croppedViewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 120 230'
|
||||
const croppedWidth = widthMatch ? parseFloat(widthMatch[1]) : ABACUS_NATURAL_WIDTH
|
||||
const croppedHeight = heightMatch ? parseFloat(heightMatch[1]) : ABACUS_NATURAL_HEIGHT
|
||||
|
||||
// Calculate scale to fit cropped abacus in cell
|
||||
const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / croppedWidth
|
||||
const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / croppedHeight
|
||||
const fitScale = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.95 // 95% to leave breathing room
|
||||
|
||||
const scaledWidth = croppedWidth * fitScale
|
||||
const scaledHeight = croppedHeight * fitScale
|
||||
|
||||
// Center abacus in cell
|
||||
const abacusCenterX = cellX + CELL_WIDTH / 2
|
||||
const abacusCenterY = cellY + DAY_CELL_HEIGHT / 2
|
||||
|
||||
// Offset to top-left corner of abacus
|
||||
const abacusX = abacusCenterX - scaledWidth / 2
|
||||
const abacusY = abacusCenterY - scaledHeight / 2
|
||||
|
||||
// Extract SVG content (remove outer <svg> tags)
|
||||
const svgContent = abacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
|
||||
|
||||
return `
|
||||
<!-- Day ${day} (row ${row}, col ${col}) -->
|
||||
<svg x="${abacusX}" y="${abacusY}" width="${scaledWidth}" height="${scaledHeight}"
|
||||
viewBox="${croppedViewBox}">
|
||||
${svgContent}
|
||||
</svg>`
|
||||
})
|
||||
.join('')}
|
||||
</svg>`
|
||||
|
||||
return compositeSVG
|
||||
}
|
||||
206
apps/web/src/workers/openscad.worker.ts
Normal file
206
apps/web/src/workers/openscad.worker.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { createOpenSCAD } from 'openscad-wasm-prebuilt'
|
||||
|
||||
declare const self: DedicatedWorkerGlobalScope
|
||||
|
||||
let openscad: Awaited<ReturnType<typeof createOpenSCAD>> | null = null
|
||||
let simplifiedStlData: ArrayBuffer | null = null
|
||||
let isInitializing = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
// Message types
|
||||
interface RenderRequest {
|
||||
type: 'render'
|
||||
columns: number
|
||||
scaleFactor: number
|
||||
}
|
||||
|
||||
interface InitRequest {
|
||||
type: 'init'
|
||||
}
|
||||
|
||||
type WorkerRequest = RenderRequest | InitRequest
|
||||
|
||||
// Initialize OpenSCAD instance and load base STL file
|
||||
async function initialize() {
|
||||
if (openscad) return // Already initialized
|
||||
if (isInitializing) return initPromise // Already initializing, return existing promise
|
||||
|
||||
isInitializing = true
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
console.log('[OpenSCAD Worker] Initializing...')
|
||||
|
||||
// Create OpenSCAD instance
|
||||
openscad = await createOpenSCAD()
|
||||
console.log('[OpenSCAD Worker] OpenSCAD WASM loaded')
|
||||
|
||||
// Fetch the simplified STL file once
|
||||
const stlResponse = await fetch('/3d-models/simplified.abacus.stl')
|
||||
if (!stlResponse.ok) {
|
||||
throw new Error(`Failed to fetch STL: ${stlResponse.statusText}`)
|
||||
}
|
||||
simplifiedStlData = await stlResponse.arrayBuffer()
|
||||
console.log('[OpenSCAD Worker] Simplified STL loaded', simplifiedStlData.byteLength, 'bytes')
|
||||
|
||||
self.postMessage({ type: 'ready' })
|
||||
} catch (error) {
|
||||
console.error('[OpenSCAD Worker] Initialization failed:', error)
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Initialization failed',
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
isInitializing = false
|
||||
}
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
async function render(columns: number, scaleFactor: number) {
|
||||
// Wait for initialization if not ready
|
||||
if (!openscad || !simplifiedStlData) {
|
||||
await initialize()
|
||||
}
|
||||
|
||||
if (!openscad || !simplifiedStlData) {
|
||||
throw new Error('Worker not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[OpenSCAD Worker] Rendering with columns=${columns}, scaleFactor=${scaleFactor}`)
|
||||
|
||||
// Get low-level instance for filesystem access
|
||||
const instance = openscad.getInstance()
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
try {
|
||||
instance.FS.mkdir('/3d-models')
|
||||
console.log('[OpenSCAD Worker] Created /3d-models directory')
|
||||
} catch (e: any) {
|
||||
// Check if it's EEXIST (directory already exists) - errno 20
|
||||
if (e.errno === 20) {
|
||||
console.log('[OpenSCAD Worker] /3d-models directory already exists')
|
||||
} else {
|
||||
console.error('[OpenSCAD Worker] Failed to create directory:', e)
|
||||
throw new Error(`Failed to create /3d-models directory: ${e.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Write STL file
|
||||
instance.FS.writeFile('/3d-models/simplified.abacus.stl', new Uint8Array(simplifiedStlData))
|
||||
console.log('[OpenSCAD Worker] Wrote simplified STL to filesystem')
|
||||
|
||||
// Generate the SCAD code with parameters
|
||||
const scadCode = `
|
||||
// Inline version of abacus.scad that doesn't require BOSL2
|
||||
columns = ${columns};
|
||||
scale_factor = ${scaleFactor};
|
||||
|
||||
stl_path = "/3d-models/simplified.abacus.stl";
|
||||
|
||||
// Known bounding box dimensions
|
||||
bbox_size = [186, 60, 120];
|
||||
|
||||
// Calculate parameters
|
||||
total_columns_in_stl = 13;
|
||||
columns_per_side = columns / 2;
|
||||
width_scale = columns_per_side / total_columns_in_stl;
|
||||
|
||||
units_per_column = bbox_size[0] / total_columns_in_stl;
|
||||
column_spacing = columns_per_side * units_per_column;
|
||||
|
||||
// Model modules
|
||||
module imported() {
|
||||
import(stl_path, convexity = 10);
|
||||
}
|
||||
|
||||
module bounding_box_manual() {
|
||||
translate([-bbox_size[0]/2, -bbox_size[1]/2, -bbox_size[2]/2])
|
||||
cube(bbox_size);
|
||||
}
|
||||
|
||||
module half_abacus() {
|
||||
intersection() {
|
||||
scale([width_scale, 1, 1]) bounding_box_manual();
|
||||
imported();
|
||||
}
|
||||
}
|
||||
|
||||
scale([scale_factor, scale_factor, scale_factor]) {
|
||||
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
|
||||
half_abacus();
|
||||
}
|
||||
`
|
||||
|
||||
// Use high-level renderToStl API
|
||||
console.log('[OpenSCAD Worker] Calling renderToStl...')
|
||||
const stlBuffer = await openscad.renderToStl(scadCode)
|
||||
console.log('[OpenSCAD Worker] Rendering complete:', stlBuffer.byteLength, 'bytes')
|
||||
|
||||
// Send the result back
|
||||
self.postMessage({
|
||||
type: 'result',
|
||||
stl: stlBuffer,
|
||||
}, [stlBuffer]) // Transfer ownership of the buffer
|
||||
|
||||
// Clean up STL file
|
||||
try {
|
||||
instance.FS.unlink('/3d-models/simplified.abacus.stl')
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OpenSCAD Worker] Rendering failed:', error)
|
||||
|
||||
// Try to get more error details
|
||||
let errorMessage = 'Rendering failed'
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message
|
||||
console.error('[OpenSCAD Worker] Error stack:', error.stack)
|
||||
}
|
||||
|
||||
// Check if it's an Emscripten FS error
|
||||
if (error && typeof error === 'object' && 'errno' in error) {
|
||||
console.error('[OpenSCAD Worker] FS errno:', (error as any).errno)
|
||||
console.error('[OpenSCAD Worker] FS error details:', error)
|
||||
}
|
||||
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Message handler
|
||||
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
||||
const { data } = event
|
||||
|
||||
try {
|
||||
switch (data.type) {
|
||||
case 'init':
|
||||
await initialize()
|
||||
break
|
||||
|
||||
case 'render':
|
||||
await render(data.columns, data.scaleFactor)
|
||||
break
|
||||
|
||||
default:
|
||||
console.error('[OpenSCAD Worker] Unknown message type:', data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OpenSCAD Worker] Message handler error:', error)
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize on worker start
|
||||
initialize()
|
||||
@@ -11,7 +11,24 @@
|
||||
"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)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(pnpm dev)",
|
||||
"Bash(npx biome:*)",
|
||||
"Bash(pnpm --filter @soroban/web tsc:*)",
|
||||
"Bash(git tag:*)",
|
||||
"Bash(gh api:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:schroer.ca)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Bash(npm search:*)",
|
||||
"Bash(pnpm add:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
|
||||
@@ -1,3 +1,62 @@
|
||||
# [2.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.9.0...abacus-react-v2.10.0) (2025-11-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* replace regex HTML parsing with deterministic bead position calculations in icon generation ([41a3707](https://github.com/antialias/soroban-abacus-flashcards/commit/41a3707841595a74de56c6adf6d271237f81ee0e))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add cropToActiveBeads prop to AbacusStatic and AbacusReact ([35b0824](https://github.com/antialias/soroban-abacus-flashcards/commit/35b0824fc4fb0b754e53b20a00541da1bf4b8434))
|
||||
* **calendar:** add beautiful daily calendar with locale-based paper size detection ([bdca315](https://github.com/antialias/soroban-abacus-flashcards/commit/bdca3154f8336e17a7031be8d2917f9cf05f274a))
|
||||
* **calendar:** add i18n support and cropped abacus day numbers ([5242f89](https://github.com/antialias/soroban-abacus-flashcards/commit/5242f890f725c872a74b6ee45cd611092628690a))
|
||||
* **i18n:** add internationalization for all create pages ([b080970](https://github.com/antialias/soroban-abacus-flashcards/commit/b080970d7647c8286a713b05b772166c2d701c4c))
|
||||
|
||||
# [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.8.3...abacus-react-v2.9.0) (2025-11-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** upgrade OpenSCAD to 2024.11 to fix CGAL intersection bug ([e1bcd24](https://github.com/antialias/soroban-abacus-flashcards/commit/e1bcd241691050fa05cd49e14c288b4b070a7d17))
|
||||
* **guide:** increase abacus sizes - they were too small ([1074624](https://github.com/antialias/soroban-abacus-flashcards/commit/1074624b2fbce1d1d887dbd6326cf22eeb31dcec))
|
||||
* **guide:** make abacus sizes consistent and add nav spacing ([bea4842](https://github.com/antialias/soroban-abacus-flashcards/commit/bea4842a29aa86ca4261b4ddd6150bacc8babc46))
|
||||
* **guide:** remove inner containers and tighten margins ([7e54c6f](https://github.com/antialias/soroban-abacus-flashcards/commit/7e54c6f4fc5bc4daa6088eb3381d860a495776f2))
|
||||
* **layout:** add systematic spacing for fixed nav bar ([4559fb1](https://github.com/antialias/soroban-abacus-flashcards/commit/4559fb121d0df954ebaf33616a5262c7ca633c6e))
|
||||
* **layout:** remove wrapper, use utility class for nav spacing ([247c3d9](https://github.com/antialias/soroban-abacus-flashcards/commit/247c3d9874303f83641e599724a485eea8d5604a))
|
||||
* **nav:** restrict transparent hero styling to home page only ([fab227d](https://github.com/antialias/soroban-abacus-flashcards/commit/fab227d6862672e8250b1c169b302fbae23ce4d2))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **3d-abacus:** change default columns from 13 to 4 ([cd15c70](https://github.com/antialias/soroban-abacus-flashcards/commit/cd15c70a25c597c17ee5d2f816b1c85ba8ce4ce9))
|
||||
* add client-side OpenSCAD WASM support for 3D preview ([eaaf17c](https://github.com/antialias/soroban-abacus-flashcards/commit/eaaf17cd4c675bfd40e0573b9c99f0c733d926aa))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -146,6 +146,62 @@ import { AbacusStatic } from '@soroban/abacus-react/static';
|
||||
- `@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 |
|
||||
@@ -156,8 +212,9 @@ import { AbacusStatic } from '@soroban/abacus-react/static';
|
||||
| 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 | Interactive tutorials, games, tools |
|
||||
| Use cases | Preview cards, thumbnails, static pages, PDFs | Interactive tutorials, games, tools |
|
||||
|
||||
```tsx
|
||||
// Example: Server Component with static abacus cards
|
||||
@@ -648,6 +705,63 @@ 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
|
||||
@@ -725,6 +839,8 @@ import {
|
||||
calculateBeadDiffFromValues,
|
||||
validateAbacusValue,
|
||||
areStatesEqual,
|
||||
calculateStandardDimensions, // NEW: Shared layout calculator
|
||||
calculateBeadPosition, // NEW: Bead position calculator
|
||||
|
||||
// Theme Presets
|
||||
ABACUS_THEMES,
|
||||
@@ -740,7 +856,9 @@ import {
|
||||
BeadState,
|
||||
BeadDiffResult,
|
||||
BeadDiffOutput,
|
||||
AbacusThemeName
|
||||
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
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
435
packages/abacus-react/src/AbacusCropping.stories.tsx
Normal file
435
packages/abacus-react/src/AbacusCropping.stories.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { AbacusStatic } from './AbacusStatic'
|
||||
import { AbacusReact } from './AbacusReact'
|
||||
|
||||
/**
|
||||
* Abacus Cropping - Automatic viewBox cropping to active beads
|
||||
*
|
||||
* ## Overview:
|
||||
* The `cropToActiveBeads` prop automatically crops the SVG viewBox to show only active beads,
|
||||
* making it perfect for:
|
||||
* - Compact inline displays
|
||||
* - Favicon generation (see /icon route)
|
||||
* - Focus on active beads without distraction
|
||||
* - Responsive layouts where space is limited
|
||||
*
|
||||
* ## Key Features:
|
||||
* - ✅ Works with both AbacusStatic and AbacusReact
|
||||
* - ✅ Configurable padding (top, bottom, left, right)
|
||||
* - ✅ Maintains aspect ratio
|
||||
* - ✅ Preserves frame elements (posts, bar) if enabled
|
||||
* - ✅ Dynamic cropping based on value
|
||||
*
|
||||
* ## API:
|
||||
* ```tsx
|
||||
* // Simple boolean
|
||||
* <AbacusStatic value={15} cropToActiveBeads />
|
||||
*
|
||||
* // With custom padding
|
||||
* <AbacusStatic
|
||||
* value={15}
|
||||
* cropToActiveBeads={{
|
||||
* padding: { top: 8, bottom: 2, left: 5, right: 5 }
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const meta = {
|
||||
title: 'AbacusStatic/Cropping',
|
||||
component: AbacusStatic,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AbacusStatic>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const CroppedVsUncropped: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', alignItems: 'flex-start' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '10px' }}>
|
||||
<AbacusStatic value={15} hideInactiveBeads />
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Without Cropping</p>
|
||||
<p style={{ fontSize: '12px', color: '#94a3b8' }}>Full abacus frame shown</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #3b82f6', borderRadius: '8px', padding: '10px' }}>
|
||||
<AbacusStatic value={15} hideInactiveBeads cropToActiveBeads />
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#3b82f6', fontWeight: 'bold' }}>With Cropping</p>
|
||||
<p style={{ fontSize: '12px', color: '#94a3b8' }}>Focused on active beads</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DifferentValues: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '20px' }}>
|
||||
{[1, 5, 10, 15, 20, 25, 30, 31].map((value) => (
|
||||
<div key={value} style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '10px', minHeight: '100px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AbacusStatic
|
||||
value={value}
|
||||
columns={2}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', fontSize: '16px', fontWeight: 'bold', color: '#475569' }}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const CustomPadding: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '10px' }}>
|
||||
<AbacusStatic
|
||||
value={15}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Default Padding</p>
|
||||
<p style={{ fontSize: '11px', color: '#94a3b8' }}>No custom padding</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '10px' }}>
|
||||
<AbacusStatic
|
||||
value={15}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads={{ padding: { top: 20, bottom: 20, left: 20, right: 20 } }}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Generous Padding</p>
|
||||
<p style={{ fontSize: '11px', color: '#94a3b8' }}>20px all around</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '10px' }}>
|
||||
<AbacusStatic
|
||||
value={15}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads={{ padding: { top: 2, bottom: 2, left: 2, right: 2 } }}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Tight Crop</p>
|
||||
<p style={{ fontSize: '11px', color: '#94a3b8' }}>2px minimal padding</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '10px' }}>
|
||||
<AbacusStatic
|
||||
value={15}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads={{ padding: { top: 15, bottom: 2, left: 5, right: 5 } }}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Asymmetric</p>
|
||||
<p style={{ fontSize: '11px', color: '#94a3b8' }}>More top space</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithColorSchemes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap' }}>
|
||||
{(['place-value', 'monochrome', 'heaven-earth', 'alternating'] as const).map((scheme) => (
|
||||
<div key={scheme} style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '10px', background: 'white' }}>
|
||||
<AbacusStatic
|
||||
value={123}
|
||||
colorScheme={scheme}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
scaleFactor={0.9}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b', fontSize: '14px' }}>
|
||||
{scheme.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DifferentColumnCounts: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '15px', background: 'white' }}>
|
||||
<AbacusStatic
|
||||
value={9}
|
||||
columns={1}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
scaleFactor={1.5}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>1 Column</p>
|
||||
<p style={{ fontSize: '12px', color: '#94a3b8' }}>Value: 9</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '15px', background: 'white' }}>
|
||||
<AbacusStatic
|
||||
value={25}
|
||||
columns={2}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
scaleFactor={1.5}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>2 Columns</p>
|
||||
<p style={{ fontSize: '12px', color: '#94a3b8' }}>Value: 25</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '15px', background: 'white' }}>
|
||||
<AbacusStatic
|
||||
value={456}
|
||||
columns={3}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>3 Columns</p>
|
||||
<p style={{ fontSize: '12px', color: '#94a3b8' }}>Value: 456</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '15px', background: 'white' }}>
|
||||
<AbacusStatic
|
||||
value={9876}
|
||||
columns={4}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
scaleFactor={1.0}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>4 Columns</p>
|
||||
<p style={{ fontSize: '12px', color: '#94a3b8' }}>Value: 9876</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const InlineEquation: Story = {
|
||||
render: () => (
|
||||
<div style={{ fontSize: '32px', display: 'flex', alignItems: 'center', gap: '15px', fontFamily: 'system-ui' }}>
|
||||
<span>If</span>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '6px', padding: '5px', background: 'white' }}>
|
||||
<AbacusStatic value={5} columns={1} hideInactiveBeads cropToActiveBeads scaleFactor={1.2} />
|
||||
</div>
|
||||
<span>+</span>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '6px', padding: '5px', background: 'white' }}>
|
||||
<AbacusStatic value={3} columns={1} hideInactiveBeads cropToActiveBeads scaleFactor={1.2} />
|
||||
</div>
|
||||
<span>=</span>
|
||||
<div style={{ border: '2px solid #10b981', borderRadius: '6px', padding: '5px', background: '#f0fdf4' }}>
|
||||
<AbacusStatic value={8} columns={1} hideInactiveBeads cropToActiveBeads scaleFactor={1.2} />
|
||||
</div>
|
||||
<span>then what is</span>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '6px', padding: '5px', background: 'white' }}>
|
||||
<AbacusStatic value={10} columns={2} hideInactiveBeads cropToActiveBeads scaleFactor={1.0} />
|
||||
</div>
|
||||
<span>+</span>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '6px', padding: '5px', background: 'white' }}>
|
||||
<AbacusStatic value={15} columns={2} hideInactiveBeads cropToActiveBeads scaleFactor={1.0} />
|
||||
</div>
|
||||
<span>?</span>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const FaviconStyle: Story = {
|
||||
render: () => (
|
||||
<div>
|
||||
<p style={{ marginBottom: '20px', color: '#64748b', maxWidth: '600px' }}>
|
||||
These examples show how cropping is used for the dynamic favicon (see <code>/icon</code> route).
|
||||
Each day of the month gets its own cropped abacus icon.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(80px, 1fr))', gap: '15px', maxWidth: '800px' }}>
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => (
|
||||
<div key={day} style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '4px',
|
||||
background: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
<AbacusStatic
|
||||
value={day}
|
||||
columns={2}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads={{
|
||||
padding: { top: 6, bottom: 2, left: 4, right: 4 }
|
||||
}}
|
||||
scaleFactor={0.8}
|
||||
customStyles={{
|
||||
columnPosts: { fill: '#1c1917', stroke: '#0c0a09', strokeWidth: 2 },
|
||||
reckoningBar: { fill: '#1c1917', stroke: '#0c0a09', strokeWidth: 3 },
|
||||
columns: {
|
||||
0: { heavenBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 2 }, earthBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 2 } },
|
||||
1: { heavenBeads: { fill: '#a855f7', stroke: '#7e22ce', strokeWidth: 2 }, earthBeads: { fill: '#a855f7', stroke: '#7e22ce', strokeWidth: 2 } },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '8px', fontSize: '12px', color: '#64748b', fontWeight: 'bold' }}>{day}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const AbacusReactCropping: Story = {
|
||||
render: () => (
|
||||
<div>
|
||||
<p style={{ marginBottom: '20px', color: '#64748b', maxWidth: '600px' }}>
|
||||
Cropping also works with <code>AbacusReact</code> (the interactive animated version).
|
||||
This is useful for interactive tutorials where you want to focus on specific beads.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '15px', background: 'white' }}>
|
||||
<AbacusReact
|
||||
value={15}
|
||||
columns={2}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
animated
|
||||
interactive
|
||||
scaleFactor={1.5}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Interactive + Cropped</p>
|
||||
<p style={{ fontSize: '12px', color: '#94a3b8' }}>Try clicking the beads!</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '15px', background: 'white' }}>
|
||||
<AbacusReact
|
||||
value={9}
|
||||
columns={1}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads={{ padding: { top: 10, bottom: 10, left: 10, right: 10 } }}
|
||||
animated
|
||||
interactive
|
||||
scaleFactor={2.0}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Single Column</p>
|
||||
<p style={{ fontSize: '12px', color: '#94a3b8' }}>Value: 9</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '15px', background: 'white' }}>
|
||||
<AbacusReact
|
||||
value={456}
|
||||
columns={3}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
animated
|
||||
interactive
|
||||
scaleFactor={1.2}
|
||||
colorScheme="heaven-earth"
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Three Columns</p>
|
||||
<p style={{ fontSize: '12px', color: '#94a3b8' }}>Heaven-Earth colors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ComparisonGrid: Story = {
|
||||
render: () => {
|
||||
const testValues = [
|
||||
{ value: 1, label: 'Minimal (1)' },
|
||||
{ value: 5, label: 'Heaven only (5)' },
|
||||
{ value: 4, label: 'Earth only (4)' },
|
||||
{ value: 9, label: 'Maximum (9)' },
|
||||
{ value: 15, label: 'Two columns (15)' },
|
||||
{ value: 99, label: 'Two nines (99)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ marginBottom: '20px', color: '#64748b', maxWidth: '700px' }}>
|
||||
Comparison showing how cropping adapts to different bead configurations.
|
||||
Notice how the viewBox changes dynamically based on active beads.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '20px' }}>
|
||||
{testValues.map(({ value, label }) => (
|
||||
<div key={value}>
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<h4 style={{ margin: '0 0 10px 0', color: '#475569' }}>{label}</h4>
|
||||
<div style={{ display: 'flex', gap: '15px' }}>
|
||||
<div style={{ flex: 1, textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '6px', padding: '10px', minHeight: '120px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AbacusStatic
|
||||
value={value}
|
||||
columns="auto"
|
||||
hideInactiveBeads
|
||||
scaleFactor={1.0}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '5px', fontSize: '11px', color: '#94a3b8' }}>Normal</p>
|
||||
</div>
|
||||
<div style={{ flex: 1, textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #3b82f6', borderRadius: '6px', padding: '10px', minHeight: '120px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#eff6ff' }}>
|
||||
<AbacusStatic
|
||||
value={value}
|
||||
columns="auto"
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
scaleFactor={1.0}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '5px', fontSize: '11px', color: '#3b82f6', fontWeight: 'bold' }}>Cropped</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const BeadShapesWithCropping: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap' }}>
|
||||
{(['circle', 'diamond', 'square'] as const).map((shape) => (
|
||||
<div key={shape} style={{ textAlign: 'center' }}>
|
||||
<div style={{ border: '2px solid #e2e8f0', borderRadius: '8px', padding: '15px', background: 'white' }}>
|
||||
<AbacusStatic
|
||||
value={25}
|
||||
columns={2}
|
||||
beadShape={shape}
|
||||
hideInactiveBeads
|
||||
cropToActiveBeads
|
||||
scaleFactor={1.5}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: '10px', color: '#64748b', textTransform: 'capitalize' }}>{shape}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
401
packages/abacus-react/src/AbacusSVGRenderer.tsx
Normal file
401
packages/abacus-react/src/AbacusSVGRenderer.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* 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, calculateAbacusCrop, type AbacusState, type CropPadding } 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
|
||||
|
||||
// Cropping
|
||||
cropToActiveBeads?: boolean | { padding?: CropPadding }
|
||||
|
||||
// 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,
|
||||
cropToActiveBeads,
|
||||
highlightColumns = [],
|
||||
columnLabels = [],
|
||||
defsContent,
|
||||
children,
|
||||
BeadComponent,
|
||||
getBeadColor,
|
||||
onBeadClick,
|
||||
onBeadMouseEnter,
|
||||
onBeadMouseLeave,
|
||||
onBeadRef,
|
||||
calculateExtraBeadProps,
|
||||
}: AbacusSVGRendererProps) {
|
||||
const { width, height, rodSpacing, barY, beadSize, barThickness, labelHeight, numbersHeight } = dimensions
|
||||
|
||||
// Calculate crop viewBox if enabled
|
||||
let viewBox = `0 0 ${width} ${height}`
|
||||
let svgWidth = width
|
||||
let svgHeight = height
|
||||
|
||||
if (cropToActiveBeads) {
|
||||
const padding = typeof cropToActiveBeads === 'object' ? cropToActiveBeads.padding : undefined
|
||||
// Use the actual scaleFactor so crop calculations match the rendered abacus size
|
||||
const crop = calculateAbacusCrop(Number(value), columns, scaleFactor, padding)
|
||||
viewBox = crop.viewBox
|
||||
svgWidth = crop.width
|
||||
svgHeight = crop.height
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
viewBox={viewBox}
|
||||
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
|
||||
@@ -7,20 +7,39 @@ import { ABACUS_THEMES } from './AbacusThemes'
|
||||
*
|
||||
* ## Key Features:
|
||||
* - ✅ Works in React Server Components (no "use client")
|
||||
* - ✅ Shares core utilities with AbacusReact (numberToAbacusState, color logic)
|
||||
* - ✅ **Identical layout to AbacusReact** - same props = same exact SVG output
|
||||
* - ✅ No animations, hooks, or client-side JavaScript
|
||||
* - ✅ Lightweight rendering for static displays
|
||||
*
|
||||
* ## Shared Code (No Duplication!):
|
||||
* - Uses `numberToAbacusState()` from AbacusUtils
|
||||
* - Uses same color scheme logic as AbacusReact
|
||||
* - Uses same bead positioning concepts
|
||||
* - Accepts same `customStyles` prop structure
|
||||
* ## 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 = {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* AbacusStatic - Server Component compatible static abacus
|
||||
*
|
||||
* Shares core logic with AbacusReact but uses static rendering without hooks/animations.
|
||||
* Reuses: numberToAbacusState, getBeadColor logic, positioning calculations
|
||||
* Different: No hooks, no animations, no interactions, simplified rendering
|
||||
* 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, calculateAbacusDimensions } from './AbacusUtils'
|
||||
import { numberToAbacusState, calculateStandardDimensions, type CropPadding } from './AbacusUtils'
|
||||
import { AbacusSVGRenderer } from './AbacusSVGRenderer'
|
||||
import { AbacusStaticBead } from './AbacusStaticBead'
|
||||
import type {
|
||||
AbacusCustomStyles,
|
||||
@@ -28,9 +30,10 @@ export interface AbacusStaticConfig {
|
||||
customStyles?: AbacusCustomStyles
|
||||
highlightColumns?: number[]
|
||||
columnLabels?: string[]
|
||||
cropToActiveBeads?: boolean | { padding?: CropPadding }
|
||||
}
|
||||
|
||||
// Shared color logic from AbacusReact (simplified for static use)
|
||||
// Shared color logic (matches AbacusReact)
|
||||
function getBeadColor(
|
||||
bead: BeadConfig,
|
||||
totalColumns: number,
|
||||
@@ -87,37 +90,6 @@ function getBeadColor(
|
||||
return '#3b82f6'
|
||||
}
|
||||
|
||||
// Calculate bead positions (simplified from AbacusReact)
|
||||
function calculateBeadPosition(
|
||||
bead: BeadConfig,
|
||||
dimensions: { beadSize: number; rodSpacing: number; heavenY: number; earthY: number; barY: number; totalColumns: number }
|
||||
): { x: number; y: number } {
|
||||
const { beadSize, rodSpacing, heavenY, earthY, barY, 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
|
||||
if (bead.type === 'heaven') {
|
||||
// Heaven bead: if active, near bar; if inactive, at top
|
||||
const y = bead.active ? barY - beadSize - 5 : heavenY
|
||||
return { x, y }
|
||||
} else {
|
||||
// Earth bead: if active, stack up from bar; if inactive, at bottom
|
||||
const earthSpacing = beadSize + 4
|
||||
if (bead.active) {
|
||||
// Active earth beads stack upward from the bar
|
||||
const y = barY + beadSize / 2 + 10 + bead.position * earthSpacing
|
||||
return { x, y }
|
||||
} else {
|
||||
// Inactive earth beads rest at the bottom
|
||||
const y = earthY + (bead.position - 2) * earthSpacing
|
||||
return { x, y }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AbacusStatic - Pure static abacus component (Server Component compatible)
|
||||
*/
|
||||
@@ -135,6 +107,7 @@ export function AbacusStatic({
|
||||
customStyles,
|
||||
highlightColumns = [],
|
||||
columnLabels = [],
|
||||
cropToActiveBeads,
|
||||
}: AbacusStaticConfig) {
|
||||
// Calculate columns
|
||||
const valueStr = value.toString().replace('-', '')
|
||||
@@ -175,196 +148,39 @@ export function AbacusStatic({
|
||||
beadConfigs.push(beads)
|
||||
}
|
||||
|
||||
// Calculate dimensions using shared utility
|
||||
const { width, height } = calculateAbacusDimensions({
|
||||
// Calculate standard dimensions (same as AbacusReact!)
|
||||
const dimensions = calculateStandardDimensions({
|
||||
columns: effectiveColumns,
|
||||
scaleFactor,
|
||||
showNumbers: !!showNumbers,
|
||||
columnLabels,
|
||||
})
|
||||
|
||||
// Layout constants (must match calculateAbacusDimensions)
|
||||
const beadSize = 20
|
||||
const rodSpacing = 40
|
||||
const heavenHeight = 60
|
||||
const earthHeight = 120
|
||||
const barHeight = 10
|
||||
const padding = 20
|
||||
const numberHeightCalc = showNumbers ? 30 : 0
|
||||
const labelHeight = columnLabels.length > 0 ? 30 : 0
|
||||
|
||||
const dimensions = {
|
||||
width,
|
||||
height,
|
||||
beadSize,
|
||||
rodSpacing,
|
||||
heavenY: padding + labelHeight + heavenHeight / 3,
|
||||
earthY: padding + labelHeight + heavenHeight + barHeight + earthHeight * 0.7,
|
||||
barY: padding + labelHeight + heavenHeight,
|
||||
padding,
|
||||
totalColumns: effectiveColumns,
|
||||
}
|
||||
|
||||
// Compact mode hides frame
|
||||
const effectiveFrameVisible = compact ? false : frameVisible
|
||||
|
||||
// Use shared renderer with static bead component
|
||||
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' : ''}`}
|
||||
style={{ overflow: 'visible', display: 'block' }}
|
||||
>
|
||||
<defs>
|
||||
<style>{`
|
||||
.abacus-bead {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.hide-inactive-mode .abacus-bead.hidden-inactive {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
</defs>
|
||||
|
||||
{/* Column highlights */}
|
||||
{highlightColumns.map((colIndex) => {
|
||||
if (colIndex < 0 || colIndex >= effectiveColumns) return null
|
||||
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
|
||||
const highlightWidth = rodSpacing * 0.9
|
||||
const highlightHeight = height - padding * 2 - numberHeightCalc - labelHeight
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={`column-highlight-${colIndex}`}
|
||||
x={x - highlightWidth / 2}
|
||||
y={padding + 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 >= effectiveColumns) return null
|
||||
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
|
||||
|
||||
return (
|
||||
<text
|
||||
key={`column-label-${colIndex}`}
|
||||
x={x}
|
||||
y={padding + 15}
|
||||
textAnchor="middle"
|
||||
fontSize="14"
|
||||
fontWeight="600"
|
||||
fill="rgba(0, 0, 0, 0.7)"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Rods (column posts) */}
|
||||
{effectiveFrameVisible && beadConfigs.map((_, colIndex) => {
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={`rod-${colIndex}`}
|
||||
x={x - 3}
|
||||
y={padding + labelHeight}
|
||||
width={6}
|
||||
height={heavenHeight + earthHeight + barHeight}
|
||||
fill={customStyles?.columnPosts?.fill || 'rgb(0, 0, 0, 0.1)'}
|
||||
stroke={customStyles?.columnPosts?.stroke || 'rgba(0, 0, 0, 0.2)'}
|
||||
strokeWidth={customStyles?.columnPosts?.strokeWidth || 1}
|
||||
opacity={customStyles?.columnPosts?.opacity ?? 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Reckoning bar */}
|
||||
{effectiveFrameVisible && (
|
||||
<rect
|
||||
x={padding}
|
||||
y={dimensions.barY}
|
||||
width={effectiveColumns * rodSpacing}
|
||||
height={barHeight}
|
||||
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 */}
|
||||
{beadConfigs.map((columnBeads, colIndex) => {
|
||||
const placeValue = effectiveColumns - 1 - colIndex
|
||||
|
||||
return (
|
||||
<g key={`column-${colIndex}`}>
|
||||
{columnBeads.map((bead, beadIndex) => {
|
||||
const position = calculateBeadPosition(bead, dimensions)
|
||||
|
||||
// Adjust X for padding
|
||||
position.x += padding
|
||||
|
||||
const color = getBeadColor(bead, effectiveColumns, colorScheme, colorPalette)
|
||||
|
||||
return (
|
||||
<AbacusStaticBead
|
||||
key={`bead-${colIndex}-${beadIndex}`}
|
||||
bead={bead}
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
size={beadSize}
|
||||
shape={beadShape}
|
||||
color={color}
|
||||
hideInactiveBeads={hideInactiveBeads}
|
||||
customStyle={
|
||||
bead.type === 'heaven'
|
||||
? customStyles?.heavenBeads
|
||||
: customStyles?.earthBeads
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Column numbers */}
|
||||
{showNumbers && beadConfigs.map((_, colIndex) => {
|
||||
const placeValue = effectiveColumns - 1 - colIndex
|
||||
const columnState = state[placeValue] || { heavenActive: false, earthActive: 0 }
|
||||
const digit = (columnState.heavenActive ? 5 : 0) + columnState.earthActive
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
|
||||
|
||||
return (
|
||||
<text
|
||||
key={`number-${colIndex}`}
|
||||
x={x}
|
||||
y={height - padding + 5}
|
||||
textAnchor="middle"
|
||||
fontSize={customStyles?.numerals?.fontSize || 16}
|
||||
fontWeight={customStyles?.numerals?.fontWeight || '600'}
|
||||
fill={customStyles?.numerals?.color || 'rgba(0, 0, 0, 0.8)'}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{digit}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
<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}
|
||||
cropToActiveBeads={cropToActiveBeads}
|
||||
BeadComponent={AbacusStaticBead}
|
||||
getBeadColor={getBeadColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ export function AbacusStaticBead({
|
||||
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' }}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,38 @@
|
||||
|
||||
import type { ValidPlaceValues, BeadHighlight } from './AbacusReact'
|
||||
|
||||
/**
|
||||
* Calculate the actual rendered dimensions of a bead based on its shape
|
||||
* These values match the exact rendering in AbacusStaticBead and AbacusAnimatedBead
|
||||
*
|
||||
* @param size - The base bead size parameter
|
||||
* @param shape - The bead shape ('circle', 'diamond', or 'square')
|
||||
* @returns Object with width and height of the rendered bead
|
||||
*/
|
||||
export function calculateBeadDimensions(
|
||||
size: number,
|
||||
shape: 'circle' | 'diamond' | 'square' = 'diamond'
|
||||
): { width: number; height: number } {
|
||||
switch (shape) {
|
||||
case 'diamond':
|
||||
// Diamond polygon: points=`${size*0.7},0 ${size*1.4},${size/2} ${size*0.7},${size} 0,${size/2}`
|
||||
// Spans from x=0 to x=size*1.4, y=0 to y=size
|
||||
return { width: size * 1.4, height: size }
|
||||
|
||||
case 'circle':
|
||||
// Circle with radius=size/2, so diameter=size
|
||||
return { width: size, height: size }
|
||||
|
||||
case 'square':
|
||||
// Square with width=size, height=size
|
||||
return { width: size, height: size }
|
||||
|
||||
default:
|
||||
// Default to diamond (most common/largest)
|
||||
return { width: size * 1.4, height: size }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the state of beads in a single column
|
||||
*/
|
||||
@@ -358,13 +390,114 @@ function getPlaceName(place: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the natural dimensions of an abacus SVG
|
||||
* This uses the same logic as AbacusStatic to ensure consistency
|
||||
* 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 Object with width and height in pixels (at scale=1)
|
||||
* @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,
|
||||
@@ -375,18 +508,266 @@ export function calculateAbacusDimensions({
|
||||
showNumbers?: boolean
|
||||
columnLabels?: string[]
|
||||
}): { width: number; height: number } {
|
||||
// Constants matching AbacusStatic
|
||||
const beadSize = 20
|
||||
const rodSpacing = 40
|
||||
const heavenHeight = 60
|
||||
const earthHeight = 120
|
||||
const barHeight = 10
|
||||
const padding = 20
|
||||
const numberHeightCalc = showNumbers ? 30 : 0
|
||||
const labelHeight = columnLabels.length > 0 ? 30 : 0
|
||||
|
||||
const width = columns * rodSpacing + padding * 2
|
||||
const height = heavenHeight + earthHeight + barHeight + padding * 2 + numberHeightCalc + labelHeight
|
||||
|
||||
return { width, height }
|
||||
// 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, labelHeight } = 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
|
||||
// NOTE: All Y positions are offset by labelHeight to match absolute SVG coordinates
|
||||
if (bead.type === 'heaven') {
|
||||
if (bead.active) {
|
||||
// Active heaven bead: positioned close to reckoning bar (Typst line 175)
|
||||
const y = labelHeight + heavenEarthGap - beadSize / 2 - activeGap
|
||||
return { x, y }
|
||||
} else {
|
||||
// Inactive heaven bead: positioned away from reckoning bar (Typst line 178)
|
||||
const y = labelHeight + 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 = labelHeight + 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 = labelHeight + 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 = labelHeight + heavenEarthGap + barThickness + inactiveGap + beadSize / 2 +
|
||||
bead.position * (beadSize + adjacentSpacing)
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Padding configuration for cropping
|
||||
*/
|
||||
export interface CropPadding {
|
||||
top?: number
|
||||
bottom?: number
|
||||
left?: number
|
||||
right?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding box for crop area
|
||||
*/
|
||||
export interface BoundingBox {
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete crop calculation result
|
||||
*/
|
||||
export interface CropResult extends BoundingBox {
|
||||
viewBox: string // SVG viewBox attribute value
|
||||
scaledWidth: number // Width after scaling to fit target
|
||||
scaledHeight: number // Height after scaling to fit target
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bounding box around active beads for a given value
|
||||
* Uses the same position calculations as the rendering engine
|
||||
*
|
||||
* @param value - The number to display
|
||||
* @param columns - Number of columns
|
||||
* @param scaleFactor - Scale factor for the abacus
|
||||
* @returns Bounding box containing all active beads
|
||||
*/
|
||||
export function calculateActiveBeadsBounds(
|
||||
value: number,
|
||||
columns: number,
|
||||
scaleFactor: number = 1
|
||||
): BoundingBox {
|
||||
// Get which beads are active for this value
|
||||
const abacusState = numberToAbacusState(value, columns)
|
||||
|
||||
// Get layout dimensions
|
||||
const dimensions = calculateStandardDimensions({
|
||||
columns,
|
||||
scaleFactor,
|
||||
showNumbers: false,
|
||||
columnLabels: [],
|
||||
})
|
||||
|
||||
// Calculate positions of all active beads
|
||||
const activeBeadPositions: Array<{ x: number; y: number }> = []
|
||||
|
||||
for (let placeValue = 0; placeValue < columns; placeValue++) {
|
||||
const columnState = abacusState[placeValue]
|
||||
if (!columnState) continue
|
||||
|
||||
// Heaven bead
|
||||
if (columnState.heavenActive) {
|
||||
const bead: BeadPositionConfig = {
|
||||
type: 'heaven',
|
||||
active: true,
|
||||
position: 0,
|
||||
placeValue,
|
||||
}
|
||||
const pos = calculateBeadPosition(bead, dimensions, { earthActive: columnState.earthActive })
|
||||
activeBeadPositions.push(pos)
|
||||
}
|
||||
|
||||
// Earth beads
|
||||
for (let earthPos = 0; earthPos < columnState.earthActive; earthPos++) {
|
||||
const bead: BeadPositionConfig = {
|
||||
type: 'earth',
|
||||
active: true,
|
||||
position: earthPos,
|
||||
placeValue,
|
||||
}
|
||||
const pos = calculateBeadPosition(bead, dimensions, { earthActive: columnState.earthActive })
|
||||
activeBeadPositions.push(pos)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeBeadPositions.length === 0) {
|
||||
// Fallback if no active beads - show full abacus
|
||||
return {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: dimensions.width,
|
||||
maxY: dimensions.height,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate bounding box from active bead positions
|
||||
// Use diamond dimensions (largest bead shape) for consistent cropping across all shapes
|
||||
const { width: beadWidth, height: beadHeight } = calculateBeadDimensions(dimensions.beadSize, 'diamond')
|
||||
|
||||
let minX = Infinity
|
||||
let maxX = -Infinity
|
||||
let minY = Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (const pos of activeBeadPositions) {
|
||||
// Bead center is at pos.x, pos.y
|
||||
// Calculate bounding box for diamond shape
|
||||
minX = Math.min(minX, pos.x - beadWidth / 2)
|
||||
maxX = Math.max(maxX, pos.x + beadWidth / 2)
|
||||
minY = Math.min(minY, pos.y - beadHeight / 2)
|
||||
maxY = Math.max(maxY, pos.y + beadHeight / 2)
|
||||
}
|
||||
|
||||
// HORIZONTAL BOUNDS: Always show full width of all columns (consistent across all values)
|
||||
// Use rod positions for consistent horizontal bounds
|
||||
const rodSpacing = dimensions.rodSpacing
|
||||
minX = rodSpacing / 2 - beadWidth / 2
|
||||
maxX = (columns - 0.5) * rodSpacing + beadWidth / 2
|
||||
|
||||
return {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate crop parameters with padding for SVG viewBox
|
||||
*
|
||||
* @param value - The number to display
|
||||
* @param columns - Number of columns
|
||||
* @param scaleFactor - Scale factor for the abacus
|
||||
* @param padding - Padding to add around the crop area
|
||||
* @returns Complete crop result with viewBox and dimensions
|
||||
*/
|
||||
export function calculateAbacusCrop(
|
||||
value: number,
|
||||
columns: number,
|
||||
scaleFactor: number = 1,
|
||||
padding: CropPadding = {}
|
||||
): CropResult {
|
||||
const bbox = calculateActiveBeadsBounds(value, columns, scaleFactor)
|
||||
|
||||
// Apply padding
|
||||
const paddingTop = padding.top ?? 0
|
||||
const paddingBottom = padding.bottom ?? 0
|
||||
const paddingLeft = padding.left ?? 0
|
||||
const paddingRight = padding.right ?? 0
|
||||
|
||||
const cropX = bbox.minX - paddingLeft
|
||||
const cropY = bbox.minY - paddingTop
|
||||
const cropWidth = bbox.width + paddingLeft + paddingRight
|
||||
const cropHeight = bbox.height + paddingTop + paddingBottom
|
||||
|
||||
// Create viewBox string
|
||||
const viewBox = `${cropX} ${cropY} ${cropWidth} ${cropHeight}`
|
||||
|
||||
return {
|
||||
minX: cropX,
|
||||
minY: cropY,
|
||||
maxX: cropX + cropWidth,
|
||||
maxY: cropY + cropHeight,
|
||||
width: cropWidth,
|
||||
height: cropHeight,
|
||||
viewBox,
|
||||
scaledWidth: cropWidth,
|
||||
scaledHeight: cropHeight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export {
|
||||
validateAbacusValue,
|
||||
areStatesEqual,
|
||||
calculateAbacusDimensions,
|
||||
calculateStandardDimensions, // NEW: Shared layout calculator
|
||||
calculateBeadPosition, // NEW: Bead position calculator
|
||||
calculateBeadDimensions, // NEW: Calculate exact bead dimensions by shape
|
||||
calculateActiveBeadsBounds, // NEW: Calculate bounding box for active beads
|
||||
calculateAbacusCrop, // NEW: Calculate crop parameters with padding
|
||||
} from "./AbacusUtils";
|
||||
export type {
|
||||
BeadState,
|
||||
@@ -55,6 +60,11 @@ export type {
|
||||
BeadDiffResult,
|
||||
BeadDiffOutput,
|
||||
PlaceValueBasedBead,
|
||||
AbacusLayoutDimensions, // NEW: Complete layout dimensions type
|
||||
BeadPositionConfig, // NEW: Bead config for position calculation
|
||||
CropPadding, // NEW: Padding config for cropping
|
||||
BoundingBox, // NEW: Bounding box type
|
||||
CropResult, // NEW: Complete crop calculation result
|
||||
} from "./AbacusUtils";
|
||||
|
||||
export { useAbacusDiff, useAbacusState } from "./AbacusHooks";
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -188,6 +188,9 @@ importers:
|
||||
next-intl:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0(next@14.2.33(@babel/core@7.28.4)(@playwright/test@1.56.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
|
||||
openscad-wasm-prebuilt:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
python-bridge:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
@@ -7484,6 +7487,9 @@ packages:
|
||||
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
openscad-wasm-prebuilt@1.2.0:
|
||||
resolution: {integrity: sha512-JfXJlopHVHvsYiVYUKYDXkg8yOkABm8zclo8zPrSRBeeICR+fitesO6NRwBdAzSdbtOYh6Scmuz/XLyhllbzYA==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -17737,6 +17743,8 @@ snapshots:
|
||||
is-docker: 2.2.1
|
||||
is-wsl: 2.2.0
|
||||
|
||||
openscad-wasm-prebuilt@1.2.0: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
||||
Reference in New Issue
Block a user