feat: dynamically crop favicon to active beads for maximum size

Implement bounding box calculation to crop the favicon SVG to show only
the active beads, maximizing their size within the 100x100 icon canvas.

Algorithm:
1. Parse rendered SVG to find all active bead positions (via regex)
2. Calculate bounding box with 15px padding
3. Compute optimal scale to fit within 96x96 (leaving border room)
4. Apply transform: translate + scale + translate to crop and center

Results:
- Day 1-9 (few beads): scale ~1.14 (2.4x larger than before)
- Day 31 (many beads): scale ~0.74 (1.5x larger than before)
- Original fixed scale: 0.48

This uses no external dependencies - just regex parsing of the rendered
SVG to extract active bead coordinates.

🤖 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-03 08:31:01 -06:00
parent e1369fa275
commit 567032296a
1 changed files with 64 additions and 4 deletions

View File

@ -19,6 +19,48 @@ function extractSvgContent(markup: string): string {
return svgMatch[1]
}
// Calculate bounding box of active beads from rendered SVG
interface BoundingBox {
minX: number
minY: number
maxX: number
maxY: number
}
function getActiveBeadsBoundingBox(svgContent: string, scaleFactor: number): BoundingBox {
// Parse all 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 matches = [...svgContent.matchAll(activeBeadRegex)]
if (matches.length === 0) {
// Fallback if no active beads found
return { minX: 0, minY: 0, maxX: 50 * scaleFactor, maxY: 120 * scaleFactor }
}
// Bead dimensions (diamond): width ≈ 30px * scaleFactor, height ≈ 21px * scaleFactor
const beadWidth = 30.24 * scaleFactor
const beadHeight = 21.6 * scaleFactor
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const match of matches) {
const x = parseFloat(match[1])
const y = parseFloat(match[2])
// Account for bead dimensions
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x + beadWidth)
maxY = Math.max(maxY, y + beadHeight)
}
return { minX, minY, maxX, maxY }
}
// Get day from command line argument
const day = parseInt(process.argv[2], 10)
@ -70,15 +112,33 @@ let svgContent = extractSvgContent(abacusMarkup)
// Remove !important from CSS (production code policy)
svgContent = svgContent.replace(/\s*!important/g, '')
// Calculate bounding box of active beads
const bbox = getActiveBeadsBoundingBox(svgContent, 1.8)
// Add padding around active beads (in abacus coordinates)
const padding = 15
const cropX = bbox.minX - padding
const cropY = bbox.minY - padding
const cropWidth = bbox.maxX - bbox.minX + padding * 2
const cropHeight = bbox.maxY - bbox.minY + padding * 2
// Calculate scale to fit cropped region into 96x96 (leaving room for border)
const targetSize = 96
const scale = Math.min(targetSize / cropWidth, targetSize / cropHeight)
// Center in 100x100 canvas
const scaledWidth = cropWidth * scale
const scaledHeight = cropHeight * scale
const offsetX = (100 - scaledWidth) / 2
const offsetY = (100 - scaledHeight) / 2
// Wrap in SVG with proper viewBox for favicon sizing
// AbacusReact with 2 columns + scaleFactor 1.8 = ~90×216px
// Scale 0.48 = ~43×104px (slightly overflows height, but fine for icon)
const svg = `<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle with border for definition -->
<circle cx="50" cy="50" r="48" fill="#fef3c7" stroke="#d97706" stroke-width="2"/>
<!-- Abacus showing day ${day.toString().padStart(2, '0')} (US Central Time) -->
<g class="hide-inactive-mode" transform="translate(28, -2) scale(0.48)">
<!-- Abacus showing day ${day.toString().padStart(2, '0')} (US Central Time) - cropped to active beads -->
<g class="hide-inactive-mode" transform="translate(${offsetX}, ${offsetY}) scale(${scale}) translate(${-cropX}, ${-cropY})">
${svgContent}
</g>
</svg>