fix: resolve abacus sizing and prefix matching issues in memory quiz
- Increase abacus SVG dimensions from 200pt×250pt to 600pt×500pt for better visibility during memory flash - Remove SVG width/height attributes during optimization to allow proper CSS scaling - Add transparent background support to TypstSoroban component - Fix prefix matching logic to exclude already found numbers, resolving issue where entering 555 was blocked after finding 55 - Improve memory quiz card grid layout with adaptive sizing and better responsive design 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6394218667
commit
b1db02851c
|
|
@ -187,16 +187,12 @@ const generateQuizCards = (count: number, difficulty: DifficultyLevel): QuizCard
|
|||
|
||||
return numbers.map(number => ({
|
||||
number,
|
||||
svgComponent: <div style={{
|
||||
transform: 'scale(2.0)',
|
||||
transformOrigin: 'center'
|
||||
}}>
|
||||
<TypstSoroban
|
||||
number={number}
|
||||
width="280pt"
|
||||
height="360pt"
|
||||
/>
|
||||
</div>,
|
||||
svgComponent: <TypstSoroban
|
||||
number={number}
|
||||
width="600pt"
|
||||
height="500pt"
|
||||
transparent={true}
|
||||
/>,
|
||||
element: null
|
||||
}))
|
||||
}
|
||||
|
|
@ -496,8 +492,8 @@ function DisplayPhase({ state, dispatch }: { state: SorobanQuizState; dispatch:
|
|||
|
||||
{showCard && currentCard && (
|
||||
<div className={css({
|
||||
width: 'min(80vw, 600px)',
|
||||
height: 'min(40vh, 350px)',
|
||||
width: 'min(90vw, 800px)',
|
||||
height: 'min(70vh, 600px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
|
@ -518,10 +514,35 @@ function DisplayPhase({ state, dispatch }: { state: SorobanQuizState; dispatch:
|
|||
)
|
||||
}
|
||||
|
||||
// Visual card grid component
|
||||
// Visual card grid component with adaptive layout
|
||||
function CardGrid({ state }: { state: SorobanQuizState }) {
|
||||
if (state.quizCards.length === 0) return null
|
||||
|
||||
// Calculate optimal grid layout based on number of cards
|
||||
const cardCount = state.quizCards.length
|
||||
|
||||
// Define static grid classes that Panda can generate
|
||||
const getGridClass = (count: number) => {
|
||||
if (count <= 2) return 'repeat(2, 1fr)'
|
||||
if (count <= 4) return 'repeat(2, 1fr)'
|
||||
if (count <= 6) return 'repeat(3, 1fr)'
|
||||
if (count <= 9) return 'repeat(3, 1fr)'
|
||||
if (count <= 12) return 'repeat(4, 1fr)'
|
||||
return 'repeat(5, 1fr)'
|
||||
}
|
||||
|
||||
const getCardSize = (count: number) => {
|
||||
if (count <= 2) return { minSize: '180px', cardHeight: '160px' }
|
||||
if (count <= 4) return { minSize: '160px', cardHeight: '150px' }
|
||||
if (count <= 6) return { minSize: '140px', cardHeight: '140px' }
|
||||
if (count <= 9) return { minSize: '120px', cardHeight: '130px' }
|
||||
if (count <= 12) return { minSize: '110px', cardHeight: '120px' }
|
||||
return { minSize: '100px', cardHeight: '110px' }
|
||||
}
|
||||
|
||||
const gridClass = getGridClass(cardCount)
|
||||
const cardSize = getCardSize(cardCount)
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
marginTop: '16px',
|
||||
|
|
@ -529,22 +550,38 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
|
|||
background: 'gray.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200'
|
||||
borderColor: 'gray.200',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
textAlign: 'center',
|
||||
color: 'gray.700',
|
||||
marginBottom: '12px',
|
||||
marginBottom: '16px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600'
|
||||
})}>Cards you saw:</h4>
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px',
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto'
|
||||
})}>
|
||||
})}>Cards you saw ({cardCount}):</h4>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gap: '12px',
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
width: 'fit-content',
|
||||
|
||||
// Responsive overrides with static values
|
||||
'@media (max-width: 768px)': {
|
||||
gap: '10px'
|
||||
},
|
||||
'@media (max-width: 480px)': {
|
||||
gap: '8px'
|
||||
}
|
||||
})}
|
||||
style={{
|
||||
gridTemplateColumns: gridClass
|
||||
}}
|
||||
>
|
||||
{state.quizCards.map((card, index) => {
|
||||
const isRevealed = state.foundNumbers.includes(card.number)
|
||||
return (
|
||||
|
|
@ -552,8 +589,22 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
|
|||
key={`card-${index}-${card.number}`}
|
||||
className={css({
|
||||
perspective: '1000px',
|
||||
height: '140px'
|
||||
maxWidth: '200px',
|
||||
|
||||
// Static responsive sizing fallbacks
|
||||
'@media (max-width: 768px)': {
|
||||
height: '130px',
|
||||
minWidth: '100px'
|
||||
},
|
||||
'@media (max-width: 480px)': {
|
||||
height: '120px',
|
||||
minWidth: '90px'
|
||||
}
|
||||
})}
|
||||
style={{
|
||||
height: cardSize.cardHeight,
|
||||
minWidth: cardSize.minSize
|
||||
}}
|
||||
>
|
||||
<div className={css({
|
||||
position: 'relative',
|
||||
|
|
@ -578,6 +629,14 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
|
|||
background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
|
||||
color: 'white',
|
||||
fontSize: '48px',
|
||||
|
||||
// Responsive font sizing
|
||||
'@media (max-width: 768px)': {
|
||||
fontSize: '40px'
|
||||
},
|
||||
'@media (max-width: 480px)': {
|
||||
fontSize: '32px'
|
||||
},
|
||||
fontWeight: 'bold',
|
||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
|
||||
border: '3px solid #5f3dc4'
|
||||
|
|
@ -585,7 +644,7 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
|
|||
<div className={css({ opacity: 0.8 })}>?</div>
|
||||
</div>
|
||||
|
||||
{/* Card front (revealed state) - using ServerSorobanSVG */}
|
||||
{/* Card front (revealed state) */}
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
|
|
@ -605,18 +664,14 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
|
|||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
<div style={{
|
||||
transform: 'scale(2.8)',
|
||||
transformOrigin: 'center'
|
||||
}}>
|
||||
<TypstSoroban
|
||||
number={card.number}
|
||||
width="100pt"
|
||||
height="130pt"
|
||||
/>
|
||||
</div>
|
||||
<TypstSoroban
|
||||
number={card.number}
|
||||
width="120pt"
|
||||
height="160pt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -624,6 +679,28 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary row for large numbers of cards */}
|
||||
{cardCount > 8 && (
|
||||
<div className={css({
|
||||
marginTop: '12px',
|
||||
padding: '8px 12px',
|
||||
background: 'blue.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
textAlign: 'center',
|
||||
fontSize: '14px',
|
||||
color: 'blue.700'
|
||||
})}>
|
||||
<strong>{state.foundNumbers.length}</strong> of <strong>{cardCount}</strong> cards found
|
||||
{state.foundNumbers.length > 0 && (
|
||||
<span className={css({ marginLeft: '8px', fontWeight: 'normal' })}>
|
||||
({Math.round((state.foundNumbers.length / cardCount) * 100)}% complete)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -633,8 +710,12 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
|||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>('neutral')
|
||||
|
||||
const isPrefix = useCallback((input: string, numbers: number[]) => {
|
||||
return numbers.some(n => n.toString().startsWith(input) && n.toString() !== input)
|
||||
const isPrefix = useCallback((input: string, numbers: number[], foundNumbers: number[]) => {
|
||||
return numbers.some(n =>
|
||||
n.toString().startsWith(input) &&
|
||||
n.toString() !== input &&
|
||||
!foundNumbers.includes(n)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleKeyPress = useCallback((e: KeyboardEvent) => {
|
||||
|
|
@ -660,7 +741,7 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
|||
|
||||
// Check if correct and not already found
|
||||
if (state.correctAnswers.includes(number) && !state.foundNumbers.includes(number)) {
|
||||
if (!isPrefix(newInput, state.correctAnswers)) {
|
||||
if (!isPrefix(newInput, state.correctAnswers, state.foundNumbers)) {
|
||||
acceptCorrectNumber(number)
|
||||
} else {
|
||||
const timeout = setTimeout(() => {
|
||||
|
|
@ -895,8 +976,13 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
|||
</div>
|
||||
|
||||
|
||||
{/* Visual card grid showing cards the user was shown - now more compact */}
|
||||
<div className={css({ marginTop: '16px', flex: 1, overflow: 'auto' })}>
|
||||
{/* Visual card grid showing cards the user was shown */}
|
||||
<div className={css({
|
||||
marginTop: '16px',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
minHeight: '0' // Allow flex child to shrink
|
||||
})}>
|
||||
<CardGrid state={state} />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ interface TypstSorobanProps {
|
|||
onSuccess?: () => void
|
||||
enableServerFallback?: boolean
|
||||
lazy?: boolean // New prop for lazy loading
|
||||
transparent?: boolean // New prop for transparent background
|
||||
}
|
||||
|
||||
export function TypstSoroban({
|
||||
|
|
@ -24,7 +25,8 @@ export function TypstSoroban({
|
|||
onError,
|
||||
onSuccess,
|
||||
enableServerFallback = false,
|
||||
lazy = false
|
||||
lazy = false,
|
||||
transparent = false
|
||||
}: TypstSorobanProps) {
|
||||
const [svg, setSvg] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(!lazy) // Don't start loading if lazy
|
||||
|
|
@ -66,6 +68,7 @@ export function TypstSoroban({
|
|||
hideInactiveBeads: stableConfig.hideInactiveBeads,
|
||||
coloredNumerals: stableConfig.coloredNumerals,
|
||||
scaleFactor: stableConfig.scaleFactor,
|
||||
transparent,
|
||||
enableServerFallback
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +108,7 @@ export function TypstSoroban({
|
|||
hideInactiveBeads: stableConfig.hideInactiveBeads,
|
||||
coloredNumerals: stableConfig.coloredNumerals,
|
||||
scaleFactor: stableConfig.scaleFactor,
|
||||
transparent,
|
||||
enableServerFallback
|
||||
})
|
||||
|
||||
|
|
@ -121,7 +125,7 @@ export function TypstSoroban({
|
|||
hideInactiveBeads: stableConfig.hideInactiveBeads,
|
||||
coloredNumerals: stableConfig.coloredNumerals,
|
||||
scaleFactor: stableConfig.scaleFactor,
|
||||
transparent: false,
|
||||
transparent,
|
||||
enableServerFallback
|
||||
}
|
||||
|
||||
|
|
@ -162,7 +166,7 @@ export function TypstSoroban({
|
|||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [shouldLoad, number, width, height, stableConfig, enableServerFallback])
|
||||
}, [shouldLoad, number, width, height, stableConfig, enableServerFallback, transparent])
|
||||
|
||||
// Handler to trigger loading on user interaction
|
||||
const handleLoadTrigger = useCallback(() => {
|
||||
|
|
@ -289,10 +293,10 @@ export function TypstSoroban({
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto'
|
||||
maxHeight: '100%'
|
||||
}
|
||||
})}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,65 @@ if (typeof window !== 'undefined') {
|
|||
}, 100) // Small delay to avoid blocking initial render
|
||||
}
|
||||
|
||||
// SVG viewBox optimization - crops SVG to actual content bounds
|
||||
function optimizeSvgViewBox(svgString: string): string {
|
||||
try {
|
||||
// Parse SVG to analyze content bounds
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(svgString, 'image/svg+xml')
|
||||
const svgElement = doc.querySelector('svg')
|
||||
|
||||
if (!svgElement) return svgString
|
||||
|
||||
// Create a temporary element to measure bounds
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.style.position = 'absolute'
|
||||
tempDiv.style.visibility = 'hidden'
|
||||
tempDiv.style.top = '-9999px'
|
||||
tempDiv.innerHTML = svgString
|
||||
document.body.appendChild(tempDiv)
|
||||
|
||||
const tempSvg = tempDiv.querySelector('svg')
|
||||
if (!tempSvg) {
|
||||
document.body.removeChild(tempDiv)
|
||||
return svgString
|
||||
}
|
||||
|
||||
// Get the bounding box of all content
|
||||
try {
|
||||
const bbox = tempSvg.getBBox()
|
||||
document.body.removeChild(tempDiv)
|
||||
|
||||
// Add small padding around content (5% of content dimensions)
|
||||
const padding = Math.max(bbox.width, bbox.height) * 0.05
|
||||
const newX = Math.max(0, bbox.x - padding)
|
||||
const newY = Math.max(0, bbox.y - padding)
|
||||
const newWidth = bbox.width + (2 * padding)
|
||||
const newHeight = bbox.height + (2 * padding)
|
||||
|
||||
// Update the viewBox to crop to content bounds
|
||||
const newViewBox = `${newX} ${newY} ${newWidth} ${newHeight}`
|
||||
|
||||
// Replace viewBox and remove fixed dimensions to allow CSS scaling
|
||||
let optimizedSvg = svgString
|
||||
.replace(/viewBox="[^"]*"/, `viewBox="${newViewBox}"`)
|
||||
.replace(/<svg[^>]*width="[^"]*"/, (match) => match.replace(/width="[^"]*"/, ''))
|
||||
.replace(/<svg[^>]*height="[^"]*"/, (match) => match.replace(/height="[^"]*"/, ''))
|
||||
|
||||
console.log(`📐 Optimized SVG: ${bbox.width.toFixed(1)}×${bbox.height.toFixed(1)} content bounds, viewBox optimized for CSS scaling`)
|
||||
|
||||
return optimizedSvg
|
||||
} catch (bboxError) {
|
||||
document.body.removeChild(tempDiv)
|
||||
console.warn('Could not get SVG bounding box, returning original:', bboxError)
|
||||
return svgString
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('SVG optimization failed, returning original:', error)
|
||||
return svgString
|
||||
}
|
||||
}
|
||||
|
||||
// Preload WASM and template without blocking - starts in background
|
||||
async function preloadTypstWasm() {
|
||||
if ($typst || isPreloading || typstInitializationPromise) return
|
||||
|
|
@ -262,8 +321,8 @@ ${template}
|
|||
|
||||
#align(center + horizon)[
|
||||
#box(
|
||||
width: ${width} - 2 * (${width} * 0.05),
|
||||
height: ${height} - 2 * (${height} * 0.05)
|
||||
width: ${width},
|
||||
height: ${height}
|
||||
)[
|
||||
#align(center + horizon)[
|
||||
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
|
||||
|
|
@ -410,7 +469,10 @@ async function generateSVGInBrowser(config: SorobanConfig): Promise<string> {
|
|||
|
||||
console.log('✅ Generated browser SVG, length:', svg.length)
|
||||
|
||||
return svg
|
||||
// Optimize viewBox to crop to actual content bounds
|
||||
const optimizedSvg = optimizeSvgViewBox(svg)
|
||||
|
||||
return optimizedSvg
|
||||
}
|
||||
|
||||
async function generateSVGOnServer(config: SorobanConfig): Promise<string> {
|
||||
|
|
@ -435,7 +497,11 @@ async function generateSVGOnServer(config: SorobanConfig): Promise<string> {
|
|||
}
|
||||
|
||||
console.log('🔄 Generated SVG on server, length:', data.svg.length)
|
||||
return data.svg
|
||||
|
||||
// Optimize viewBox to crop to actual content bounds
|
||||
const optimizedSvg = optimizeSvgViewBox(data.svg)
|
||||
|
||||
return optimizedSvg
|
||||
}
|
||||
|
||||
export async function generateSorobanPreview(
|
||||
|
|
|
|||
Loading…
Reference in New Issue