concept-collection / ans-visualizer
initial
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 81094866575e Browse files
24 changed files+6994−0
.gitignoreadded+24−0View file
@@ -0,0 +1,24 @@
1+# Logs
2+logs
3+*.log
4+npm-debug.log*
5+yarn-debug.log*
6+yarn-error.log*
7+pnpm-debug.log*
8+lerna-debug.log*
9+
10+node_modules
11+dist
12+dist-ssr
13+*.local
14+
15+# Editor directories and files
16+.vscode/*
17+!.vscode/extensions.json
18+.idea
19+.DS_Store
20+*.suo
21+*.ntvs*
22+*.njsproj
23+*.sln
24+*.sw?
README.mdadded+73−0View file
@@ -0,0 +1,73 @@
1+# React + TypeScript + Vite
2+
3+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4+
5+Currently, two official plugins are available:
6+
7+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9+
10+## React Compiler
11+
12+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13+
14+## Expanding the ESLint configuration
15+
16+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17+
18+```js
19+export default defineConfig([
20+ globalIgnores(['dist']),
21+ {
22+ files: ['**/*.{ts,tsx}'],
23+ extends: [
24+ // Other configs...
25+
26+ // Remove tseslint.configs.recommended and replace with this
27+ tseslint.configs.recommendedTypeChecked,
28+ // Alternatively, use this for stricter rules
29+ tseslint.configs.strictTypeChecked,
30+ // Optionally, add this for stylistic rules
31+ tseslint.configs.stylisticTypeChecked,
32+
33+ // Other configs...
34+ ],
35+ languageOptions: {
36+ parserOptions: {
37+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38+ tsconfigRootDir: import.meta.dirname,
39+ },
40+ // other options...
41+ },
42+ },
43+])
44+```
45+
46+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47+
48+```js
49+// eslint.config.js
50+import reactX from 'eslint-plugin-react-x'
51+import reactDom from 'eslint-plugin-react-dom'
52+
53+export default defineConfig([
54+ globalIgnores(['dist']),
55+ {
56+ files: ['**/*.{ts,tsx}'],
57+ extends: [
58+ // Other configs...
59+ // Enable lint rules for React
60+ reactX.configs['recommended-typescript'],
61+ // Enable lint rules for React DOM
62+ reactDom.configs.recommended,
63+ ],
64+ languageOptions: {
65+ parserOptions: {
66+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67+ tsconfigRootDir: import.meta.dirname,
68+ },
69+ // other options...
70+ },
71+ },
72+])
73+```
eslint.config.jsadded+23−0View file
@@ -0,0 +1,23 @@
1+import js from '@eslint/js'
2+import globals from 'globals'
3+import reactHooks from 'eslint-plugin-react-hooks'
4+import reactRefresh from 'eslint-plugin-react-refresh'
5+import tseslint from 'typescript-eslint'
6+import { defineConfig, globalIgnores } from 'eslint/config'
7+
8+export default defineConfig([
9+ globalIgnores(['dist']),
10+ {
11+ files: ['**/*.{ts,tsx}'],
12+ extends: [
13+ js.configs.recommended,
14+ tseslint.configs.recommended,
15+ reactHooks.configs['recommended-latest'],
16+ reactRefresh.configs.vite,
17+ ],
18+ languageOptions: {
19+ ecmaVersion: 2020,
20+ globals: globals.browser,
21+ },
22+ },
23+])
index.htmladded+17−0View file
@@ -0,0 +1,17 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+ <meta name="description" content="Illustration of the Asymmetric Numeral Systems algorithm" />
8+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10+ <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" />
11+ <title>ANS Visualizer</title>
12+ </head>
13+ <body>
14+ <div id="root"></div>
15+ <script type="module" src="/src/main.tsx"></script>
16+ </body>
17+</html>
package-lock.jsonadded+5610−0View file
This diff is 5,615 lines long and is not shown.
package.jsonadded+37−0View file
@@ -0,0 +1,37 @@
1+{
2+ "name": "ans-visualizer",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc -b && vite build",
9+ "lint": "eslint .",
10+ "preview": "vite preview"
11+ },
12+ "dependencies": {
13+ "@emotion/react": "^11.14.0",
14+ "@emotion/styled": "^11.14.1",
15+ "@mui/icons-material": "^7.3.4",
16+ "@mui/material": "^7.3.4",
17+ "react": "^19.1.1",
18+ "react-dom": "^19.1.1",
19+ "react-markdown": "^10.1.0",
20+ "rehype-katex": "^7.0.1",
21+ "remark-math": "^6.0.0"
22+ },
23+ "devDependencies": {
24+ "@eslint/js": "^9.36.0",
25+ "@types/node": "^24.6.0",
26+ "@types/react": "^19.1.16",
27+ "@types/react-dom": "^19.1.9",
28+ "@vitejs/plugin-react": "^5.0.4",
29+ "eslint": "^9.36.0",
30+ "eslint-plugin-react-hooks": "^5.2.0",
31+ "eslint-plugin-react-refresh": "^0.4.22",
32+ "globals": "^16.4.0",
33+ "typescript": "~5.9.3",
34+ "typescript-eslint": "^8.45.0",
35+ "vite": "^7.1.7"
36+ }
37+}
public/vite.svgadded+1−0View file
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file
src/App.tsxadded+94−0View file
@@ -0,0 +1,94 @@
1+import { useState, useEffect } from 'react';
2+import { Container, Typography, Box, CssBaseline, ThemeProvider, createTheme, Paper } from '@mui/material';
3+import ReactMarkdown from 'react-markdown';
4+import remarkMath from 'remark-math';
5+import rehypeKatex from 'rehype-katex';
6+import 'katex/dist/katex.min.css';
7+import MobileWarning from './components/MobileWarning';
8+import FrequencyInput from './components/FrequencyInput';
9+import StateGrid from './components/StateGrid';
10+import type { Symbol } from './types';
11+import { isMobileDevice } from './utils/deviceDetection';
12+import { getDefaultColors } from './utils/ansLogic';
13+import explanationText from './explanation.md?raw';
14+
15+const theme = createTheme({
16+ palette: {
17+ mode: 'light',
18+ primary: {
19+ main: '#1976d2',
20+ },
21+ },
22+});
23+
24+function App() {
25+ const [isMobile, setIsMobile] = useState(false);
26+ const [symbols, setSymbols] = useState<Symbol[]>([]);
27+
28+ useEffect(() => {
29+ // Check if mobile
30+ setIsMobile(isMobileDevice());
31+
32+ // Initialize with default symbols: A-1, B-1, C-1
33+ const defaultColors = getDefaultColors();
34+ const defaultSymbols: Symbol[] = [
35+ { name: 'A', frequency: 3, color: defaultColors[0] },
36+ { name: 'B', frequency: 2, color: defaultColors[1] },
37+ { name: 'C', frequency: 1, color: defaultColors[2] },
38+ ];
39+ setSymbols(defaultSymbols);
40+
41+ // Re-check on resize
42+ const handleResize = () => {
43+ setIsMobile(isMobileDevice());
44+ };
45+ window.addEventListener('resize', handleResize);
46+ return () => window.removeEventListener('resize', handleResize);
47+ }, []);
48+
49+ if (isMobile) {
50+ return (
51+ <ThemeProvider theme={theme}>
52+ <CssBaseline />
53+ <MobileWarning />
54+ </ThemeProvider>
55+ );
56+ }
57+
58+ return (
59+ <ThemeProvider theme={theme}>
60+ <CssBaseline />
61+ <Container maxWidth="xl" sx={{ py: 2 }}>
62+ <Box sx={{ mb: 2 }}>
63+ <Typography variant="h4" component="h1" gutterBottom>
64+ ANS Visualizer
65+ </Typography>
66+ <Typography variant="body2" color="text.secondary">
67+ Illustration of the Asymmetric Numeral Systems algorithm
68+ </Typography>
69+ </Box>
70+
71+ <Box sx={{ display: 'flex', gap: 3 }}>
72+ <Box sx={{ flex: 1 }}>
73+ <FrequencyInput symbols={symbols} onSymbolsChange={setSymbols} />
74+
75+ <Paper sx={{ p: 2, mt: 3, maxHeight: '600px', overflowY: 'auto' }}>
76+ <ReactMarkdown
77+ remarkPlugins={[remarkMath]}
78+ rehypePlugins={[rehypeKatex]}
79+ >
80+ {explanationText}
81+ </ReactMarkdown>
82+ </Paper>
83+ </Box>
84+
85+ <Box sx={{ flex: 1 }}>
86+ <StateGrid symbols={symbols} />
87+ </Box>
88+ </Box>
89+ </Container>
90+ </ThemeProvider>
91+ );
92+}
93+
94+export default App;
src/assets/react.svgadded+1−0View file
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
\ No newline at end of file
src/components/FrequencyInput.tsxadded+96−0View file
@@ -0,0 +1,96 @@
1+import { Box, TextField, Button, IconButton, Typography, Paper } from '@mui/material';
2+import AddIcon from '@mui/icons-material/Add';
3+import DeleteIcon from '@mui/icons-material/Delete';
4+import type { Symbol } from '../types';
5+
6+interface FrequencyInputProps {
7+ symbols: Symbol[];
8+ onSymbolsChange: (symbols: Symbol[]) => void;
9+}
10+
11+export default function FrequencyInput({ symbols, onSymbolsChange }: FrequencyInputProps) {
12+ const handleAddSymbol = () => {
13+ const nextLetter = String.fromCharCode(65 + symbols.length); // A, B, C, ...
14+ const defaultColors = ['#1976d2', '#dc004e', '#9c27b0', '#f57c00', '#388e3c', '#d32f2f', '#0097a7', '#7b1fa2', '#c2185b', '#5d4037'];
15+ const newSymbol: Symbol = {
16+ name: nextLetter,
17+ frequency: 1,
18+ color: defaultColors[symbols.length % defaultColors.length],
19+ };
20+ onSymbolsChange([...symbols, newSymbol]);
21+ };
22+
23+ const handleRemoveSymbol = (index: number) => {
24+ const newSymbols = symbols.filter((_, i) => i !== index);
25+ onSymbolsChange(newSymbols);
26+ };
27+
28+ const handleFrequencyChange = (index: number, value: string) => {
29+ const frequency = parseInt(value) || 1;
30+ const newSymbols = [...symbols];
31+ newSymbols[index] = { ...newSymbols[index], frequency: Math.max(1, frequency) };
32+ onSymbolsChange(newSymbols);
33+ };
34+
35+ const totalL = symbols.reduce((sum, s) => sum + s.frequency, 0);
36+
37+ return (
38+ <Paper elevation={2} sx={{ p: 2, mb: 2 }}>
39+ <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600 }}>
40+ Symbol Frequencies
41+ </Typography>
42+ <Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
43+ Configure the relative frequencies of each symbol. L = {totalL}
44+ </Typography>
45+
46+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
47+ {symbols.map((symbol, index) => (
48+ <Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
49+ <Box
50+ sx={{
51+ width: 40,
52+ height: 40,
53+ backgroundColor: symbol.color,
54+ borderRadius: 1,
55+ display: 'flex',
56+ alignItems: 'center',
57+ justifyContent: 'center',
58+ color: 'white',
59+ fontWeight: 'bold',
60+ }}
61+ >
62+ {symbol.name}
63+ </Box>
64+ <TextField
65+ label="Frequency"
66+ type="number"
67+ value={symbol.frequency}
68+ onChange={(e) => handleFrequencyChange(index, e.target.value)}
69+ size="small"
70+ inputProps={{ min: 1 }}
71+ sx={{ width: 120 }}
72+ />
73+ <IconButton
74+ onClick={() => handleRemoveSymbol(index)}
75+ disabled={symbols.length <= 1}
76+ color="error"
77+ size="small"
78+ >
79+ <DeleteIcon />
80+ </IconButton>
81+ </Box>
82+ ))}
83+
84+ <Button
85+ variant="outlined"
86+ startIcon={<AddIcon />}
87+ onClick={handleAddSymbol}
88+ disabled={symbols.length >= 26}
89+ sx={{ alignSelf: 'flex-start' }}
90+ >
91+ Add Symbol
92+ </Button>
93+ </Box>
94+ </Paper>
95+ );
96+}
src/components/MobileWarning.tsxadded+31−0View file
@@ -0,0 +1,31 @@
1+import { Box, Typography } from '@mui/material';
2+import DesktopWindowsIcon from '@mui/icons-material/DesktopWindows';
3+
4+export default function MobileWarning() {
5+ return (
6+ <Box
7+ sx={{
8+ position: 'fixed',
9+ top: 0,
10+ left: 0,
11+ width: '100vw',
12+ height: '100vh',
13+ backgroundColor: 'background.default',
14+ display: 'flex',
15+ flexDirection: 'column',
16+ alignItems: 'center',
17+ justifyContent: 'center',
18+ padding: 3,
19+ zIndex: 9999,
20+ }}
21+ >
22+ <DesktopWindowsIcon sx={{ fontSize: 80, color: 'primary.main', mb: 3 }} />
23+ <Typography variant="h5" align="center" gutterBottom>
24+ Desktop Required
25+ </Typography>
26+ <Typography variant="body1" align="center" color="text.secondary">
27+ Please view this visualization on a desktop computer to fully experience the ANS algorithm.
28+ </Typography>
29+ </Box>
30+ );
31+}
src/components/StateBox.tsxadded+49−0View file
@@ -0,0 +1,49 @@
1+import { Box, Typography } from '@mui/material';
2+import type { StateInfo } from '../types';
3+
4+interface StateBoxProps {
5+ state: StateInfo;
6+ size: number;
7+ isLastInGroup?: boolean;
8+ onHover?: (stateIndex: number) => void;
9+ onLeave?: () => void;
10+}
11+
12+export default function StateBox({ state, size, isLastInGroup = false, onHover, onLeave }: StateBoxProps) {
13+ const boxHeight = Math.floor(size * 0.6); // Height is 60% of width
14+
15+ return (
16+ <Box
17+ onMouseEnter={() => onHover?.(state.index)}
18+ onMouseLeave={() => onLeave?.()}
19+ sx={{
20+ width: size,
21+ height: boxHeight,
22+ backgroundColor: state.symbol.color,
23+ display: 'flex',
24+ flexDirection: 'column',
25+ alignItems: 'center',
26+ justifyContent: 'center',
27+ border: '1px solid rgba(0, 0, 0, 0.1)',
28+ cursor: 'pointer',
29+ marginRight: isLastInGroup ? '6px' : 0,
30+ '&:hover': {
31+ boxShadow: '0 0 0 2px rgba(0, 0, 0, 0.3)',
32+ zIndex: 1,
33+ },
34+ }}
35+ >
36+ <Typography
37+ variant="caption"
38+ sx={{
39+ color: 'white',
40+ fontWeight: 'bold',
41+ fontSize: size > 20 ? '0.75rem' : '0.65rem',
42+ textShadow: '0 1px 2px rgba(0, 0, 0, 0.5)',
43+ }}
44+ >
45+ {state.symbol.name}
46+ </Typography>
47+ </Box>
48+ );
49+}
src/components/StateGrid.tsxadded+318−0View file
@@ -0,0 +1,318 @@
1+import { Box, Paper, Typography, TextField } from "@mui/material";
2+import { useState, useEffect, useMemo, useRef, useCallback } from "react";
3+import type { Symbol } from "../types";
4+import {
5+ generateStatePattern,
6+ calculateL,
7+ calculateEdgeChain,
8+ calculateForwardEdges,
9+} from "../utils/ansLogic";
10+import {
11+ clearCanvas,
12+ drawStateBox,
13+ drawEdge,
14+ getBoxAtPosition,
15+ type RenderConfig,
16+} from "../utils/canvasRenderer";
17+
18+interface StateGridProps {
19+ symbols: Symbol[];
20+}
21+
22+export default function StateGrid({ symbols }: StateGridProps) {
23+ const canvasRef = useRef<HTMLCanvasElement>(null);
24+ const [containerElement, setContainerElement] =
25+ useState<HTMLDivElement | null>(null);
26+ const [boxSize, setBoxSize] = useState(60);
27+ const [boxesPerRow, setBoxesPerRow] = useState(0);
28+ const [hoveredState, setHoveredState] = useState<number | null>(null);
29+ const [numLeadingAs, setNumLeadingAs] = useState(0);
30+
31+ const L = useMemo(() => calculateL(symbols), [symbols]);
32+ const maxStates = 5000; // Show first 5000 states
33+ const states = useMemo(
34+ () => generateStatePattern(symbols, maxStates),
35+ [symbols, maxStates]
36+ );
37+
38+
39+ // Calculate edge chain on demand for hovered state
40+ const edgeChain = useMemo(() => {
41+ if (hoveredState === null) return [];
42+ console.log("Calculating edge chain for state:", hoveredState);
43+ return calculateEdgeChain(hoveredState, symbols, numLeadingAs);
44+ }, [hoveredState, symbols, numLeadingAs]);
45+
46+ // Calculate forward edges on demand for hovered state
47+ const forwardEdges = useMemo(() => {
48+ if (hoveredState === null) return [];
49+ console.log("Calculating forward edges for state:", hoveredState);
50+ return calculateForwardEdges(hoveredState, symbols, maxStates);
51+ }, [hoveredState, symbols, maxStates]);
52+
53+ // Calculate canvas dimensions
54+ const canvasWidth = useMemo(() => {
55+ if (!containerElement || boxesPerRow === 0) return 0;
56+ return containerElement.clientWidth;
57+ }, [containerElement, boxesPerRow]);
58+
59+ const canvasHeight = useMemo(() => {
60+ if (boxesPerRow === 0) return 0;
61+ const numRows = Math.ceil(states.length / boxesPerRow);
62+ const boxHeight = Math.floor(boxSize * 0.6);
63+ return numRows * boxHeight;
64+ }, [states.length, boxesPerRow, boxSize]);
65+
66+ // Update layout based on container size
67+ useEffect(() => {
68+ const updateLayout = () => {
69+ if (!containerElement) return;
70+
71+ const containerWidth = containerElement.clientWidth;
72+ if (containerWidth === 0) return; // Container not ready yet
73+
74+ const desiredBoxSize = 24;
75+ const gapSize = 6; // Space between groups
76+
77+ // Calculate how many complete groups can fit
78+ // Each group has L boxes, and each group (except the last on a row) has a gap after it
79+ let numGroups = 1;
80+ while (true) {
81+ const totalWidth =
82+ numGroups * L * desiredBoxSize + (numGroups - 1) * gapSize;
83+ if (totalWidth > containerWidth) break;
84+ numGroups++;
85+ }
86+ numGroups = Math.max(1, numGroups - 1);
87+
88+ const adjustedBoxesPerRow = numGroups * L;
89+
90+ // Calculate actual box size to fill the width perfectly
91+ const totalGapWidth = (numGroups - 1) * gapSize;
92+ const availableWidthForBoxes = containerWidth - totalGapWidth;
93+ const actualBoxSize = Math.floor(
94+ availableWidthForBoxes / adjustedBoxesPerRow
95+ );
96+
97+ setBoxesPerRow(adjustedBoxesPerRow);
98+ setBoxSize(actualBoxSize);
99+ };
100+
101+ updateLayout();
102+
103+ window.addEventListener("resize", updateLayout);
104+ return () => {
105+ window.removeEventListener("resize", updateLayout);
106+ };
107+ }, [L, containerElement]);
108+
109+ // Render canvas whenever state changes
110+ useEffect(() => {
111+ const canvas = canvasRef.current;
112+ if (!canvas || boxesPerRow === 0) return;
113+
114+ const ctx = canvas.getContext("2d");
115+ if (!ctx) return;
116+
117+ // Set canvas resolution
118+ const dpr = window.devicePixelRatio || 1;
119+ canvas.width = canvasWidth * dpr;
120+ canvas.height = canvasHeight * dpr;
121+ canvas.style.width = `${canvasWidth}px`;
122+ canvas.style.height = `${canvasHeight}px`;
123+ ctx.scale(dpr, dpr);
124+
125+ // Clear canvas
126+ clearCanvas(ctx, canvasWidth, canvasHeight);
127+
128+ // Create render config
129+ const config: RenderConfig = {
130+ boxSize,
131+ boxesPerRow,
132+ L,
133+ canvasWidth,
134+ canvasHeight,
135+ };
136+
137+ // Draw all state boxes
138+ states.forEach((state) => {
139+ const isHovered = state.index === hoveredState;
140+ drawStateBox(ctx, state, config, isHovered);
141+ });
142+
143+ // Draw backward edges (incoming) - black with arrow pointing to hovered state
144+ if (edgeChain.length > 1) {
145+ edgeChain.slice(0, -1).forEach((fromState, idx) => {
146+ const toState = edgeChain[idx + 1];
147+ // Draw from toState to fromState so arrow points to hovered
148+ drawEdge(ctx, toState, fromState, config, "rgba(0, 0, 0, 0.85)", 3);
149+ });
150+ }
151+
152+ // Draw forward edges (outgoing) - blue with arrow pointing away from hovered state
153+ if (forwardEdges.length > 0 && hoveredState !== null) {
154+ forwardEdges.forEach((edge) => {
155+ drawEdge(
156+ ctx,
157+ hoveredState,
158+ edge.toState,
159+ config,
160+ "rgba(33, 150, 243, 0.85)",
161+ 3
162+ );
163+ });
164+ }
165+ }, [
166+ states,
167+ boxSize,
168+ boxesPerRow,
169+ L,
170+ canvasWidth,
171+ canvasHeight,
172+ hoveredState,
173+ edgeChain,
174+ forwardEdges,
175+ ]);
176+
177+ // Handle mouse move on canvas
178+ const handleMouseMove = useCallback(
179+ (e: React.MouseEvent<HTMLCanvasElement>) => {
180+ const canvas = canvasRef.current;
181+ if (!canvas || boxesPerRow === 0) return;
182+
183+ const rect = canvas.getBoundingClientRect();
184+ const mouseX = e.clientX - rect.left;
185+ const mouseY = e.clientY - rect.top;
186+
187+ const config: RenderConfig = {
188+ boxSize,
189+ boxesPerRow,
190+ L,
191+ canvasWidth,
192+ canvasHeight,
193+ };
194+
195+ const boxIndex = getBoxAtPosition(
196+ mouseX,
197+ mouseY,
198+ states.length,
199+ config
200+ );
201+ setHoveredState(boxIndex);
202+ },
203+ [boxSize, boxesPerRow, L, canvasWidth, canvasHeight, states.length]
204+ );
205+
206+ // Handle mouse leave
207+ const handleMouseLeave = useCallback(() => {
208+ setHoveredState(null);
209+ }, []);
210+
211+ if (symbols.length === 0) {
212+ return (
213+ <Paper elevation={2} sx={{ p: 3 }}>
214+ <Typography color="text.secondary">
215+ Add at least one symbol to see the state visualization.
216+ </Typography>
217+ </Paper>
218+ );
219+ }
220+
221+ if (boxesPerRow === 0) {
222+ return (
223+ <Paper elevation={2} sx={{ p: 3 }}>
224+ <Typography variant="h6" gutterBottom>
225+ State Visualization
226+ </Typography>
227+ <Box
228+ ref={(elmt: HTMLDivElement | null) => setContainerElement(elmt)}
229+ sx={{
230+ border: "1px solid",
231+ borderColor: "divider",
232+ backgroundColor: "grey.50",
233+ minHeight: 100,
234+ display: "flex",
235+ alignItems: "center",
236+ justifyContent: "center",
237+ }}
238+ >
239+ <Typography color="text.secondary" sx={{ p: 2 }}>
240+ Loading state visualization...
241+ </Typography>
242+ </Box>
243+ </Paper>
244+ );
245+ }
246+
247+ return (
248+ <Paper elevation={2} sx={{ p: 2 }}>
249+ <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600 }}>
250+ State Visualization
251+ </Typography>
252+ <Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
253+ Each box represents a state (integer).
254+ </Typography>
255+
256+ <Box sx={{ mb: 1.5 }}>
257+ <TextField
258+ label="Number of Leading A's"
259+ type="number"
260+ value={numLeadingAs}
261+ onChange={(e) => {
262+ const value = parseInt(e.target.value, 10);
263+ if (!isNaN(value) && value >= 0) {
264+ setNumLeadingAs(value);
265+ }
266+ }}
267+ size="small"
268+ inputProps={{ min: 0, step: 1 }}
269+ sx={{ width: 200 }}
270+ />
271+ </Box>
272+
273+ <Box sx={{ mb: 1.5, p: 1.5, backgroundColor: "grey.100", borderRadius: 1, height: 45, overflow: "hidden" }}>
274+ {hoveredState !== null ? (
275+ <Typography variant="body2">
276+ <Box component="span">
277+ <span style={{ fontWeight: "bold" }}>State:</span> {hoveredState}
278+ </Box>{" | "}
279+ <Box
280+ component="span"
281+ sx={{ fontWeight: "medium", fontFamily: "monospace" }}
282+ >
283+ <span style={{ fontWeight: "bold" }}>Encoded sequence:</span> {edgeChain
284+ .slice()
285+ .reverse()
286+ .map((stateIdx) => states[stateIdx]?.symbol.name || "?")
287+ .join(" → ")}
288+ </Box>
289+ </Typography>
290+ ) : (
291+ <Typography variant="body2" color="text.secondary" sx={{ fontStyle: "italic" }}>
292+ Hover over a state to see its details
293+ </Typography>
294+ )}
295+ </Box>
296+
297+ <Box
298+ ref={(elmt: HTMLDivElement | null) => setContainerElement(elmt)}
299+ sx={{
300+ border: "1px solid",
301+ borderColor: "divider",
302+ backgroundColor: "grey.50",
303+ position: "relative",
304+ }}
305+ >
306+ <canvas
307+ ref={canvasRef}
308+ onMouseMove={handleMouseMove}
309+ onMouseLeave={handleMouseLeave}
310+ style={{
311+ display: "block",
312+ cursor: "pointer",
313+ }}
314+ />
315+ </Box>
316+ </Paper>
317+ );
318+}
src/explanation.mdadded+134−0View file
@@ -0,0 +1,134 @@
1+## Explanation of Asymmetric Numeral Systems (ANS)
2+
3+The core idea of ANS is to encode a sequence of symbols into a single integer state, with each symbol affecting the state in a way that reflects its probability of occurrence. This encoding is reversible, allowing us to recover the original sequence from the coded integer.
4+
5+Suppose that $\{0, 1, ..., S-1\}$ are the symbols we want to encode (in this UI these are denoted as $A, B, C, ...$), and assume that they occur with relative frequencies $f_0, ..., f_{S-1}$ (positive integers). The goal is to encode a sequence of symbols $(s_1, s_2, ..., s_k)$ into a single integer state $x$.
6+
7+We assume that the symbols are sampled randomly and independently of one another. Let
8+
9+$$
10+L = \sum_{i=0}^{S-1} f_i
11+$$
12+
13+be the sum of the symbol frequencies.
14+
15+## ANS Encoding/Decoding
16+
17+### The Symbol Table
18+To encode our symbols efficiently, we need a way to map between states and symbols that reflects their frequencies. We accomplish this using an infinite table $T$ that maps natural numbers to symbols: $T: \mathbb{N} \to \{0,...,S-1\}$
19+
20+We construct this table with a periodic pattern where:
21+- Each period of length $L$ consists of consecutive blocks:
22+ * $f_0$ occurrences of symbol 0 (or A)
23+ * $f_1$ occurrences of symbol 1 (or B)
24+ * ...
25+ * $f_{S-1}$ occurrences of symbol $S-1$
26+- This pattern repeats every $L$ positions
27+- For any position $n$, $T(n) = T(n \text{ mod } L)$
28+- Define $C_s = \sum_{i=0}^{s-1} f_i$ as the cumulative frequency (start position of symbol $s$ in each period)
29+
30+### The Encoding/Decoding Process
31+
32+The encoding process works by maintaining a state value $x$ that evolves as we process each symbol. Starting from state $x_0$:
33+
34+1. To encode symbol $s_1$:
35+ - Find the $x_0$-th occurrence of symbol $s_1$ (counting from 0)
36+ - This position index becomes our new state $x_1$
37+2. Repeat this process for each new symbol, using the previous state to find the next one
38+
39+This process effectively "pushes" each symbol onto our state value in a way that can be reversed.
40+
41+With the periodic structured table, the formula for this is
42+
43+$$x_{i+1} = (x_i // f_{s_i}) \cdot L + C(s_i) + (x_i \text{ mod } f_{s_i})$$
44+
45+where $//$ denotes integer division and $\text{ mod }$ is the modulo operation.
46+
47+Notice that for large $x_i$,
48+
49+$$x_{i+1} \approx x_i \cdot p_s^{-1}$$
50+
51+where $p_s = f_s / L$ is the probability of symbol $s$. This means that the expected value of $\log(x_i)$ grows by $-\log(p_s)$ at each step, leading to:
52+
53+$$\lim_{k \to \infty} E(\frac{1}{k}\log(x_k)) = -\sum_{s=0}^{S-1} p_s \log(p_s).$$
54+
55+Or in other words, if $H$ is the average number of bits required to encode a single symbol in the sequence, then
56+
57+$$H = -\sum_{s=0}^{S-1} p_s \log(p_s)$$
58+
59+which matches the Shannon entropy of the symbol distribution. In other words, the integer $x_k$ encodes the state using the theoretically optimal number of bits.
60+
61+**Now for decoding.** Given a final state $x_k$, we can recover the original sequence as follows:
62+
63+1. The last symbol $s_k$ is simply $T[x_k]$ (the symbol at position $x_k$)
64+2. To get the previous state $x_{k-1}$:
65+ - Count how many times $s_k$ appeared before position $x_k$
66+ - This count is our previous state $x_{k-1}$
67+3. Continue this process to recover all symbols in reverse order
68+
69+The formula for this decoding is
70+
71+$$x_i = (x_{i+1} // L) \cdot f_{s_i} + (x_{i+1} \text{ mod } L) - C(s_i)$$
72+
73+## Practical ANS Implementation
74+
75+### The Need for Bounded State
76+In practice, we can't work with arbitrarily large integers. Our state $x$ would grow indefinitely as we encode more symbols, and operating on integers with arbitrarily large precision is very inefficient. We therefore need a way to keep the state within a manageable range while preserving the reversibility of the encoding.
77+
78+### Word-Based Streaming with Conditional Normalization
79+Instead of normalizing at every step, we use a more efficient approach that only normalizes when necessary. We maintain our state as a 64-bit integer and stream out 32-bit words when the state would overflow.
80+
81+Let $W$ be an array of 32-bit words, initialized to empty. We define:
82+- `STATE_BITS = 64` (total bits for state)
83+- `WORD_BITS = 32` (bits per output word)
84+- `THRESHOLD = 2^32` (minimum state value after normalization)
85+
86+For efficiency of calculations we assume that $L$ is a power of 2:
87+
88+$$
89+L = 2^{l}
90+$$
91+
92+### Encoding Process
93+When encoding symbol $s$ with current state $x$:
94+
95+1. **Check for normalization**: If $(x >> (64 - l)) \geq f_s$, then:
96+ - Extract the lower 32 bits: $w = x \text{ mod } 2^{32}$
97+ - Append $w$ to word array $W$
98+ - Update state: $x = x >> 32$
99+
100+2. **Encode the symbol**:
101+ - Compute: $remainder = x \text{ mod } f_s$
102+ - Compute: $prefix = x // f_s$
103+ - Update state: $x = (prefix << l) | (C_s + remainder)$
104+
105+The key insight is that normalization only occurs when the next encoding step would cause overflow, making the algorithm more efficient.
106+
107+### Decoding Process
108+To decode from final state $x$ and word array $W$:
109+
110+1. **Extract symbol**:
111+ - $quantile = x \text{ mod } L$
112+ - Find symbol $s$ such that $C_s \leq quantile < C_s + f_s$
113+
114+2. **Compute previous state**:
115+ - $prefix = x >> l$
116+ - $previous\_state = prefix \cdot f_s + (quantile - C_s)$
117+
118+3. **Check for denormalization**: If $previous\_state < THRESHOLD$ and words remain:
119+ - Pop word $w$ from end of $W$
120+ - Update: $previous\_state = (previous\_state << 32) | w$
121+
122+4. **Continue**: Set $x = previous\_state$ and repeat
123+
124+This approach maintains the state above the threshold while efficiently managing memory by streaming out words only when necessary.
125+
126+## Optimality of the Compression
127+
128+In the above, there are two sources of loss in the compression efficiency:
129+1. The assumption that the frequencies sum to a power of two
130+2. The need to normalize the state
131+
132+The first of these can be addressed by choosing $L=2^l$ large enough so that the proportions $p_i = f_i / L$ are accurate enough so that the loss is negligible.
133+
134+The second source of loss is more difficult to predict but can be examined empirically. (not yet explored)
\ No newline at end of file
src/index.cssadded+17−0View file
@@ -0,0 +1,17 @@
1+* {
2+ /* margin: 0;
3+ padding: 0; */
4+ box-sizing: border-box;
5+}
6+
7+body {
8+ font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Oxygen',
9+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
10+ sans-serif;
11+ -webkit-font-smoothing: antialiased;
12+ -moz-osx-font-smoothing: grayscale;
13+}
14+
15+#root {
16+ min-height: 100vh;
17+}
src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import { StrictMode } from 'react'
2+import { createRoot } from 'react-dom/client'
3+import './index.css'
4+import App from './App.tsx'
5+
6+createRoot(document.getElementById('root')!).render(
7+ <StrictMode>
8+ <App />
9+ </StrictMode>,
10+)
src/types/index.tsadded+10−0View file
@@ -0,0 +1,10 @@
1+export interface Symbol {
2+ name: string;
3+ frequency: number;
4+ color: string;
5+}
6+
7+export interface StateInfo {
8+ index: number;
9+ symbol: Symbol;
10+}
src/utils/ansLogic.tsadded+168−0View file
@@ -0,0 +1,168 @@
1+import type { Symbol, StateInfo } from '../types';
2+
3+/**
4+ * Generate the repeating pattern of symbols based on their frequencies.
5+ * For example: A-2, B-1, C-1 produces [A, A, B, C, A, A, B, C, ...]
6+ */
7+export function generateStatePattern(symbols: Symbol[], maxStates: number = 1000): StateInfo[] {
8+ if (symbols.length === 0) return [];
9+
10+ // Create the base pattern by repeating each symbol according to its frequency
11+ const basePattern: Symbol[] = [];
12+ symbols.forEach(symbol => {
13+ for (let i = 0; i < symbol.frequency; i++) {
14+ basePattern.push(symbol);
15+ }
16+ });
17+
18+ // Generate states by repeating the base pattern
19+ const states: StateInfo[] = [];
20+ for (let i = 0; i < maxStates; i++) {
21+ const patternIndex = i % basePattern.length;
22+ states.push({
23+ index: i,
24+ symbol: basePattern[patternIndex]
25+ });
26+ }
27+
28+ return states;
29+}
30+
31+/**
32+ * Calculate L (sum of all frequencies)
33+ */
34+export function calculateL(symbols: Symbol[]): number {
35+ return symbols.reduce((sum, symbol) => sum + symbol.frequency, 0);
36+}
37+
38+/**
39+ * Calculate the chain of previous states from state n back to state 0.
40+ * Uses the formula: prevState = (n // L) * f_{s(n)} + (n mod L) - C_{s(n)}
41+ * where:
42+ * - n // L is integer division
43+ * - n mod L is the remainder
44+ * - s(n) is the symbol at state n
45+ * - f_s is the frequency of symbol s
46+ * - C_s is the cumulative sum of frequencies up to (not including) symbol s
47+ */
48+export function calculateEdgeChain(n: number, symbols: Symbol[], numLeadingAs: number): number[] {
49+ if (symbols.length === 0 || n < 0) return [];
50+
51+ const L = calculateL(symbols);
52+
53+ // Build cumulative sum map: symbol name -> cumulative sum
54+ const cumulativeMap = new Map<string, number>();
55+ let cumSum = 0;
56+ symbols.forEach(symbol => {
57+ cumulativeMap.set(symbol.name, cumSum);
58+ cumSum += symbol.frequency;
59+ });
60+
61+ // Build frequency map: symbol name -> frequency
62+ const frequencyMap = new Map<string, number>();
63+ symbols.forEach(symbol => {
64+ frequencyMap.set(symbol.name, symbol.frequency);
65+ });
66+
67+ const symbolMap = new Map<number, Symbol>();
68+ let ii = 0;
69+ for (let i = 0; i < symbols.length; i++) {
70+ for (let j = 0; j < symbols[i].frequency; j++) {
71+ symbolMap.set(ii, symbols[i]);
72+ ii++;
73+ }
74+ }
75+ if (ii !== L) {
76+ throw new Error("Symbol map construction error");
77+ }
78+
79+ const chain: number[] = [];
80+ let current = n;
81+
82+ // Iterate backwards until we reach initial states
83+ while (true) {
84+ if (current < (frequencyMap.get(symbols[0].name) || 1)) {
85+ for (let i = 0; i < numLeadingAs; i++) {
86+ chain.push(current);
87+ }
88+ break;
89+ }
90+ else {
91+ chain.push(current);
92+ }
93+
94+ const currentModL = current % L;
95+ const currentSymbol = symbolMap.get(currentModL);
96+ if (!currentSymbol) {
97+ throw new Error("Invalid state encountered in edge chain calculation");
98+ }
99+ const symbolName = currentSymbol.name;
100+ const f_s = frequencyMap.get(symbolName) || 0;
101+ const C_s = cumulativeMap.get(symbolName) || 0;
102+
103+ // Calculate prevState using the formula
104+ const prevState = Math.floor(current / L) * f_s + (current % L) - C_s;
105+
106+ current = prevState;
107+ }
108+
109+ return chain;
110+}
111+
112+/**
113+ * Calculate the forward edges from state n for each symbol.
114+ * Uses the formula: nextState(n, s) = (n // f_s)*L + C_s + (n mod f_s)
115+ * where:
116+ * - n // f_s is integer division
117+ * - f_s is the frequency of symbol s
118+ * - C_s is the cumulative sum of frequencies up to (not including) symbol s
119+ * - L is the sum of all frequencies
120+ */
121+export function calculateForwardEdges(n: number, symbols: Symbol[], maxStates: number): Array<{ toState: number; symbol: Symbol }> {
122+ if (symbols.length === 0 || n < 0) return [];
123+
124+ const L = calculateL(symbols);
125+
126+ // Build cumulative sum map: symbol name -> cumulative sum
127+ const cumulativeMap = new Map<string, number>();
128+ let cumSum = 0;
129+ symbols.forEach(symbol => {
130+ cumulativeMap.set(symbol.name, cumSum);
131+ cumSum += symbol.frequency;
132+ });
133+
134+ const forwardEdges: Array<{ toState: number; symbol: Symbol }> = [];
135+
136+ symbols.forEach(symbol => {
137+ const f_s = symbol.frequency;
138+ const C_s = cumulativeMap.get(symbol.name) || 0;
139+
140+ // Calculate nextState using the formula
141+ const nextState = Math.floor(n / f_s) * L + C_s + (n % f_s);
142+
143+ // Only add if the next state is within bounds
144+ if (nextState >= 0 && nextState < maxStates) {
145+ forwardEdges.push({ toState: nextState, symbol });
146+ }
147+ });
148+
149+ return forwardEdges;
150+}
151+
152+/**
153+ * Get default color palette - softer, more appealing colors
154+ */
155+export function getDefaultColors(): string[] {
156+ return [
157+ '#64B5F6', // light blue
158+ '#F06292', // light pink
159+ '#BA68C8', // light purple
160+ '#FFB74D', // light orange
161+ '#81C784', // light green
162+ '#E57373', // light red
163+ '#4DD0E1', // light cyan
164+ '#9575CD', // medium purple
165+ '#FF8A65', // coral
166+ '#A1887F', // light brown
167+ ];
168+}
src/utils/canvasRenderer.tsadded+199−0View file
@@ -0,0 +1,199 @@
1+import type { StateInfo } from "../types";
2+
3+export interface BoxPosition {
4+ x: number;
5+ y: number;
6+ width: number;
7+ height: number;
8+}
9+
10+export interface RenderConfig {
11+ boxSize: number;
12+ boxesPerRow: number;
13+ L: number;
14+ canvasWidth: number;
15+ canvasHeight: number;
16+}
17+
18+/**
19+ * Calculate the position and dimensions of a state box
20+ */
21+export function getBoxBounds(
22+ stateIndex: number,
23+ config: RenderConfig
24+): BoxPosition {
25+ const { boxSize, boxesPerRow, L } = config;
26+ const row = Math.floor(stateIndex / boxesPerRow);
27+ const col = stateIndex % boxesPerRow;
28+ const boxHeight = Math.floor(boxSize * 0.6);
29+
30+ // Calculate group-aware positioning
31+ const groupSize = L;
32+ const groupIndex = Math.floor(col / groupSize);
33+ const gapSize = 6;
34+
35+ const x = col * boxSize + groupIndex * gapSize;
36+ const y = row * boxHeight;
37+
38+ return { x, y, width: boxSize, height: boxHeight };
39+}
40+
41+/**
42+ * Get the center point of a box for edge drawing
43+ */
44+export function getBoxCenter(
45+ stateIndex: number,
46+ config: RenderConfig
47+): { x: number; y: number } {
48+ const bounds = getBoxBounds(stateIndex, config);
49+ return {
50+ x: bounds.x + bounds.width / 2,
51+ y: bounds.y + bounds.height / 2,
52+ };
53+}
54+
55+/**
56+ * Find which box (if any) is at the given mouse coordinates
57+ */
58+export function getBoxAtPosition(
59+ mouseX: number,
60+ mouseY: number,
61+ numStates: number,
62+ config: RenderConfig
63+): number | null {
64+ for (let i = 0; i < numStates; i++) {
65+ const bounds = getBoxBounds(i, config);
66+ if (
67+ mouseX >= bounds.x &&
68+ mouseX <= bounds.x + bounds.width &&
69+ mouseY >= bounds.y &&
70+ mouseY <= bounds.y + bounds.height
71+ ) {
72+ return i;
73+ }
74+ }
75+ return null;
76+}
77+
78+/**
79+ * Draw a single state box on the canvas
80+ */
81+export function drawStateBox(
82+ ctx: CanvasRenderingContext2D,
83+ state: StateInfo,
84+ config: RenderConfig,
85+ isHovered: boolean = false
86+): void {
87+ const bounds = getBoxBounds(state.index, config);
88+ const { x, y, width, height } = bounds;
89+
90+ // Draw box background
91+ ctx.fillStyle = state.symbol.color;
92+ ctx.fillRect(x, y, width, height);
93+
94+ // Draw border
95+ ctx.strokeStyle = "rgba(0, 0, 0, 0.1)";
96+ ctx.lineWidth = 1;
97+ ctx.strokeRect(x, y, width, height);
98+
99+ // Draw hover effect
100+ if (isHovered) {
101+ ctx.strokeStyle = "rgba(0, 0, 0, 0.3)";
102+ ctx.lineWidth = 2;
103+ ctx.strokeRect(x - 1, y - 1, width + 2, height + 2);
104+ }
105+
106+ // Draw text
107+ ctx.fillStyle = "white";
108+ ctx.font = `bold ${config.boxSize > 20 ? 12 : 10}px Arial`;
109+ ctx.textAlign = "center";
110+ ctx.textBaseline = "middle";
111+ ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
112+ ctx.shadowBlur = 2;
113+ ctx.shadowOffsetX = 0;
114+ ctx.shadowOffsetY = 1;
115+ ctx.fillText(state.symbol.name, x + width / 2, y + height / 2);
116+ ctx.shadowColor = "transparent";
117+ ctx.shadowBlur = 0;
118+}
119+
120+/**
121+ * Draw an arrow head at the end of a line
122+ */
123+function drawArrowHead(
124+ ctx: CanvasRenderingContext2D,
125+ fromX: number,
126+ fromY: number,
127+ toX: number,
128+ toY: number,
129+ color: string
130+): void {
131+ const headLength = 10;
132+ const angle = Math.atan2(toY - fromY, toX - fromX);
133+
134+ ctx.save();
135+ ctx.fillStyle = color;
136+ ctx.beginPath();
137+ ctx.moveTo(toX, toY);
138+ ctx.lineTo(
139+ toX - headLength * Math.cos(angle - Math.PI / 6),
140+ toY - headLength * Math.sin(angle - Math.PI / 6)
141+ );
142+ ctx.lineTo(
143+ toX - headLength * Math.cos(angle + Math.PI / 6),
144+ toY - headLength * Math.sin(angle + Math.PI / 6)
145+ );
146+ ctx.closePath();
147+ ctx.fill();
148+ ctx.restore();
149+}
150+
151+/**
152+ * Draw an edge (line with arrow) between two states
153+ */
154+export function drawEdge(
155+ ctx: CanvasRenderingContext2D,
156+ fromState: number,
157+ toState: number,
158+ config: RenderConfig,
159+ color: string,
160+ strokeWidth: number = 3
161+): void {
162+ const fromPos = getBoxCenter(fromState, config);
163+ const toPos = getBoxCenter(toState, config);
164+
165+ // Draw line
166+ ctx.strokeStyle = color;
167+ ctx.lineWidth = strokeWidth;
168+ ctx.beginPath();
169+ ctx.moveTo(fromPos.x, fromPos.y);
170+ ctx.lineTo(toPos.x, toPos.y);
171+ ctx.stroke();
172+
173+ // Draw arrow head
174+ drawArrowHead(ctx, fromPos.x, fromPos.y, toPos.x, toPos.y, color);
175+}
176+
177+/**
178+ * Clear the entire canvas
179+ */
180+export function clearCanvas(
181+ ctx: CanvasRenderingContext2D,
182+ width: number,
183+ height: number
184+): void {
185+ ctx.clearRect(0, 0, width, height);
186+}
187+
188+/**
189+ * Calculate the total height needed for the canvas
190+ */
191+export function calculateCanvasHeight(
192+ numStates: number,
193+ config: RenderConfig
194+): number {
195+ if (config.boxesPerRow === 0) return 0;
196+ const numRows = Math.ceil(numStates / config.boxesPerRow);
197+ const boxHeight = Math.floor(config.boxSize * 0.6);
198+ return numRows * boxHeight;
199+}
src/utils/deviceDetection.tsadded+14−0View file
@@ -0,0 +1,14 @@
1+/**
2+ * Detect if the user is on a mobile device
3+ */
4+export function isMobileDevice(): boolean {
5+ // Check screen width
6+ const isMobileWidth = window.innerWidth < 768;
7+
8+ // Check user agent
9+ const userAgent = navigator.userAgent || navigator.vendor || '';
10+ const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
11+ const isMobileUA = mobileRegex.test(userAgent);
12+
13+ return isMobileWidth || isMobileUA;
14+}
tsconfig.app.jsonadded+28−0View file
@@ -0,0 +1,28 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4+ "target": "ES2022",
5+ "useDefineForClassFields": true,
6+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7+ "module": "ESNext",
8+ "types": ["vite/client"],
9+ "skipLibCheck": true,
10+
11+ /* Bundler mode */
12+ "moduleResolution": "bundler",
13+ "allowImportingTsExtensions": true,
14+ "verbatimModuleSyntax": true,
15+ "moduleDetection": "force",
16+ "noEmit": true,
17+ "jsx": "react-jsx",
18+
19+ /* Linting */
20+ "strict": true,
21+ "noUnusedLocals": true,
22+ "noUnusedParameters": true,
23+ "erasableSyntaxOnly": true,
24+ "noFallthroughCasesInSwitch": true,
25+ "noUncheckedSideEffectImports": true
26+ },
27+ "include": ["src"]
28+}
tsconfig.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "files": [],
3+ "references": [
4+ { "path": "./tsconfig.app.json" },
5+ { "path": "./tsconfig.node.json" }
6+ ]
7+}
tsconfig.node.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4+ "target": "ES2023",
5+ "lib": ["ES2023"],
6+ "module": "ESNext",
7+ "types": ["node"],
8+ "skipLibCheck": true,
9+
10+ /* Bundler mode */
11+ "moduleResolution": "bundler",
12+ "allowImportingTsExtensions": true,
13+ "verbatimModuleSyntax": true,
14+ "moduleDetection": "force",
15+ "noEmit": true,
16+
17+ /* Linting */
18+ "strict": true,
19+ "noUnusedLocals": true,
20+ "noUnusedParameters": true,
21+ "erasableSyntaxOnly": true,
22+ "noFallthroughCasesInSwitch": true,
23+ "noUncheckedSideEffectImports": true
24+ },
25+ "include": ["vite.config.ts"]
26+}
vite.config.tsadded+7−0View file
@@ -0,0 +1,7 @@
1+import { defineConfig } from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+// https://vite.dev/config/
5+export default defineConfig({
6+ plugins: [react()],
7+})