diff --git a/apps/web/scripts/generateDayIcon.tsx b/apps/web/scripts/generateDayIcon.tsx index e4503c1f..212e3e32 100644 --- a/apps/web/scripts/generateDayIcon.tsx +++ b/apps/web/scripts/generateDayIcon.tsx @@ -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: - const postRegex = / - const activeBeadRegex = - / = [] + + 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 diff --git a/apps/web/src/app/icon/route.tsx b/apps/web/src/app/icon/route.tsx index 6d1be9ba..2a99704d 100644 --- a/apps/web/src/app/icon/route.tsx +++ b/apps/web/src/app/icon/route.tsx @@ -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', }, }) }