feat: remove typst-related code and routes
Remove all typst-related files, API routes, and components. This completes the typst dependency removal. Removed: - apps/web/src/app/api/typst-svg/route.ts - apps/web/src/app/api/typst-template/route.ts - apps/web/src/lib/typst-soroban.ts - apps/web/src/components/TypstSoroban.tsx - apps/web/src/app/test-typst/ - apps/web/src/app/typst-gallery/ - apps/web/src/app/typst-playground/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,154 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { $typst } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export interface TypstSVGRequest {
|
||||
number: number
|
||||
beadShape?: 'diamond' | 'circle' | 'square'
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
|
||||
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'
|
||||
hideInactiveBeads?: boolean
|
||||
showEmptyColumns?: boolean
|
||||
columns?: number | 'auto'
|
||||
scaleFactor?: number
|
||||
width?: string
|
||||
height?: string
|
||||
fontSize?: string
|
||||
fontFamily?: string
|
||||
transparent?: boolean
|
||||
coloredNumerals?: boolean
|
||||
}
|
||||
|
||||
// Cache for template content
|
||||
let flashcardsTemplate: string | null = null
|
||||
|
||||
async function getFlashcardsTemplate(): Promise<string> {
|
||||
if (flashcardsTemplate) {
|
||||
return flashcardsTemplate
|
||||
}
|
||||
|
||||
try {
|
||||
const { getTemplatePath } = require('@soroban/templates')
|
||||
const templatePath = getTemplatePath('flashcards.typ')
|
||||
flashcardsTemplate = fs.readFileSync(templatePath, 'utf-8')
|
||||
return flashcardsTemplate
|
||||
} catch (error) {
|
||||
console.error('Failed to load flashcards template:', error)
|
||||
throw new Error('Template loading failed')
|
||||
}
|
||||
}
|
||||
|
||||
function processBeadAnnotations(svg: string): string {
|
||||
const { extractBeadAnnotations } = require('@soroban/templates')
|
||||
const result = extractBeadAnnotations(svg)
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('ℹ️ SVG bead processing warnings:', result.warnings)
|
||||
}
|
||||
|
||||
console.log(`🔗 Processed ${result.count} bead links into data attributes`)
|
||||
return result.processedSVG
|
||||
}
|
||||
|
||||
function createTypstContent(config: TypstSVGRequest, template: string): string {
|
||||
const {
|
||||
number,
|
||||
beadShape = 'diamond',
|
||||
colorScheme = 'place-value',
|
||||
colorPalette = 'default',
|
||||
hideInactiveBeads = false,
|
||||
showEmptyColumns = false,
|
||||
columns = 'auto',
|
||||
scaleFactor = 1.0,
|
||||
width = '120pt',
|
||||
height = '160pt',
|
||||
fontSize = '48pt',
|
||||
fontFamily = 'DejaVu Sans',
|
||||
transparent = false,
|
||||
coloredNumerals = false
|
||||
} = config
|
||||
|
||||
return `
|
||||
${template}
|
||||
|
||||
#set page(
|
||||
width: ${width},
|
||||
height: ${height},
|
||||
margin: 0pt,
|
||||
fill: ${transparent ? 'none' : 'white'}
|
||||
)
|
||||
|
||||
#set text(font: "${fontFamily}", size: ${fontSize}, fallback: true)
|
||||
|
||||
#align(center + horizon)[
|
||||
#box(
|
||||
width: ${width} - 2 * (${width} * 0.05),
|
||||
height: ${height} - 2 * (${height} * 0.05)
|
||||
)[
|
||||
#align(center + horizon)[
|
||||
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
|
||||
#draw-soroban(
|
||||
${number},
|
||||
columns: ${columns},
|
||||
show-empty: ${showEmptyColumns},
|
||||
hide-inactive: ${hideInactiveBeads},
|
||||
bead-shape: "${beadShape}",
|
||||
color-scheme: "${colorScheme}",
|
||||
color-palette: "${colorPalette}",
|
||||
base-size: 1.0
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
`
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const config: TypstSVGRequest = await request.json()
|
||||
|
||||
console.log('🎨 Generating typst.ts SVG for number:', config.number)
|
||||
|
||||
// Load template
|
||||
const template = await getFlashcardsTemplate()
|
||||
|
||||
// Create typst content
|
||||
const typstContent = createTypstContent(config, template)
|
||||
|
||||
// Generate SVG using typst.ts
|
||||
const rawSvg = await $typst.svg({ mainContent: typstContent })
|
||||
|
||||
// Post-process to convert bead annotations to data attributes
|
||||
const svg = processBeadAnnotations(rawSvg)
|
||||
|
||||
console.log('✅ Generated and processed typst.ts SVG, length:', svg.length)
|
||||
|
||||
return NextResponse.json({
|
||||
svg,
|
||||
success: true,
|
||||
number: config.number
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Typst SVG generation failed:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
success: false
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
endpoint: 'typst-svg',
|
||||
message: 'Typst.ts SVG generation API is running'
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import fs from 'fs'
|
||||
import { getTemplatePath } from '@soroban/templates'
|
||||
|
||||
// API endpoint to serve the flashcards.typ template content
|
||||
export async function GET() {
|
||||
try {
|
||||
const templatePath = getTemplatePath('flashcards.typ');
|
||||
const flashcardsTemplate = fs.readFileSync(templatePath, 'utf-8')
|
||||
|
||||
return NextResponse.json({
|
||||
template: flashcardsTemplate,
|
||||
success: true
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load typst template:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to load template',
|
||||
success: false
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { TypstSoroban } from '@/components/TypstSoroban'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { container, stack, grid, hstack } from '../../../styled-system/patterns'
|
||||
|
||||
export default function TestTypstPage() {
|
||||
const [selectedNumber, setSelectedNumber] = useState(23)
|
||||
const [generationCount, setGenerationCount] = useState(0)
|
||||
const [errorCount, setErrorCount] = useState(0)
|
||||
|
||||
const testNumbers = [5, 23, 67, 123, 456]
|
||||
|
||||
return (
|
||||
<div className={css({ minH: 'screen', bg: 'gray.50', py: '8' })}>
|
||||
<div className={container({ maxW: '6xl', px: '4' })}>
|
||||
<div className={stack({ gap: '8' })}>
|
||||
{/* Header */}
|
||||
<div className={stack({ gap: '4', textAlign: 'center' })}>
|
||||
<h1 className={css({
|
||||
fontSize: '3xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Typst.ts Integration Test
|
||||
</h1>
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
Testing browser-only Soroban SVG generation (no server fallback)
|
||||
</p>
|
||||
<div className={hstack({ gap: '6', justify: 'center' })}>
|
||||
<div className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'green.100',
|
||||
color: 'green.700',
|
||||
rounded: 'full',
|
||||
fontSize: 'sm'
|
||||
})}>
|
||||
Generated: {generationCount}
|
||||
</div>
|
||||
<div className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: errorCount > 0 ? 'red.100' : 'gray.100',
|
||||
color: errorCount > 0 ? 'red.700' : 'gray.700',
|
||||
rounded: 'full',
|
||||
fontSize: 'sm'
|
||||
})}>
|
||||
Errors: {errorCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number Selector */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
shadow: 'card'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
mb: '4'
|
||||
})}>
|
||||
Select Number to Generate
|
||||
</h3>
|
||||
<div className={hstack({ gap: '3', flexWrap: 'wrap' })}>
|
||||
{testNumbers.map((num) => (
|
||||
<button
|
||||
key={num}
|
||||
onClick={() => setSelectedNumber(num)}
|
||||
className={css({
|
||||
px: '4',
|
||||
py: '2',
|
||||
rounded: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor: selectedNumber === num ? 'brand.600' : 'gray.200',
|
||||
bg: selectedNumber === num ? 'brand.50' : 'white',
|
||||
color: selectedNumber === num ? 'brand.700' : 'gray.700',
|
||||
fontWeight: 'medium',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
bg: 'brand.25'
|
||||
}
|
||||
})}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="number"
|
||||
value={selectedNumber}
|
||||
onChange={(e) => setSelectedNumber(parseInt(e.target.value) || 0)}
|
||||
className={css({
|
||||
w: '20',
|
||||
px: '3',
|
||||
py: '2',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'lg',
|
||||
fontSize: 'sm',
|
||||
_focus: {
|
||||
borderColor: 'brand.400',
|
||||
outline: 'none'
|
||||
}
|
||||
})}
|
||||
min="0"
|
||||
max="9999"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generated Soroban */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
shadow: 'card'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
mb: '4'
|
||||
})}>
|
||||
Generated Soroban (Number: {selectedNumber})
|
||||
</h3>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minH: '300px',
|
||||
bg: 'gray.50',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200'
|
||||
})}>
|
||||
<TypstSoroban
|
||||
number={selectedNumber}
|
||||
width="200pt"
|
||||
height="250pt"
|
||||
enableServerFallback={false}
|
||||
lazy={false}
|
||||
onSuccess={() => setGenerationCount(prev => prev + 1)}
|
||||
onError={() => setErrorCount(prev => prev + 1)}
|
||||
className={css({
|
||||
maxW: 'sm',
|
||||
maxH: '400px'
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Grid */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
shadow: 'card'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
mb: '4'
|
||||
})}>
|
||||
Test Grid (Multiple Numbers)
|
||||
</h3>
|
||||
<div className={grid({
|
||||
columns: { base: 2, md: 3, lg: 5 },
|
||||
gap: '4'
|
||||
})}>
|
||||
{testNumbers.map((num, index) => (
|
||||
<div
|
||||
key={num}
|
||||
className={css({
|
||||
aspectRatio: '3/4',
|
||||
bg: 'gray.50',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
overflow: 'hidden'
|
||||
})}
|
||||
>
|
||||
<div className={css({
|
||||
p: '2',
|
||||
bg: 'white',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
textAlign: 'center',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1'
|
||||
})}>
|
||||
{num}
|
||||
{index > 1 && <span className={css({ fontSize: 'xs', color: 'blue.600' })}>lazy</span>}
|
||||
</div>
|
||||
<div className={css({ p: '2', h: 'full' })}>
|
||||
<TypstSoroban
|
||||
number={num}
|
||||
width="100pt"
|
||||
height="120pt"
|
||||
enableServerFallback={false}
|
||||
lazy={index > 1} // Make the last 3 components lazy
|
||||
onSuccess={() => setGenerationCount(prev => prev + 1)}
|
||||
onError={() => setErrorCount(prev => prev + 1)}
|
||||
className={css({ w: 'full', h: 'full' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className={css({
|
||||
bg: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'blue.900',
|
||||
mb: '3'
|
||||
})}>
|
||||
About This Test
|
||||
</h3>
|
||||
<div className={stack({ gap: '2' })}>
|
||||
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
|
||||
• This page tests the typst.ts integration for generating Soroban SVGs directly in the browser
|
||||
</p>
|
||||
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
|
||||
• No Python bridge required - everything runs natively in TypeScript/WebAssembly
|
||||
</p>
|
||||
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
|
||||
• WASM preloading starts automatically in background for better performance
|
||||
</p>
|
||||
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
|
||||
• Lazy loading demo: Last 3 grid items show placeholders until clicked (progressive enhancement)
|
||||
</p>
|
||||
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
|
||||
• Global abacus display settings are automatically applied
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface TemplateExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
number: number;
|
||||
config: {
|
||||
bead_shape?: 'diamond' | 'circle' | 'square';
|
||||
color_scheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
|
||||
base_size?: number;
|
||||
columns?: number | 'auto';
|
||||
show_empty?: boolean;
|
||||
hide_inactive?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const examples: TemplateExample[] = [
|
||||
{
|
||||
id: 'basic-5',
|
||||
title: 'Basic Number 5',
|
||||
description: 'Simple representation of 5 with default settings',
|
||||
number: 5,
|
||||
config: {}
|
||||
},
|
||||
{
|
||||
id: 'diamond-123',
|
||||
title: 'Diamond Beads - 123',
|
||||
description: 'Number 123 with diamond beads and place-value colors',
|
||||
number: 123,
|
||||
config: {
|
||||
bead_shape: 'diamond',
|
||||
color_scheme: 'place-value',
|
||||
base_size: 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'circle-1234',
|
||||
title: 'Circle Beads - 1234',
|
||||
description: 'Larger number with circular beads and heaven-earth colors',
|
||||
number: 1234,
|
||||
config: {
|
||||
bead_shape: 'circle',
|
||||
color_scheme: 'heaven-earth',
|
||||
base_size: 1.0
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'large-scale-42',
|
||||
title: 'Large Scale - 42',
|
||||
description: 'Number 42 with larger scale for detail work',
|
||||
number: 42,
|
||||
config: {
|
||||
bead_shape: 'diamond',
|
||||
color_scheme: 'place-value',
|
||||
base_size: 2.0
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'minimal-999',
|
||||
title: 'Minimal - 999',
|
||||
description: 'Compact rendering with hidden inactive beads',
|
||||
number: 999,
|
||||
config: {
|
||||
bead_shape: 'square',
|
||||
color_scheme: 'monochrome',
|
||||
hide_inactive: true,
|
||||
base_size: 0.8
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'alternating-colors-567',
|
||||
title: 'Alternating Colors - 567',
|
||||
description: 'Mid-range number with alternating color scheme',
|
||||
number: 567,
|
||||
config: {
|
||||
bead_shape: 'circle',
|
||||
color_scheme: 'alternating',
|
||||
base_size: 1.5
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default function TypstGallery() {
|
||||
const [renderings, setRenderings] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState<Record<string, boolean>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const generateSvg = async (example: TemplateExample) => {
|
||||
setLoading(prev => ({ ...prev, [example.id]: true }));
|
||||
setErrors(prev => ({ ...prev, [example.id]: '' }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/typst-svg', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
number: example.number,
|
||||
...example.config
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setRenderings(prev => ({ ...prev, [example.id]: data.svg }));
|
||||
} catch (error) {
|
||||
console.error('Error generating SVG:', error);
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[example.id]: error instanceof Error ? error.message : 'Unknown error'
|
||||
}));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, [example.id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const generateAll = async () => {
|
||||
for (const example of examples) {
|
||||
await generateSvg(example);
|
||||
// Small delay between requests to avoid overwhelming the server
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
🧮 Soroban Template Gallery
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 mb-6">
|
||||
Interactive preview of soroban template renderings with different configurations
|
||||
</p>
|
||||
<button
|
||||
onClick={generateAll}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Generate All Examples
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{examples.map((example) => (
|
||||
<div key={example.id} className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{example.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
{example.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => generateSvg(example)}
|
||||
disabled={loading[example.id]}
|
||||
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading[example.id] ? '🔄' : '🎨'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Configuration Details */}
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded text-xs">
|
||||
<div className="font-medium text-gray-700 mb-1">Configuration:</div>
|
||||
<div className="text-gray-600">
|
||||
<div>Number: <code>{example.number}</code></div>
|
||||
{Object.entries(example.config).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
{key}: <code>{String(value)}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rendering Area */}
|
||||
<div className="border rounded-lg p-4 bg-gray-50 min-h-[200px] flex items-center justify-center">
|
||||
{loading[example.id] && (
|
||||
<div className="text-center">
|
||||
<div className="animate-spin text-2xl mb-2">🔄</div>
|
||||
<div className="text-gray-600">Generating...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors[example.id] && (
|
||||
<div className="text-center text-red-600">
|
||||
<div className="text-2xl mb-2">❌</div>
|
||||
<div className="text-sm">{errors[example.id]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderings[example.id] && !loading[example.id] && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: renderings[example.id] }}
|
||||
className="max-w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading[example.id] && !errors[example.id] && !renderings[example.id] && (
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="text-2xl mb-2">🧮</div>
|
||||
<div className="text-sm">Click generate to render</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-12 text-gray-600">
|
||||
<p>
|
||||
Powered by <strong>@soroban/templates</strong> and <strong>typst.ts</strong>
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
This gallery helps visualize different template configurations before implementation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface TypstConfig {
|
||||
number: number;
|
||||
bead_shape: 'diamond' | 'circle' | 'square';
|
||||
color_scheme: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
|
||||
color_palette: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature';
|
||||
base_size: number;
|
||||
columns: number | 'auto';
|
||||
show_empty: boolean;
|
||||
hide_inactive: boolean;
|
||||
}
|
||||
|
||||
const defaultConfig: TypstConfig = {
|
||||
number: 123,
|
||||
bead_shape: 'diamond',
|
||||
color_scheme: 'place-value',
|
||||
color_palette: 'default',
|
||||
base_size: 1.0,
|
||||
columns: 'auto',
|
||||
show_empty: false,
|
||||
hide_inactive: false,
|
||||
};
|
||||
|
||||
const presets: Record<string, Partial<TypstConfig>> = {
|
||||
'Classic': { bead_shape: 'diamond', color_scheme: 'monochrome', base_size: 1.0 },
|
||||
'Colorful': { bead_shape: 'circle', color_scheme: 'place-value', color_palette: 'default', base_size: 1.2 },
|
||||
'Educational': { bead_shape: 'circle', color_scheme: 'heaven-earth', show_empty: true, base_size: 1.5 },
|
||||
'Minimal': { bead_shape: 'square', color_scheme: 'monochrome', hide_inactive: true, base_size: 0.8 },
|
||||
'Accessible': { bead_shape: 'circle', color_scheme: 'place-value', color_palette: 'colorblind', base_size: 1.3 },
|
||||
};
|
||||
|
||||
export default function TypstPlayground() {
|
||||
const [config, setConfig] = useState<TypstConfig>(defaultConfig);
|
||||
const [svg, setSvg] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [lastGenerated, setLastGenerated] = useState<string>('');
|
||||
|
||||
const generateSvg = useCallback(async (currentConfig: TypstConfig) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/typst-svg', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(currentConfig),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSvg(data.svg);
|
||||
setLastGenerated(new Date().toLocaleTimeString());
|
||||
} catch (error) {
|
||||
console.error('Error generating SVG:', error);
|
||||
setError(error instanceof Error ? error.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateConfig = (key: keyof TypstConfig, value: any) => {
|
||||
const newConfig = { ...config, [key]: value };
|
||||
setConfig(newConfig);
|
||||
// Auto-generate on config change (debounced)
|
||||
setTimeout(() => generateSvg(newConfig), 500);
|
||||
};
|
||||
|
||||
const applyPreset = (presetName: string) => {
|
||||
const newConfig = { ...config, ...presets[presetName] };
|
||||
setConfig(newConfig);
|
||||
generateSvg(newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
🛠️ Soroban Template Playground
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
Interactive tool for testing soroban template configurations in real-time
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Configuration Panel */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 sticky top-8">
|
||||
<h2 className="text-xl font-semibold mb-6">Configuration</h2>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Quick Presets
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.keys(presets).map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => applyPreset(preset)}
|
||||
className="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded transition-colors"
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Number
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.number}
|
||||
onChange={(e) => updateConfig('number', parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
min="0"
|
||||
max="99999"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bead Shape */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bead Shape
|
||||
</label>
|
||||
<select
|
||||
value={config.bead_shape}
|
||||
onChange={(e) => updateConfig('bead_shape', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="diamond">Diamond</option>
|
||||
<option value="circle">Circle</option>
|
||||
<option value="square">Square</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Color Scheme */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color Scheme
|
||||
</label>
|
||||
<select
|
||||
value={config.color_scheme}
|
||||
onChange={(e) => updateConfig('color_scheme', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="monochrome">Monochrome</option>
|
||||
<option value="place-value">Place Value</option>
|
||||
<option value="heaven-earth">Heaven & Earth</option>
|
||||
<option value="alternating">Alternating</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Color Palette */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color Palette
|
||||
</label>
|
||||
<select
|
||||
value={config.color_palette}
|
||||
onChange={(e) => updateConfig('color_palette', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="colorblind">Colorblind Friendly</option>
|
||||
<option value="mnemonic">Mnemonic</option>
|
||||
<option value="grayscale">Grayscale</option>
|
||||
<option value="nature">Nature</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Base Size */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Scale: {config.base_size}x
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="3.0"
|
||||
step="0.1"
|
||||
value={config.base_size}
|
||||
onChange={(e) => updateConfig('base_size', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Columns */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Columns
|
||||
</label>
|
||||
<select
|
||||
value={config.columns}
|
||||
onChange={(e) => updateConfig('columns', e.target.value === 'auto' ? 'auto' : parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.show_empty}
|
||||
onChange={(e) => updateConfig('show_empty', e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Show empty columns</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hide_inactive}
|
||||
onChange={(e) => updateConfig('hide_inactive', e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Hide inactive beads</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
onClick={() => generateSvg(config)}
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white py-3 px-4 rounded-md font-medium transition-colors"
|
||||
>
|
||||
{loading ? 'Generating...' : 'Generate Now'}
|
||||
</button>
|
||||
|
||||
{lastGenerated && (
|
||||
<p className="text-xs text-gray-500 mt-2 text-center">
|
||||
Last updated: {lastGenerated}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Panel */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold">Live Preview</h2>
|
||||
<div className="text-sm text-gray-600">
|
||||
Number: <code className="bg-gray-100 px-2 py-1 rounded">{config.number}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Area */}
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 min-h-[400px] flex items-center justify-center bg-gradient-to-br from-gray-50 to-white">
|
||||
{loading && (
|
||||
<div className="text-center">
|
||||
<div className="animate-spin text-4xl mb-4">🔄</div>
|
||||
<div className="text-gray-600">Generating soroban...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-center text-red-600">
|
||||
<div className="text-4xl mb-4">❌</div>
|
||||
<div className="font-medium">Generation Error</div>
|
||||
<div className="text-sm mt-2">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{svg && !loading && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
className="max-w-full max-h-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!svg && !loading && !error && (
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="text-4xl mb-4">🧮</div>
|
||||
<div className="font-medium">Configure and Generate</div>
|
||||
<div className="text-sm mt-2">Adjust settings and click generate to see your soroban</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configuration Summary */}
|
||||
{svg && (
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-medium text-gray-900 mb-2">Current Configuration</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">Shape:</span>{' '}
|
||||
<span className="font-mono">{config.bead_shape}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Colors:</span>{' '}
|
||||
<span className="font-mono">{config.color_scheme}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Scale:</span>{' '}
|
||||
<span className="font-mono">{config.base_size}x</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Columns:</span>{' '}
|
||||
<span className="font-mono">{config.columns}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { generateSorobanSVG, getWasmStatus, triggerWasmPreload, type SorobanConfig } from '@/lib/typst-soroban'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
|
||||
interface TypstSorobanProps {
|
||||
number: number
|
||||
width?: string
|
||||
height?: string
|
||||
className?: string
|
||||
onError?: (error: string) => void
|
||||
onSuccess?: () => void
|
||||
enableServerFallback?: boolean
|
||||
lazy?: boolean // New prop for lazy loading
|
||||
transparent?: boolean // New prop for transparent background
|
||||
}
|
||||
|
||||
export function TypstSoroban({
|
||||
number,
|
||||
width = '120pt',
|
||||
height = '160pt',
|
||||
className,
|
||||
onError,
|
||||
onSuccess,
|
||||
enableServerFallback = false,
|
||||
lazy = false,
|
||||
transparent = false
|
||||
}: TypstSorobanProps) {
|
||||
const [svg, setSvg] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(!lazy) // Don't start loading if lazy
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [shouldLoad, setShouldLoad] = useState(!lazy) // Control when loading starts
|
||||
const globalConfig = useAbacusConfig()
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const currentConfigRef = useRef<any>(null)
|
||||
|
||||
// Memoize the config to prevent unnecessary re-renders
|
||||
const stableConfig = useMemo(() => ({
|
||||
beadShape: globalConfig.beadShape,
|
||||
colorScheme: globalConfig.colorScheme,
|
||||
hideInactiveBeads: globalConfig.hideInactiveBeads,
|
||||
coloredNumerals: globalConfig.coloredNumerals,
|
||||
scaleFactor: globalConfig.scaleFactor
|
||||
}), [
|
||||
globalConfig.beadShape,
|
||||
globalConfig.colorScheme,
|
||||
globalConfig.hideInactiveBeads,
|
||||
globalConfig.coloredNumerals,
|
||||
globalConfig.scaleFactor
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLoad) return
|
||||
|
||||
console.log(`🔄 TypstSoroban useEffect triggered for number ${number}, hasExistingSVG: ${!!svg}`)
|
||||
|
||||
async function generateSVG() {
|
||||
// Create current config signature
|
||||
const currentConfig = {
|
||||
number,
|
||||
width,
|
||||
height,
|
||||
beadShape: stableConfig.beadShape,
|
||||
colorScheme: stableConfig.colorScheme,
|
||||
hideInactiveBeads: stableConfig.hideInactiveBeads,
|
||||
coloredNumerals: stableConfig.coloredNumerals,
|
||||
scaleFactor: stableConfig.scaleFactor,
|
||||
transparent,
|
||||
enableServerFallback
|
||||
}
|
||||
|
||||
// Check if config changed since last render
|
||||
const configChanged = JSON.stringify(currentConfig) !== JSON.stringify(currentConfigRef.current)
|
||||
|
||||
// Don't regenerate if we already have an SVG for this exact config
|
||||
if (svg && !error && !configChanged) {
|
||||
console.log(`✅ Skipping regeneration for ${number} - already have SVG with same config`)
|
||||
return
|
||||
}
|
||||
|
||||
if (configChanged) {
|
||||
console.log(`🔄 Config changed for ${number}, regenerating SVG`)
|
||||
// Clear existing SVG to show fresh loading state for config changes
|
||||
setSvg(null)
|
||||
}
|
||||
|
||||
// Update config ref
|
||||
currentConfigRef.current = currentConfig
|
||||
|
||||
// Cancel any previous generation
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController()
|
||||
const signal = abortControllerRef.current.signal
|
||||
|
||||
// Check cache quickly before showing loading state
|
||||
const cacheKey = JSON.stringify({
|
||||
number,
|
||||
width,
|
||||
height,
|
||||
beadShape: stableConfig.beadShape,
|
||||
colorScheme: stableConfig.colorScheme,
|
||||
hideInactiveBeads: stableConfig.hideInactiveBeads,
|
||||
coloredNumerals: stableConfig.coloredNumerals,
|
||||
scaleFactor: stableConfig.scaleFactor,
|
||||
transparent,
|
||||
enableServerFallback
|
||||
})
|
||||
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Try generation immediately to see if it's cached
|
||||
const config: SorobanConfig = {
|
||||
number,
|
||||
width,
|
||||
height,
|
||||
beadShape: stableConfig.beadShape,
|
||||
colorScheme: stableConfig.colorScheme,
|
||||
hideInactiveBeads: stableConfig.hideInactiveBeads,
|
||||
coloredNumerals: stableConfig.coloredNumerals,
|
||||
scaleFactor: stableConfig.scaleFactor,
|
||||
transparent,
|
||||
enableServerFallback
|
||||
}
|
||||
|
||||
// Set loading only after a delay if generation is slow
|
||||
const loadingTimeout = setTimeout(() => {
|
||||
if (!signal.aborted) {
|
||||
setIsLoading(true)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const generatedSvg = await generateSorobanSVG(config)
|
||||
|
||||
// Clear timeout since we got result quickly
|
||||
clearTimeout(loadingTimeout)
|
||||
|
||||
if (signal.aborted) return
|
||||
|
||||
// Crop the SVG to remove whitespace around abacus
|
||||
const croppedSvg = cropSVGToContent(generatedSvg)
|
||||
setSvg(croppedSvg)
|
||||
setTimeout(() => onSuccess?.(), 0)
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
||||
setError(errorMessage)
|
||||
console.error('TypstSoroban generation error:', err)
|
||||
setTimeout(() => onError?.(errorMessage), 0)
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generateSVG()
|
||||
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [shouldLoad, number, width, height, stableConfig, enableServerFallback, transparent])
|
||||
|
||||
// Handler to trigger loading on user interaction
|
||||
const handleLoadTrigger = useCallback(() => {
|
||||
if (!shouldLoad && !isLoading && !svg) {
|
||||
setShouldLoad(true)
|
||||
// Also trigger WASM preload if not already started
|
||||
triggerWasmPreload()
|
||||
}
|
||||
}, [shouldLoad, isLoading, svg])
|
||||
|
||||
// Show lazy loading placeholder
|
||||
if (lazy && !shouldLoad && !svg) {
|
||||
const wasmStatus = getWasmStatus()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onClick={handleLoadTrigger}
|
||||
onMouseEnter={handleLoadTrigger}
|
||||
>
|
||||
<div className={css({
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: wasmStatus.isLoaded ? 'green.25' : 'gray.50',
|
||||
rounded: 'md',
|
||||
minH: '200px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: wasmStatus.isLoaded ? 'green.50' : 'gray.100',
|
||||
transform: 'scale(1.02)'
|
||||
}
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: '4xl',
|
||||
opacity: '0.6',
|
||||
transition: 'all',
|
||||
_hover: { opacity: '0.8' }
|
||||
})}>
|
||||
{wasmStatus.isLoaded ? '🚀' : '🧮'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={css({
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: 'blue.25',
|
||||
rounded: 'md',
|
||||
minH: '200px'
|
||||
})}>
|
||||
<div className={css({
|
||||
w: '6',
|
||||
h: '6',
|
||||
border: '2px solid',
|
||||
borderColor: 'blue.200',
|
||||
borderTopColor: 'blue.500',
|
||||
rounded: 'full',
|
||||
animation: 'spin 1s linear infinite'
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={css({
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: 'red.25',
|
||||
rounded: 'md',
|
||||
minH: '200px'
|
||||
})}>
|
||||
<div className={css({ fontSize: '3xl', opacity: '0.6' })}>⚠️</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!svg) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={css({
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: 'gray.50',
|
||||
rounded: 'md',
|
||||
minH: '200px'
|
||||
})}>
|
||||
<div className={css({ color: 'gray.500', fontSize: 'sm' })}>
|
||||
No SVG generated
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
className={css({
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain'
|
||||
}
|
||||
})}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Optional: Create a hook for easier usage
|
||||
export function useTypstSoroban(config: SorobanConfig) {
|
||||
const [svg, setSvg] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const generate = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setSvg(null)
|
||||
|
||||
try {
|
||||
const generatedSvg = await generateSorobanSVG(config)
|
||||
setSvg(generatedSvg)
|
||||
return generatedSvg
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
||||
setError(errorMessage)
|
||||
throw err
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
svg,
|
||||
isLoading,
|
||||
error,
|
||||
generate
|
||||
}
|
||||
}
|
||||
|
||||
// SVG cropping function to remove whitespace around abacus content
|
||||
function cropSVGToContent(svgContent: string): string {
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const svgDoc = parser.parseFromString(svgContent, 'image/svg+xml')
|
||||
const svgElement = svgDoc.documentElement
|
||||
|
||||
if (svgElement.tagName !== 'svg') {
|
||||
return svgContent
|
||||
}
|
||||
|
||||
// Get all visible elements and calculate their combined bounding box
|
||||
const elements = svgElement.querySelectorAll('path, circle, rect, line, polygon, ellipse')
|
||||
const bounds: { x: number; y: number; width: number; height: number }[] = []
|
||||
|
||||
elements.forEach(element => {
|
||||
try {
|
||||
const bbox = (element as SVGGraphicsElement).getBBox()
|
||||
if (bbox.width > 0 && bbox.height > 0) {
|
||||
bounds.push(bbox)
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip elements that can't be measured
|
||||
}
|
||||
})
|
||||
|
||||
if (bounds.length === 0) {
|
||||
return svgContent // No measurable content found
|
||||
}
|
||||
|
||||
// Calculate the combined bounding box
|
||||
const minX = Math.min(...bounds.map(b => b.x))
|
||||
const maxX = Math.max(...bounds.map(b => b.x + b.width))
|
||||
const minY = Math.min(...bounds.map(b => b.y))
|
||||
const maxY = Math.max(...bounds.map(b => b.y + b.height))
|
||||
|
||||
// Add minimal padding
|
||||
const padding = 5
|
||||
const newX = minX - padding
|
||||
const newY = minY - padding
|
||||
const newWidth = (maxX - minX) + (padding * 2)
|
||||
const newHeight = (maxY - minY) + (padding * 2)
|
||||
|
||||
// Create the new viewBox
|
||||
const newViewBox = `${newX} ${newY} ${newWidth} ${newHeight}`
|
||||
|
||||
// Update the SVG viewBox while keeping original width/height
|
||||
let croppedSvg = svgContent.replace(/viewBox="[^"]*"/, `viewBox="${newViewBox}"`)
|
||||
|
||||
// Ensure preserveAspectRatio is set for proper scaling
|
||||
if (!croppedSvg.includes('preserveAspectRatio')) {
|
||||
croppedSvg = croppedSvg.replace('<svg', '<svg preserveAspectRatio="xMidYMid meet"')
|
||||
}
|
||||
|
||||
console.log(`🎯 SVG cropped: ${newWidth.toFixed(1)}x${newHeight.toFixed(1)} content in original SVG`)
|
||||
return croppedSvg
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to crop SVG:', error)
|
||||
return svgContent // Return original if cropping fails
|
||||
}
|
||||
}
|
||||
@@ -1,703 +0,0 @@
|
||||
// TypeScript module for generating Soroban SVGs using typst.ts
|
||||
// This replaces the Python bridge with a browser-native solution
|
||||
|
||||
// Browser-side typst.ts rendering
|
||||
let $typst: any = null
|
||||
let isLoading = false
|
||||
|
||||
// Promise to track the initialization process
|
||||
let typstInitializationPromise: Promise<any> | null = null
|
||||
|
||||
// Preloading state
|
||||
let isPreloading = false
|
||||
let preloadStartTime: number | null = null
|
||||
|
||||
// Start preloading WASM as soon as this module is imported
|
||||
if (typeof window !== 'undefined') {
|
||||
setTimeout(() => {
|
||||
preloadTypstWasm()
|
||||
}, 100) // Small delay to avoid blocking initial render
|
||||
}
|
||||
|
||||
// SVG viewBox optimization - crops SVG to actual content bounds
|
||||
function optimizeSvgViewBox(svgString: string): string {
|
||||
try {
|
||||
console.log('🔍 Starting SVG viewBox optimization...')
|
||||
|
||||
// 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) {
|
||||
console.warn('❌ No SVG element found, returning original')
|
||||
return svgString
|
||||
}
|
||||
|
||||
// Extract original viewBox and dimensions for debugging
|
||||
const originalViewBox = svgElement.getAttribute('viewBox')
|
||||
const originalWidth = svgElement.getAttribute('width')
|
||||
const originalHeight = svgElement.getAttribute('height')
|
||||
console.log(`📊 Original SVG - viewBox: ${originalViewBox}, width: ${originalWidth}, height: ${originalHeight}`)
|
||||
|
||||
// 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.style.left = '-9999px'
|
||||
tempDiv.innerHTML = svgString
|
||||
document.body.appendChild(tempDiv)
|
||||
|
||||
const tempSvg = tempDiv.querySelector('svg')
|
||||
if (!tempSvg) {
|
||||
document.body.removeChild(tempDiv)
|
||||
console.warn('❌ Could not create temp SVG element')
|
||||
return svgString
|
||||
}
|
||||
|
||||
// Get the bounding box of all content
|
||||
try {
|
||||
// Try multiple methods to get content bounds
|
||||
let bbox: DOMRect | SVGRect
|
||||
|
||||
// Method 1: Try to get bbox of visible content elements (paths, circles, rects, etc.)
|
||||
try {
|
||||
const contentElements = tempSvg.querySelectorAll('path, circle, rect, line, polygon, polyline, text, g[stroke], g[fill]')
|
||||
|
||||
if (contentElements.length > 0) {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||
let foundValidBounds = false
|
||||
|
||||
contentElements.forEach(element => {
|
||||
try {
|
||||
const elementBBox = (element as SVGGraphicsElement).getBBox()
|
||||
if (elementBBox.width > 0 && elementBBox.height > 0) {
|
||||
minX = Math.min(minX, elementBBox.x)
|
||||
minY = Math.min(minY, elementBBox.y)
|
||||
maxX = Math.max(maxX, elementBBox.x + elementBBox.width)
|
||||
maxY = Math.max(maxY, elementBBox.y + elementBBox.height)
|
||||
foundValidBounds = true
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip elements that can't provide bbox
|
||||
}
|
||||
})
|
||||
|
||||
if (foundValidBounds) {
|
||||
bbox = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY
|
||||
} as SVGRect
|
||||
console.log(`📦 Content elements bbox successful: x=${bbox.x}, y=${bbox.y}, width=${bbox.width}, height=${bbox.height}`)
|
||||
} else {
|
||||
throw new Error('No valid content elements found')
|
||||
}
|
||||
} else {
|
||||
throw new Error('No content elements found')
|
||||
}
|
||||
} catch (contentError) {
|
||||
console.warn('⚠️ Content elements bbox failed, trying SVG getBBox():', contentError)
|
||||
|
||||
// Method 2: Try getBBox() on the SVG element
|
||||
try {
|
||||
bbox = tempSvg.getBBox()
|
||||
console.log(`📦 SVG getBBox() successful: x=${bbox.x}, y=${bbox.y}, width=${bbox.width}, height=${bbox.height}`)
|
||||
} catch (getBBoxError) {
|
||||
console.warn('⚠️ SVG getBBox() failed, trying getBoundingClientRect():', getBBoxError)
|
||||
|
||||
// Method 3: Use getBoundingClientRect() as fallback
|
||||
const clientRect = tempSvg.getBoundingClientRect()
|
||||
bbox = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: clientRect.width || 200,
|
||||
height: clientRect.height || 280
|
||||
} as SVGRect
|
||||
console.log(`📦 getBoundingClientRect() fallback: width=${bbox.width}, height=${bbox.height}`)
|
||||
}
|
||||
}
|
||||
|
||||
document.body.removeChild(tempDiv)
|
||||
|
||||
// Validate bounding box
|
||||
if (!bbox || bbox.width <= 0 || bbox.height <= 0) {
|
||||
console.warn(`❌ Invalid bounding box: ${JSON.stringify(bbox)}, returning original`)
|
||||
return svgString
|
||||
}
|
||||
|
||||
// Much more aggressive cropping - minimal padding only
|
||||
const paddingX = Math.max(2, bbox.width * 0.01) // 1% padding, minimum 2 units
|
||||
const paddingY = Math.max(2, bbox.height * 0.01)
|
||||
|
||||
const newX = Math.max(0, bbox.x - paddingX)
|
||||
const newY = Math.max(0, bbox.y - paddingY)
|
||||
const newWidth = bbox.width + (2 * paddingX)
|
||||
const newHeight = bbox.height + (2 * paddingY)
|
||||
|
||||
// Failsafe: If the cropped SVG still has too much wasted space,
|
||||
// use a more aggressive crop based on actual content ratio
|
||||
const originalViewBox = svgElement.getAttribute('viewBox')
|
||||
const originalDimensions = originalViewBox ? originalViewBox.split(' ').map(Number) : [0, 0, 400, 400]
|
||||
const originalWidth = originalDimensions[2]
|
||||
const originalHeight = originalDimensions[3]
|
||||
|
||||
const contentRatioX = bbox.width / originalWidth
|
||||
const contentRatioY = bbox.height / originalHeight
|
||||
|
||||
// If content takes up less than 30% of original space, be even more aggressive
|
||||
if (contentRatioX < 0.3 || contentRatioY < 0.3) {
|
||||
console.log(`🔥 Ultra-aggressive crop: content only ${(contentRatioX*100).toFixed(1)}% x ${(contentRatioY*100).toFixed(1)}% of original`)
|
||||
// Remove almost all padding for tiny content
|
||||
const ultraPaddingX = Math.max(1, bbox.width * 0.005)
|
||||
const ultraPaddingY = Math.max(1, bbox.height * 0.005)
|
||||
|
||||
const ultraX = Math.max(0, bbox.x - ultraPaddingX)
|
||||
const ultraY = Math.max(0, bbox.y - ultraPaddingY)
|
||||
const ultraWidth = bbox.width + (2 * ultraPaddingX)
|
||||
const ultraHeight = bbox.height + (2 * ultraPaddingY)
|
||||
|
||||
const ultraViewBox = `${ultraX.toFixed(2)} ${ultraY.toFixed(2)} ${ultraWidth.toFixed(2)} ${ultraHeight.toFixed(2)}`
|
||||
|
||||
let ultraOptimizedSvg = svgString
|
||||
.replace(/viewBox="[^"]*"/, `viewBox="${ultraViewBox}"`)
|
||||
.replace(/<svg[^>]*width="[^"]*"/, (match) => match.replace(/width="[^"]*"/, ''))
|
||||
.replace(/<svg[^>]*height="[^"]*"/, (match) => match.replace(/height="[^"]*"/, ''))
|
||||
|
||||
console.log(`✅ Ultra-optimized SVG: ${bbox.width.toFixed(1)}×${bbox.height.toFixed(1)} content → viewBox="${ultraViewBox}"`)
|
||||
return ultraOptimizedSvg
|
||||
}
|
||||
|
||||
// Update the viewBox to crop to content bounds
|
||||
const newViewBox = `${newX.toFixed(2)} ${newY.toFixed(2)} ${newWidth.toFixed(2)} ${newHeight.toFixed(2)}`
|
||||
|
||||
// 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(`✅ SVG optimized: ${bbox.width.toFixed(1)}×${bbox.height.toFixed(1)} content → viewBox="${newViewBox}"`)
|
||||
|
||||
return optimizedSvg
|
||||
} catch (bboxError) {
|
||||
document.body.removeChild(tempDiv)
|
||||
console.warn('❌ Could not measure SVG content bounds, returning original:', bboxError)
|
||||
return svgString
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ SVG optimization failed completely, returning original:', error)
|
||||
return svgString
|
||||
}
|
||||
}
|
||||
|
||||
// Preload WASM and template without blocking - starts in background
|
||||
async function preloadTypstWasm() {
|
||||
if ($typst || isPreloading || typstInitializationPromise) return
|
||||
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
isPreloading = true
|
||||
preloadStartTime = performance.now()
|
||||
console.log('🔄 Starting background WASM and template preload...')
|
||||
|
||||
try {
|
||||
// Preload both WASM and template in parallel
|
||||
await Promise.all([
|
||||
getTypstRenderer(),
|
||||
getFlashcardsTemplate()
|
||||
])
|
||||
const loadTime = Math.round(performance.now() - (preloadStartTime || 0))
|
||||
console.log(`✅ WASM and template preloaded successfully in ${loadTime}ms - ready for instant generation!`)
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Preload failed (will retry on demand):', error)
|
||||
} finally {
|
||||
isPreloading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getTypstRenderer() {
|
||||
if ($typst) return $typst
|
||||
|
||||
// Return the existing initialization promise if one is in progress
|
||||
if (typstInitializationPromise) {
|
||||
return await typstInitializationPromise
|
||||
}
|
||||
|
||||
// Check if we're in a browser environment
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Not in browser environment')
|
||||
}
|
||||
|
||||
// Create and cache the initialization promise
|
||||
typstInitializationPromise = initializeTypstRenderer()
|
||||
|
||||
try {
|
||||
return await typstInitializationPromise
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so we can retry
|
||||
typstInitializationPromise = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeTypstRenderer() {
|
||||
console.log('🚀 Loading typst.ts WASM in browser...')
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
// Import the all-in-one typst package with timeout
|
||||
console.log('📦 Importing typst all-in-one package...')
|
||||
|
||||
const typstModule = await Promise.race([
|
||||
import('@myriaddreamin/typst-all-in-one.ts'),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('WASM module load timeout')), 30000) // 30 second timeout
|
||||
)
|
||||
]) as any
|
||||
|
||||
$typst = typstModule.$typst
|
||||
|
||||
if (!$typst) {
|
||||
throw new Error('typst.ts renderer not found in module')
|
||||
}
|
||||
|
||||
// Test the renderer with a minimal example
|
||||
console.log('🧪 Testing typst.ts renderer...')
|
||||
await $typst.svg({ mainContent: '#set page(width: 10pt, height: 10pt)\n' })
|
||||
|
||||
const loadTime = Math.round(performance.now() - startTime)
|
||||
console.log(`✅ typst.ts WASM loaded and tested successfully in ${loadTime}ms!`)
|
||||
return $typst
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load typst.ts WASM:', error)
|
||||
$typst = null
|
||||
throw new Error(`Browser typst.ts initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// We'll load the template content via an API endpoint or inline it here
|
||||
// For now, let's create a minimal template with the draw-soroban function
|
||||
|
||||
export interface SorobanConfig {
|
||||
number: number
|
||||
beadShape?: 'diamond' | 'circle' | 'square'
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
|
||||
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'
|
||||
hideInactiveBeads?: boolean
|
||||
showEmptyColumns?: boolean
|
||||
columns?: number | 'auto'
|
||||
scaleFactor?: number
|
||||
width?: string
|
||||
height?: string
|
||||
fontSize?: string
|
||||
fontFamily?: string
|
||||
transparent?: boolean
|
||||
coloredNumerals?: boolean
|
||||
enableServerFallback?: boolean
|
||||
}
|
||||
|
||||
// Cache for compiled templates to avoid recompilation
|
||||
const templateCache = new Map<string, Promise<string>>()
|
||||
|
||||
// Suspense resource for WASM loading
|
||||
class TypstResource {
|
||||
private promise: Promise<any> | null = null
|
||||
private renderer: any = null
|
||||
private error: Error | null = null
|
||||
|
||||
read() {
|
||||
if (this.error) {
|
||||
throw this.error
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
return this.renderer
|
||||
}
|
||||
|
||||
if (!this.promise) {
|
||||
this.promise = this.loadTypst()
|
||||
}
|
||||
|
||||
throw this.promise
|
||||
}
|
||||
|
||||
private async loadTypst() {
|
||||
try {
|
||||
const renderer = await getTypstRenderer()
|
||||
this.renderer = renderer
|
||||
return renderer
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error : new Error('WASM loading failed')
|
||||
throw this.error
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.promise = null
|
||||
this.renderer = null
|
||||
this.error = null
|
||||
}
|
||||
}
|
||||
|
||||
// Global resource instance
|
||||
const typstResource = new TypstResource()
|
||||
|
||||
export function resetTypstResource() {
|
||||
typstResource.reset()
|
||||
}
|
||||
|
||||
export function useTypstRenderer() {
|
||||
return typstResource.read()
|
||||
}
|
||||
|
||||
// Lazy-loaded template content
|
||||
let flashcardsTemplate: string | null = null
|
||||
let templateLoadPromise: Promise<string> | null = null
|
||||
|
||||
async function getFlashcardsTemplate(): Promise<string> {
|
||||
if (flashcardsTemplate) {
|
||||
return flashcardsTemplate
|
||||
}
|
||||
|
||||
// Return the existing promise if already loading
|
||||
if (templateLoadPromise) {
|
||||
return await templateLoadPromise
|
||||
}
|
||||
|
||||
// Create and cache the loading promise
|
||||
templateLoadPromise = loadTemplateFromAPI()
|
||||
|
||||
try {
|
||||
const template = await templateLoadPromise
|
||||
flashcardsTemplate = template
|
||||
return template
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so we can retry
|
||||
templateLoadPromise = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplateFromAPI(): Promise<string> {
|
||||
console.log('📥 Loading typst template from API...')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/typst-template')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
console.log('✅ Template loaded successfully')
|
||||
return data.template
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to load template')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch typst template:', error)
|
||||
throw new Error('Template loading failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function getTypstTemplate(config: SorobanConfig): Promise<string> {
|
||||
const template = await getFlashcardsTemplate()
|
||||
|
||||
const {
|
||||
number,
|
||||
beadShape = 'diamond',
|
||||
colorScheme = 'place-value',
|
||||
colorPalette = 'default',
|
||||
hideInactiveBeads = false,
|
||||
showEmptyColumns = false,
|
||||
columns = 'auto',
|
||||
scaleFactor = 1.0,
|
||||
width = '120pt',
|
||||
height = '160pt',
|
||||
fontSize = '48pt',
|
||||
fontFamily = 'DejaVu Sans',
|
||||
transparent = false,
|
||||
coloredNumerals = false,
|
||||
enableServerFallback = false
|
||||
} = config
|
||||
|
||||
return `
|
||||
${template}
|
||||
|
||||
#set page(
|
||||
width: ${width},
|
||||
height: ${height},
|
||||
margin: 0pt,
|
||||
fill: ${transparent ? 'none' : 'white'}
|
||||
)
|
||||
|
||||
#set text(font: "${fontFamily}", size: ${fontSize}, fallback: true)
|
||||
|
||||
#align(center + horizon)[
|
||||
#box(
|
||||
width: ${width},
|
||||
height: ${height}
|
||||
)[
|
||||
#align(center + horizon)[
|
||||
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
|
||||
#draw-soroban(
|
||||
${number},
|
||||
columns: ${columns},
|
||||
show-empty: ${showEmptyColumns},
|
||||
hide-inactive: ${hideInactiveBeads},
|
||||
bead-shape: "${beadShape}",
|
||||
color-scheme: "${colorScheme}",
|
||||
color-palette: "${colorPalette}",
|
||||
base-size: 1.0
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
`
|
||||
}
|
||||
|
||||
export async function generateSorobanSVG(config: SorobanConfig): Promise<string> {
|
||||
try {
|
||||
// Create a cache key based on the configuration
|
||||
const cacheKey = JSON.stringify(config)
|
||||
|
||||
// Check if we have a cached result
|
||||
if (templateCache.has(cacheKey)) {
|
||||
return await templateCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
// Try browser-side generation first, fallback to server if it fails
|
||||
const generationPromise = generateSVGWithFallback(config)
|
||||
|
||||
// Cache the promise to prevent duplicate generations
|
||||
templateCache.set(cacheKey, generationPromise)
|
||||
|
||||
// Clean up the cache if it gets too large (keep last 50 entries)
|
||||
if (templateCache.size > 50) {
|
||||
const entries = Array.from(templateCache.entries())
|
||||
const toKeep = entries.slice(-25) // Keep last 25
|
||||
templateCache.clear()
|
||||
toKeep.forEach(([key, value]) => templateCache.set(key, value))
|
||||
}
|
||||
|
||||
return await generationPromise
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to generate Soroban SVG with typst.ts:', error)
|
||||
throw new Error(`SVG generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Track if browser-side generation has been attempted and failed
|
||||
let browserGenerationAvailable: boolean | null = null
|
||||
|
||||
// Function to reset browser availability detection (useful for debugging)
|
||||
export function resetBrowserGenerationStatus() {
|
||||
browserGenerationAvailable = null
|
||||
$typst = null
|
||||
isLoading = false
|
||||
console.log('🔄 Reset browser generation status - will retry on next generation')
|
||||
}
|
||||
|
||||
// Export preloading utilities
|
||||
export function getWasmStatus() {
|
||||
return {
|
||||
isLoaded: !!$typst,
|
||||
isPreloading,
|
||||
isInitializing: !!typstInitializationPromise && !$typst,
|
||||
browserGenerationAvailable
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerWasmPreload() {
|
||||
if (!isPreloading && !$typst) {
|
||||
preloadTypstWasm()
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSVGWithFallback(config: SorobanConfig): Promise<string> {
|
||||
console.log('🔍 generateSVGWithFallback called for number:', config.number)
|
||||
console.log('🔍 browserGenerationAvailable status:', browserGenerationAvailable)
|
||||
console.log('🔍 enableServerFallback:', config.enableServerFallback)
|
||||
|
||||
// If we know browser generation is available, always use it
|
||||
if (browserGenerationAvailable === true) {
|
||||
console.log('🎯 Using confirmed browser-side generation')
|
||||
return await generateSVGInBrowser(config)
|
||||
}
|
||||
|
||||
// If we know browser generation is not available and server fallback is disabled, throw error
|
||||
if (browserGenerationAvailable === false && !config.enableServerFallback) {
|
||||
console.error('❌ Browser-side generation unavailable and server fallback disabled')
|
||||
throw new Error('Browser-side SVG generation failed and server fallback is disabled. Enable server fallback or fix browser WASM loading.')
|
||||
}
|
||||
|
||||
// If we know browser generation is not available, skip to server (only if fallback enabled)
|
||||
if (browserGenerationAvailable === false && config.enableServerFallback) {
|
||||
console.log('🔄 Using server fallback (browser unavailable)')
|
||||
return await generateSVGOnServer(config)
|
||||
}
|
||||
|
||||
// First attempt - try browser-side generation
|
||||
try {
|
||||
console.log('🚀 Attempting browser-side generation for number:', config.number)
|
||||
const result = await generateSVGInBrowser(config)
|
||||
browserGenerationAvailable = true
|
||||
console.log('✅ Browser-side generation successful! Will use for future requests.')
|
||||
return result
|
||||
} catch (browserError) {
|
||||
console.warn('❌ Browser-side generation failed for number:', config.number, browserError)
|
||||
browserGenerationAvailable = false
|
||||
|
||||
// Only fall back to server if explicitly enabled
|
||||
if (config.enableServerFallback) {
|
||||
try {
|
||||
console.log('🔄 Falling back to server-side generation for number:', config.number)
|
||||
return await generateSVGOnServer(config)
|
||||
} catch (serverError) {
|
||||
console.error('❌ Both browser and server generation failed for number:', config.number)
|
||||
throw new Error(`SVG generation failed: ${serverError instanceof Error ? serverError.message : 'Unknown error'}`)
|
||||
}
|
||||
} else {
|
||||
console.error('❌ Browser-side generation failed and server fallback disabled for number:', config.number)
|
||||
throw new Error(`Browser-side SVG generation failed: ${browserError instanceof Error ? browserError.message : 'Unknown error'}. Enable server fallback or fix browser WASM loading.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSVGInBrowser(config: SorobanConfig): Promise<string> {
|
||||
// Load typst.ts renderer
|
||||
const $typst = await getTypstRenderer()
|
||||
|
||||
// Get the template content
|
||||
const template = await getFlashcardsTemplate()
|
||||
|
||||
// Create the complete Typst document
|
||||
const typstContent = await getTypstTemplate(config)
|
||||
|
||||
console.log('🎨 Generating SVG in browser for number:', config.number)
|
||||
|
||||
// Generate SVG using typst.ts in the browser
|
||||
const svg = await $typst.svg({ mainContent: typstContent })
|
||||
|
||||
console.log('✅ Generated browser SVG, length:', svg.length)
|
||||
|
||||
// Optimize viewBox to crop to actual content bounds
|
||||
const optimizedSvg = optimizeSvgViewBox(svg)
|
||||
|
||||
// Process bead annotations to convert links to data attributes
|
||||
const annotatedSvg = processBeadAnnotations(optimizedSvg)
|
||||
|
||||
return annotatedSvg
|
||||
}
|
||||
|
||||
// Function to process bead annotations - converts link elements to data attributes
|
||||
function processBeadAnnotations(svg: string): string {
|
||||
console.log('🏷️ Processing bead annotations...')
|
||||
|
||||
const processedSvg = svg.replace(
|
||||
/<a[^>]*xlink:href="bead:\/\/([^"]*)"[^>]*>(.*?)<\/a>/gs,
|
||||
(match, beadId, content) => {
|
||||
// Parse the bead ID to extract metadata
|
||||
const parts = beadId.split('-')
|
||||
|
||||
let beadType = 'unknown'
|
||||
let column = '0'
|
||||
let position = ''
|
||||
let active = '0'
|
||||
|
||||
// Parse heaven beads: "heaven-col0-active1"
|
||||
if (parts[0] === 'heaven' && parts.length >= 3) {
|
||||
beadType = 'heaven'
|
||||
const colMatch = parts[1].match(/col(\d+)/)
|
||||
if (colMatch) column = colMatch[1]
|
||||
const activeMatch = parts[2].match(/active(\d+)/)
|
||||
if (activeMatch) active = activeMatch[1]
|
||||
}
|
||||
// Parse earth beads: "earth-col0-pos1-active1"
|
||||
else if (parts[0] === 'earth' && parts.length >= 4) {
|
||||
beadType = 'earth'
|
||||
const colMatch = parts[1].match(/col(\d+)/)
|
||||
if (colMatch) column = colMatch[1]
|
||||
const posMatch = parts[2].match(/pos(\d+)/)
|
||||
if (posMatch) position = posMatch[1]
|
||||
const activeMatch = parts[3].match(/active(\d+)/)
|
||||
if (activeMatch) active = activeMatch[1]
|
||||
}
|
||||
|
||||
// Construct data attributes
|
||||
const dataAttrs = `data-bead-type="${beadType}" data-bead-column="${column}"${position ? ` data-bead-position="${position}"` : ''} data-bead-active="${active}"`
|
||||
|
||||
// Add data attributes to shapes within the content and remove the link wrapper
|
||||
const processedContent = content.replace(
|
||||
/<(circle|path|rect|polygon|ellipse)([^>]*>)/g,
|
||||
`<$1 ${dataAttrs}$2`
|
||||
)
|
||||
|
||||
return processedContent
|
||||
}
|
||||
)
|
||||
|
||||
console.log('✅ Bead annotations processed')
|
||||
return processedSvg
|
||||
}
|
||||
|
||||
async function generateSVGOnServer(config: SorobanConfig): Promise<string> {
|
||||
// Fallback to server-side API generation
|
||||
const response = await fetch('/api/typst-svg', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(errorData.error || 'Server SVG generation failed')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Server SVG generation failed')
|
||||
}
|
||||
|
||||
console.log('🔄 Generated SVG on server, length:', data.svg.length)
|
||||
|
||||
// Optimize viewBox to crop to actual content bounds
|
||||
const optimizedSvg = optimizeSvgViewBox(data.svg)
|
||||
|
||||
return optimizedSvg
|
||||
}
|
||||
|
||||
export async function generateSorobanPreview(
|
||||
numbers: number[],
|
||||
config: Omit<SorobanConfig, 'number'>
|
||||
): Promise<Array<{ number: number; svg: string }>> {
|
||||
const results = await Promise.allSettled(
|
||||
numbers.map(async (number) => ({
|
||||
number,
|
||||
svg: await generateSorobanSVG({ ...config, number })
|
||||
}))
|
||||
)
|
||||
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value
|
||||
} else {
|
||||
console.error(`Failed to generate SVG for number ${numbers[index]}:`, result.reason)
|
||||
return {
|
||||
number: numbers[index],
|
||||
svg: `<svg width="200" height="300" viewBox="0 0 200 300" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="10" width="180" height="280" fill="none" stroke="#ccc" stroke-width="2"/>
|
||||
<line x1="20" y1="150" x2="180" y2="150" stroke="#ccc" stroke-width="2"/>
|
||||
<text x="100" y="160" text-anchor="middle" font-size="24" fill="#666">Generation Error</text>
|
||||
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${numbers[index]}</text>
|
||||
</svg>`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user