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:
Thomas Hallock
2025-10-07 10:41:36 -05:00
parent e157bbff43
commit be6fb1a881
7 changed files with 0 additions and 2114 deletions

View File

@@ -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'
})
}

View File

@@ -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 }
)
}
}

View File

@@ -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>
)
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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
}
}

View File

@@ -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>`
}
}
})
}