From 793ffd3c1f6b16ffdd058282b9f17a7df46a32bc Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 15 Sep 2025 18:37:47 -0500 Subject: [PATCH] fix: implement proper SVG cropping and fix abacus positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace manual scaling with proper SVG processing used elsewhere in app - Copy SVG cropping functions from ServerSorobanSVG component - Calculate actual content bounds and apply proper viewBox cropping - Fix digit display positioning (back to -45px/-55px for better spacing) - Remove excessive manual scaling that was causing display issues - Now properly crops 610x1002px SVG to show just the 245x322px abacus content 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- apps/web/src/components/InteractiveAbacus.tsx | 271 +++++++++++++++--- 1 file changed, 231 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/InteractiveAbacus.tsx b/apps/web/src/components/InteractiveAbacus.tsx index ba172dfd..62d97982 100644 --- a/apps/web/src/components/InteractiveAbacus.tsx +++ b/apps/web/src/components/InteractiveAbacus.tsx @@ -5,6 +5,8 @@ import { useSpring, animated } from '@react-spring/web' import NumberFlow from '@number-flow/react' import { css } from '../../styled-system/css' import { TypstSoroban } from './TypstSoroban' +import { generateSorobanSVG, type SorobanConfig } from '@/lib/typst-soroban' +import { useAbacusConfig } from '@/contexts/AbacusDisplayContext' interface InteractiveAbacusProps { initialValue?: number @@ -35,6 +37,8 @@ export function InteractiveAbacus({ const [disableAnimation, setDisableAnimation] = useState(false) const svgRef = useRef(null) const numberRef = useRef(null) + const globalConfig = useAbacusConfig() + const [croppedSVG, setCroppedSVG] = useState(null) // Remove the old spring animation since we're using NumberFlow now @@ -283,6 +287,204 @@ export function InteractiveAbacus({ } }, [isEditing]) + // Generate and crop SVG for better display + useEffect(() => { + const generateCroppedSVG = async () => { + try { + const config: SorobanConfig = { + number: currentValue, + width: compact ? "240px" : "300px", + height: compact ? "320px" : "400px", + beadShape: globalConfig.beadShape, + colorScheme: globalConfig.colorScheme, + hideInactiveBeads: globalConfig.hideInactiveBeads, + coloredNumerals: globalConfig.coloredNumerals, + scaleFactor: globalConfig.scaleFactor + } + + const svg = await generateSorobanSVG(config) + const processed = processSVGForDisplay(svg, compact ? 240 : 300, compact ? 320 : 400) + setCroppedSVG(processed) + } catch (error) { + console.error('Failed to generate cropped SVG:', error) + setCroppedSVG(null) + } + } + + generateCroppedSVG() + }, [currentValue, compact, globalConfig]) + + // SVG processing functions (copied from ServerSorobanSVG) + function processSVGForDisplay(svgContent: string, targetWidth: number, targetHeight: number): string { + try { + // Parse the SVG as DOM to calculate actual content bounds + const parser = new DOMParser() + const svgDoc = parser.parseFromString(svgContent, 'image/svg+xml') + const svgElement = svgDoc.documentElement + + if (svgElement.tagName !== 'svg') { + throw new Error('Invalid SVG content') + } + + // Get the bounding box of all actual content + let bounds = calculateSVGContentBounds(svgElement as unknown as SVGSVGElement) + + if (!bounds) { + // Fallback to original if we can't calculate bounds + console.warn('Could not calculate SVG bounds, using original') + return svgContent + } + + // Add reasonable padding around the content + const padding = 15 + const newViewBox = `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}` + + // Update the SVG with new dimensions and viewBox + let processedSVG = svgContent + .replace(/width="[^"]*"/, `width="${targetWidth}"`) + .replace(/height="[^"]*"/, `height="${targetHeight}"`) + .replace(/viewBox="[^"]*"/, `viewBox="${newViewBox}"`) + + // Ensure responsive scaling + if (!processedSVG.includes('preserveAspectRatio')) { + processedSVG = processedSVG.replace(' { + const d = path.getAttribute('d') || '' + const coords = extractPathCoordinates(d) + + // Apply all transforms from this path element up to the root + const transformedCoords = coords.map(coord => applyElementTransforms(coord, path, svgElement)) + allPoints.push(...transformedCoords) + }) + + if (allPoints.length === 0) { + // Fallback if no paths found + return { x: -50, y: -120, width: 200, height: 350 } + } + + // STEP 2: Calculate the bounding box from all transformed points + const minX = Math.min(...allPoints.map(p => p.x)) + const maxX = Math.max(...allPoints.map(p => p.x)) + const minY = Math.min(...allPoints.map(p => p.y)) + const maxY = Math.max(...allPoints.map(p => p.y)) + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + } + } catch (error) { + console.warn('Error in calculateTypstSorobanBounds:', error) + // Safe fallback + return { x: -50, y: -120, width: 200, height: 350 } + } + } + + function extractPathCoordinates(pathData: string): { x: number; y: number }[] { + const coords: { x: number; y: number }[] = [] + + // Simple regex to extract M, L coordinates (handles most soroban paths) + const moveToRegex = /M\s*([\d.-]+)\s+([\d.-]+)/g + const lineToRegex = /L\s*([\d.-]+)\s+([\d.-]+)/g + + let match + while ((match = moveToRegex.exec(pathData)) !== null) { + coords.push({ x: parseFloat(match[1]), y: parseFloat(match[2]) }) + } + + while ((match = lineToRegex.exec(pathData)) !== null) { + coords.push({ x: parseFloat(match[1]), y: parseFloat(match[2]) }) + } + + return coords + } + + function applyElementTransforms(point: { x: number; y: number }, element: Element, rootSVG: SVGSVGElement): { x: number; y: number } { + let current: Element | null = element + let transformedPoint = { ...point } + + // Apply transforms from element up to root + while (current && current !== rootSVG) { + const transform = current.getAttribute('transform') + if (transform) { + transformedPoint = applyTransformToPoint(transformedPoint, transform) + } + current = current.parentElement + } + + return transformedPoint + } + + function applyTransformToPoint(point: { x: number; y: number }, transformString: string): { x: number; y: number } { + let result = { ...point } + + // Handle matrix transform + const matrixMatch = transformString.match(/matrix\(([\d.-]+)[\s,]+([\d.-]+)[\s,]+([\d.-]+)[\s,]+([\d.-]+)[\s,]+([\d.-]+)[\s,]+([\d.-]+)\)/) + if (matrixMatch) { + const [, a, b, c, d, e, f] = matrixMatch.map(Number) + result = { + x: a * point.x + c * point.y + e, + y: b * point.x + d * point.y + f + } + } + + // Handle translate transform + const translateMatch = transformString.match(/translate\(([\d.-]+)[\s,]+([\d.-]+)\)/) + if (translateMatch) { + const [, tx, ty] = translateMatch.map(Number) + result = { + x: result.x + tx, + y: result.y + ty + } + } + + return result + } + return (
- {/* Aspect ratio container for proper SVG cropping */} -
-
- -
-
+ {/* Properly cropped SVG display */} +
{/* Column-based Value Display */} @@ -365,7 +556,7 @@ export function InteractiveAbacus({