fix: replace regex HTML parsing with deterministic bead position calculations in icon generation

- Replace fragile regex parsing in generateDayIcon.tsx with proper bead
  position calculations using numberToAbacusState(), calculateStandardDimensions(),
  and calculateBeadPosition() from @soroban/abacus-react
- Add query parameter support to /icon route for testing different days (e.g. /icon?day=15)
- Fix icon cropping to properly show only active beads with dynamic viewBox
- Validate day parameter (1-31) and return 400 for invalid values
- Different cache duration for production (1 hour) vs testing (1 minute)

Results:
- Day 1: 48.88px height (minimal)
- Day 5: 48.88px height (single heaven bead)
- Day 25: 100.18px height (many active beads)
- Day 31: 93.88px height

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-05 09:09:56 -06:00
parent 7ea8399745
commit 41a3707841
2 changed files with 102 additions and 38 deletions

View File

@@ -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

View File

@@ -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',
},
})
}