From be6fb1a881b983f9830d36c079b7b41f35153b8a Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Tue, 7 Oct 2025 10:41:36 -0500 Subject: [PATCH] feat: remove typst-related code and routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/app/api/typst-svg/route.ts | 154 ---- apps/web/src/app/api/typst-template/route.ts | 25 - apps/web/src/app/test-typst/page.tsx | 260 ------- apps/web/src/app/typst-gallery/page.tsx | 232 ------ apps/web/src/app/typst-playground/page.tsx | 335 --------- apps/web/src/components/TypstSoroban.tsx | 405 ----------- apps/web/src/lib/typst-soroban.ts | 703 ------------------- 7 files changed, 2114 deletions(-) delete mode 100644 apps/web/src/app/api/typst-svg/route.ts delete mode 100644 apps/web/src/app/api/typst-template/route.ts delete mode 100644 apps/web/src/app/test-typst/page.tsx delete mode 100644 apps/web/src/app/typst-gallery/page.tsx delete mode 100644 apps/web/src/app/typst-playground/page.tsx delete mode 100644 apps/web/src/components/TypstSoroban.tsx delete mode 100644 apps/web/src/lib/typst-soroban.ts diff --git a/apps/web/src/app/api/typst-svg/route.ts b/apps/web/src/app/api/typst-svg/route.ts deleted file mode 100644 index 2f92cf7e..00000000 --- a/apps/web/src/app/api/typst-svg/route.ts +++ /dev/null @@ -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 { - 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' - }) -} \ No newline at end of file diff --git a/apps/web/src/app/api/typst-template/route.ts b/apps/web/src/app/api/typst-template/route.ts deleted file mode 100644 index d1bead84..00000000 --- a/apps/web/src/app/api/typst-template/route.ts +++ /dev/null @@ -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 } - ) - } -} \ No newline at end of file diff --git a/apps/web/src/app/test-typst/page.tsx b/apps/web/src/app/test-typst/page.tsx deleted file mode 100644 index 7da8de4f..00000000 --- a/apps/web/src/app/test-typst/page.tsx +++ /dev/null @@ -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 ( -
-
-
- {/* Header */} -
-

- Typst.ts Integration Test -

-

- Testing browser-only Soroban SVG generation (no server fallback) -

-
-
- Generated: {generationCount} -
-
0 ? 'red.100' : 'gray.100', - color: errorCount > 0 ? 'red.700' : 'gray.700', - rounded: 'full', - fontSize: 'sm' - })}> - Errors: {errorCount} -
-
-
- - {/* Number Selector */} -
-

- Select Number to Generate -

-
- {testNumbers.map((num) => ( - - ))} - 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" - /> -
-
- - {/* Generated Soroban */} -
-

- Generated Soroban (Number: {selectedNumber}) -

-
- setGenerationCount(prev => prev + 1)} - onError={() => setErrorCount(prev => prev + 1)} - className={css({ - maxW: 'sm', - maxH: '400px' - })} - /> -
-
- - {/* Test Grid */} -
-

- Test Grid (Multiple Numbers) -

-
- {testNumbers.map((num, index) => ( -
-
- {num} - {index > 1 && lazy} -
-
- 1} // Make the last 3 components lazy - onSuccess={() => setGenerationCount(prev => prev + 1)} - onError={() => setErrorCount(prev => prev + 1)} - className={css({ w: 'full', h: 'full' })} - /> -
-
- ))} -
-
- - {/* Info */} -
-

- About This Test -

-
-

- โ€ข This page tests the typst.ts integration for generating Soroban SVGs directly in the browser -

-

- โ€ข No Python bridge required - everything runs natively in TypeScript/WebAssembly -

-

- โ€ข WASM preloading starts automatically in background for better performance -

-

- โ€ข Lazy loading demo: Last 3 grid items show placeholders until clicked (progressive enhancement) -

-

- โ€ข Global abacus display settings are automatically applied -

-
-
-
-
-
- ) -} \ No newline at end of file diff --git a/apps/web/src/app/typst-gallery/page.tsx b/apps/web/src/app/typst-gallery/page.tsx deleted file mode 100644 index 0c573d71..00000000 --- a/apps/web/src/app/typst-gallery/page.tsx +++ /dev/null @@ -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>({}); - const [loading, setLoading] = useState>({}); - const [errors, setErrors] = useState>({}); - - 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 ( -
-
-
-

- ๐Ÿงฎ Soroban Template Gallery -

-

- Interactive preview of soroban template renderings with different configurations -

- -
- -
- {examples.map((example) => ( -
-
-
-
-

- {example.title} -

-

- {example.description} -

-
- -
- - {/* Configuration Details */} -
-
Configuration:
-
-
Number: {example.number}
- {Object.entries(example.config).map(([key, value]) => ( -
- {key}: {String(value)} -
- ))} -
-
- - {/* Rendering Area */} -
- {loading[example.id] && ( -
-
๐Ÿ”„
-
Generating...
-
- )} - - {errors[example.id] && ( -
-
โŒ
-
{errors[example.id]}
-
- )} - - {renderings[example.id] && !loading[example.id] && ( -
- )} - - {!loading[example.id] && !errors[example.id] && !renderings[example.id] && ( -
-
๐Ÿงฎ
-
Click generate to render
-
- )} -
-
-
- ))} -
- - {/* Footer */} -
-

- Powered by @soroban/templates and typst.ts -

-

- This gallery helps visualize different template configurations before implementation -

-
-
-
- ); -} \ No newline at end of file diff --git a/apps/web/src/app/typst-playground/page.tsx b/apps/web/src/app/typst-playground/page.tsx deleted file mode 100644 index d3a9d459..00000000 --- a/apps/web/src/app/typst-playground/page.tsx +++ /dev/null @@ -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> = { - '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(defaultConfig); - const [svg, setSvg] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [lastGenerated, setLastGenerated] = useState(''); - - 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 ( -
-
-
-

- ๐Ÿ› ๏ธ Soroban Template Playground -

-

- Interactive tool for testing soroban template configurations in real-time -

-
- -
- {/* Configuration Panel */} -
-
-

Configuration

- - {/* Presets */} -
- -
- {Object.keys(presets).map((preset) => ( - - ))} -
-
- - {/* Number Input */} -
- - 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" - /> -
- - {/* Bead Shape */} -
- - -
- - {/* Color Scheme */} -
- - -
- - {/* Color Palette */} -
- - -
- - {/* Base Size */} -
- - updateConfig('base_size', parseFloat(e.target.value))} - className="w-full" - /> -
- - {/* Columns */} -
- - -
- - {/* Toggles */} -
- -
- -
- -
- - {/* Generate Button */} - - - {lastGenerated && ( -

- Last updated: {lastGenerated} -

- )} -
-
- - {/* Preview Panel */} -
-
-
-

Live Preview

-
- Number: {config.number} -
-
- - {/* Preview Area */} -
- {loading && ( -
-
๐Ÿ”„
-
Generating soroban...
-
- )} - - {error && ( -
-
โŒ
-
Generation Error
-
{error}
-
- )} - - {svg && !loading && ( -
- )} - - {!svg && !loading && !error && ( -
-
๐Ÿงฎ
-
Configure and Generate
-
Adjust settings and click generate to see your soroban
-
- )} -
- - {/* Configuration Summary */} - {svg && ( -
-

Current Configuration

-
-
- Shape:{' '} - {config.bead_shape} -
-
- Colors:{' '} - {config.color_scheme} -
-
- Scale:{' '} - {config.base_size}x -
-
- Columns:{' '} - {config.columns} -
-
-
- )} -
-
-
-
-
- ); -} \ No newline at end of file diff --git a/apps/web/src/components/TypstSoroban.tsx b/apps/web/src/components/TypstSoroban.tsx deleted file mode 100644 index 37a4f07f..00000000 --- a/apps/web/src/components/TypstSoroban.tsx +++ /dev/null @@ -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(null) - const [isLoading, setIsLoading] = useState(!lazy) // Don't start loading if lazy - const [error, setError] = useState(null) - const [shouldLoad, setShouldLoad] = useState(!lazy) // Control when loading starts - const globalConfig = useAbacusConfig() - - const abortControllerRef = useRef(null) - const currentConfigRef = useRef(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 ( -
-
-
- {wasmStatus.isLoaded ? '๐Ÿš€' : '๐Ÿงฎ'} -
-
-
- ) - } - - if (isLoading) { - return ( -
-
-
-
-
- ) - } - - if (error) { - return ( -
-
-
โš ๏ธ
-
-
- ) - } - - if (!svg) { - return ( -
-
-
- No SVG generated -
-
-
- ) - } - - return ( -
-
-
- ) -} - -// Optional: Create a hook for easier usage -export function useTypstSoroban(config: SorobanConfig) { - const [svg, setSvg] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(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(' | 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(/]*width="[^"]*"/, (match) => match.replace(/width="[^"]*"/, '')) - .replace(/]*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(/]*width="[^"]*"/, (match) => match.replace(/width="[^"]*"/, '')) - .replace(/]*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>() - -// Suspense resource for WASM loading -class TypstResource { - private promise: Promise | 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 | null = null - -async function getFlashcardsTemplate(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - // 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( - /]*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 { - // 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 -): Promise> { - 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: ` - - - Generation Error - ${numbers[index]} - ` - } - } - }) -} \ No newline at end of file