feat(abacus-react): add shared dimension calculator for consistent sizing

- Add calculateAbacusDimensions utility to AbacusUtils
- Refactor AbacusStatic to use shared dimension calculator
- Update calendar composite generator to use shared utility
- Export calculateAbacusDimensions from index and static entry points
- Ensures consistent sizing between preview and PDF generation

🤖 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-04 10:42:45 -06:00
parent 293390ae35
commit e5ba772fde
5 changed files with 72 additions and 23 deletions

View File

@@ -9,7 +9,7 @@
*/ */
import React from 'react' import React from 'react'
import { AbacusStatic } from '@soroban/abacus-react/static' import { AbacusStatic, calculateAbacusDimensions } from '@soroban/abacus-react/static'
interface CalendarCompositeOptions { interface CalendarCompositeOptions {
month: number month: number
@@ -45,9 +45,30 @@ const MARGIN = 50
const CONTENT_WIDTH = WIDTH - MARGIN * 2 const CONTENT_WIDTH = WIDTH - MARGIN * 2
const CONTENT_HEIGHT = HEIGHT - MARGIN * 2 const CONTENT_HEIGHT = HEIGHT - MARGIN * 2
// Header // Abacus natural size is 120x230 at scale=1
const HEADER_HEIGHT = 60 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 TITLE_Y = MARGIN + 35
const yearAbacusX = (WIDTH - yearAbacusDisplayWidth) / 2
const yearAbacusY = TITLE_Y + 10
// Calendar grid // Calendar grid
const GRID_START_Y = MARGIN + HEADER_HEIGHT const GRID_START_Y = MARGIN + HEADER_HEIGHT
@@ -59,10 +80,7 @@ const DAY_GRID_HEIGHT = GRID_HEIGHT - WEEKDAY_ROW_HEIGHT
const CELL_WIDTH = CONTENT_WIDTH / 7 const CELL_WIDTH = CONTENT_WIDTH / 7
const DAY_CELL_HEIGHT = DAY_GRID_HEIGHT / 6 const DAY_CELL_HEIGHT = DAY_GRID_HEIGHT / 6
// Abacus natural size is 120x230 at scale=1 // Day abacus sizing - fit in cell with padding
// We need to fit in cell with padding
const ABACUS_NATURAL_WIDTH = 120
const ABACUS_NATURAL_HEIGHT = 230
const CELL_PADDING = 5 const CELL_PADDING = 5
// Calculate max scale to fit in cell // Calculate max scale to fit in cell
@@ -82,9 +100,6 @@ for (let day = 1; day <= daysInMonth; day++) {
calendarCells.push(day) calendarCells.push(day)
} }
// Calculate how many columns needed for year
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
// Render individual abacus SVGs as complete SVG elements // Render individual abacus SVGs as complete SVG elements
function renderAbacusSVG(value: number, columns: number, scale: number): string { function renderAbacusSVG(value: number, columns: number, scale: number): string {
return renderToString( return renderToString(
@@ -105,7 +120,7 @@ const compositeSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" h
<rect width="${WIDTH}" height="${HEIGHT}" fill="white"/> <rect width="${WIDTH}" height="${HEIGHT}" fill="white"/>
<!-- Title: Month Name --> <!-- Title: Month Name -->
<text x="${WIDTH / 2}" y="${TITLE_Y - 20}" text-anchor="middle" font-family="Arial" font-size="32" font-weight="bold" fill="#1a1a1a"> <text x="${WIDTH / 2}" y="${TITLE_Y}" text-anchor="middle" font-family="Arial" font-size="32" font-weight="bold" fill="#1a1a1a">
${monthName} ${monthName}
</text> </text>
@@ -113,13 +128,8 @@ const compositeSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" h
${(() => { ${(() => {
const yearAbacusSVG = renderAbacusSVG(year, yearColumns, 1) const yearAbacusSVG = renderAbacusSVG(year, yearColumns, 1)
const yearAbacusContent = yearAbacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '') const yearAbacusContent = yearAbacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
// Scale year abacus to be smaller (about 15% of width)
const yearAbacusDisplayWidth = WIDTH * 0.15
const yearAbacusDisplayHeight = (ABACUS_NATURAL_HEIGHT / ABACUS_NATURAL_WIDTH) * yearAbacusDisplayWidth
const yearAbacusX = (WIDTH - yearAbacusDisplayWidth) / 2
const yearAbacusY = TITLE_Y - 10
return `<svg x="${yearAbacusX}" y="${yearAbacusY}" width="${yearAbacusDisplayWidth}" height="${yearAbacusDisplayHeight}" return `<svg x="${yearAbacusX}" y="${yearAbacusY}" width="${yearAbacusDisplayWidth}" height="${yearAbacusDisplayHeight}"
viewBox="0 0 ${ABACUS_NATURAL_WIDTH} ${ABACUS_NATURAL_HEIGHT}"> viewBox="0 0 ${yearAbacusActualWidth} ${yearAbacusActualHeight}">
${yearAbacusContent} ${yearAbacusContent}
</svg>` </svg>`
})()} })()}

View File

@@ -6,7 +6,7 @@
* Different: No hooks, no animations, no interactions, simplified rendering * Different: No hooks, no animations, no interactions, simplified rendering
*/ */
import { numberToAbacusState } from './AbacusUtils' import { numberToAbacusState, calculateAbacusDimensions } from './AbacusUtils'
import { AbacusStaticBead } from './AbacusStaticBead' import { AbacusStaticBead } from './AbacusStaticBead'
import type { import type {
AbacusCustomStyles, AbacusCustomStyles,
@@ -175,7 +175,14 @@ export function AbacusStatic({
beadConfigs.push(beads) beadConfigs.push(beads)
} }
// Calculate dimensions (matching AbacusReact) // Calculate dimensions using shared utility
const { width, height } = calculateAbacusDimensions({
columns: effectiveColumns,
showNumbers: !!showNumbers,
columnLabels,
})
// Layout constants (must match calculateAbacusDimensions)
const beadSize = 20 const beadSize = 20
const rodSpacing = 40 const rodSpacing = 40
const heavenHeight = 60 const heavenHeight = 60
@@ -185,9 +192,6 @@ export function AbacusStatic({
const numberHeightCalc = showNumbers ? 30 : 0 const numberHeightCalc = showNumbers ? 30 : 0
const labelHeight = columnLabels.length > 0 ? 30 : 0 const labelHeight = columnLabels.length > 0 ? 30 : 0
const width = effectiveColumns * rodSpacing + padding * 2
const height = heavenHeight + earthHeight + barHeight + padding * 2 + numberHeightCalc + labelHeight
const dimensions = { const dimensions = {
width, width,
height, height,

View File

@@ -356,3 +356,37 @@ function getPlaceName(place: number): string {
return `place ${place} column` return `place ${place} column`
} }
} }
/**
* Calculate the natural dimensions of an abacus SVG
* This uses the same logic as AbacusStatic to ensure consistency
*
* @param columns - Number of columns in the abacus
* @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)
*/
export function calculateAbacusDimensions({
columns,
showNumbers = true,
columnLabels = [],
}: {
columns: number
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 }
}

View File

@@ -47,6 +47,7 @@ export {
calculateBeadDiffFromValues, calculateBeadDiffFromValues,
validateAbacusValue, validateAbacusValue,
areStatesEqual, areStatesEqual,
calculateAbacusDimensions,
} from "./AbacusUtils"; } from "./AbacusUtils";
export type { export type {
BeadState, BeadState,

View File

@@ -9,7 +9,7 @@ export { AbacusStaticBead } from './AbacusStaticBead'
export type { StaticBeadProps } from './AbacusStaticBead' export type { StaticBeadProps } from './AbacusStaticBead'
// Re-export shared utilities that are safe for server components // Re-export shared utilities that are safe for server components
export { numberToAbacusState } from './AbacusUtils' export { numberToAbacusState, calculateAbacusDimensions } from './AbacusUtils'
export type { export type {
AbacusCustomStyles, AbacusCustomStyles,
BeadConfig, BeadConfig,