update layout, etc
8 changed files+212−110
index.htmlmodified+1−1View file
@@ -2,7 +2,7 @@
22 <html lang="en">
33 <head>
44 <meta charset="UTF-8" />
5- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
5+ <link rel="icon" type="image/svg+xml" href="/ans-logo.svg" />
66 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
77 <meta name="description" content="Illustration of the Asymmetric Numeral Systems algorithm" />
88 <link rel="preconnect" href="https://fonts.googleapis.com" />
public/ans-logo.svgadded+23−0View file
@@ -0,0 +1,23 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2+ <!-- Background circle -->
3+ <circle cx="32" cy="32" r="32" fill="#2563eb"/>
4+
5+ <!-- Discrete symbols arranged in a grid pattern -->
6+ <!-- Row 1 -->
7+ <rect x="14" y="14" width="8" height="8" rx="1.5" fill="#60a5fa"/>
8+ <rect x="26" y="14" width="12" height="8" rx="1.5" fill="#93c5fd"/>
9+ <rect x="42" y="14" width="8" height="8" rx="1.5" fill="#dbeafe"/>
10+
11+ <!-- Row 2 -->
12+ <rect x="14" y="26" width="12" height="8" rx="1.5" fill="#93c5fd"/>
13+ <rect x="30" y="26" width="8" height="8" rx="1.5" fill="#60a5fa"/>
14+ <rect x="42" y="26" width="8" height="8" rx="1.5" fill="#dbeafe"/>
15+
16+ <!-- Row 3 -->
17+ <rect x="14" y="38" width="8" height="8" rx="1.5" fill="#dbeafe"/>
18+ <rect x="26" y="38" width="8" height="8" rx="1.5" fill="#60a5fa"/>
19+ <rect x="38" y="38" width="12" height="8" rx="1.5" fill="#93c5fd"/>
20+
21+ <!-- Bottom compressed state indicator -->
22+ <rect x="18" y="50" width="28" height="4" rx="1.5" fill="#ffffff" opacity="0.8"/>
23+</svg>
src/App.tsxmodified+102−46View file
@@ -1,16 +1,15 @@
1-import { useState, useEffect } from 'react';
2-import { Container, Typography, Box, CssBaseline, ThemeProvider, createTheme, Paper } from '@mui/material';
1+import { GitHub, ArrowDownward } from '@mui/icons-material';
2+import { Box, Button, Container, createTheme, CssBaseline, IconButton, Paper, ThemeProvider, Tooltip } from '@mui/material';
3+import 'katex/dist/katex.min.css';
4+import { useEffect, useRef, useState } from 'react';
35 import ReactMarkdown from 'react-markdown';
4-import remarkMath from 'remark-math';
56 import rehypeKatex from 'rehype-katex';
6-import 'katex/dist/katex.min.css';
7-import MobileWarning from './components/MobileWarning';
7+import remarkMath from 'remark-math';
88 import FrequencyInput from './components/FrequencyInput';
99 import StateGrid from './components/StateGrid';
10+import explanationText from './explanation.md?raw';
1011 import type { Symbol } from './types';
11-import { isMobileDevice } from './utils/deviceDetection';
1212 import { getDefaultColors } from './utils/ansLogic';
13-import explanationText from './explanation.md?raw';
1413
1514 const theme = createTheme({
1615 palette: {
@@ -22,13 +21,10 @@ const theme = createTheme({
2221 });
2322
2423 function App() {
25- const [isMobile, setIsMobile] = useState(false);
2624 const [symbols, setSymbols] = useState<Symbol[]>([]);
25+ const visualizationRef = useRef<HTMLDivElement>(null);
2726
2827 useEffect(() => {
29- // Check if mobile
30- setIsMobile(isMobileDevice());
31-
3228 // Initialize with default symbols: A-1, B-1, C-1
3329 const defaultColors = getDefaultColors();
3430 const defaultSymbols: Symbol[] = [
@@ -37,45 +33,89 @@ function App() {
3733 { name: 'C', frequency: 1, color: defaultColors[2] },
3834 ];
3935 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);
4736 }, []);
4837
49- if (isMobile) {
50- return (
51- <ThemeProvider theme={theme}>
52- <CssBaseline />
53- <MobileWarning />
54- </ThemeProvider>
55- );
56- }
38+ const scrollToVisualization = () => {
39+ visualizationRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
40+ };
5741
5842 return (
5943 <ThemeProvider theme={theme}>
6044 <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. Duda et al., 2015.{' '}
68- <a href="https://ieeexplore.ieee.org/abstract/document/7170048" target="_blank" rel="noopener noreferrer">
69- [Link to paper]
70- </a>
71- </Typography>
72- </Box>
73-
74- <Box sx={{ display: 'flex', gap: 3 }}>
75- <Box sx={{ flex: 1 }}>
76- <FrequencyInput symbols={symbols} onSymbolsChange={setSymbols} />
77-
78- <Paper sx={{ p: 2, mt: 3, maxHeight: '600px', overflowY: 'auto' }}>
45+ <Tooltip title="View source on GitHub">
46+ <IconButton
47+ component="a"
48+ href="https://github.com/magland/ans-visualizer"
49+ target="_blank"
50+ rel="noopener noreferrer"
51+ sx={{
52+ position: 'fixed',
53+ top: 16,
54+ right: 16,
55+ bgcolor: 'background.paper',
56+ boxShadow: 2,
57+ '&:hover': {
58+ bgcolor: 'action.hover',
59+ },
60+ zIndex: 1000,
61+ }}
62+ >
63+ <GitHub />
64+ </IconButton>
65+ </Tooltip>
66+ <Container maxWidth="xl" sx={{
67+ py: 2,
68+ height: '100vh',
69+ display: 'flex',
70+ flexDirection: 'column',
71+ overflow: { xs: 'auto', md: 'hidden' }, // Single scroll on mobile, no scroll on desktop
72+ }}>
73+ <Box sx={{
74+ display: 'flex',
75+ flexDirection: { xs: 'column', md: 'row' },
76+ gap: 3,
77+ flex: { xs: '0 1 auto', md: 1 }, // Allow shrinking on mobile, fixed on desktop
78+ overflow: { xs: 'visible', md: 'hidden' }, // Visible on mobile (parent scrolls), hidden on desktop
79+ minHeight: { xs: 'auto', md: 0 },
80+ }}>
81+ {/* Left Column - Explanation */}
82+ <Box sx={{
83+ flex: 1,
84+ display: 'flex',
85+ flexDirection: 'column',
86+ minHeight: { xs: 'auto', md: 0 },
87+ overflow: { xs: 'visible', md: 'auto' }, // No scroll on mobile (parent scrolls), independent scroll on desktop
88+ }}>
89+ <Paper sx={{
90+ p: 2,
91+ flex: { xs: '0 0 auto', md: '1 1 auto' },
92+ overflow: { xs: 'visible', md: 'auto' },
93+ display: 'flex',
94+ flexDirection: 'column',
95+ }}>
96+ {/* Jump to Visualization Button - Only visible on mobile (single column) */}
97+ <Box sx={{
98+ display: { xs: 'flex', md: 'none' },
99+ justifyContent: 'center',
100+ mb: 2,
101+ }}>
102+ <Button
103+ variant="contained"
104+ color="primary"
105+ size="large"
106+ endIcon={<ArrowDownward />}
107+ onClick={scrollToVisualization}
108+ sx={{
109+ boxShadow: 3,
110+ '&:hover': {
111+ boxShadow: 6,
112+ },
113+ }}
114+ >
115+ Jump to Visualization
116+ </Button>
117+ </Box>
118+
79119 <ReactMarkdown
80120 remarkPlugins={[remarkMath]}
81121 rehypePlugins={[rehypeKatex]}
@@ -85,8 +125,24 @@ function App() {
85125 </Paper>
86126 </Box>
87127
88- <Box sx={{ flex: 1 }}>
89- <StateGrid symbols={symbols} />
128+ {/* Right Column - Interactive Controls */}
129+ <Box
130+ ref={visualizationRef}
131+ sx={{
132+ flex: 1,
133+ display: 'flex',
134+ flexDirection: 'column',
135+ minHeight: { xs: 'auto', md: 0 },
136+ overflow: { xs: 'visible', md: 'auto' }, // No scroll on mobile (parent scrolls), independent scroll on desktop
137+ gap: 3,
138+ pb: { xs: 2, md: 0 }, // Add bottom padding on mobile for better spacing
139+ }}
140+ >
141+ <FrequencyInput symbols={symbols} onSymbolsChange={setSymbols} />
142+
143+ <Box sx={{ flex: 1, minHeight: 0 }}>
144+ <StateGrid symbols={symbols} />
145+ </Box>
90146 </Box>
91147 </Box>
92148 </Container>
src/components/MobileWarning.tsxdeleted+0−31View file
@@ -1,31 +0,0 @@
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.
28- </Typography>
29- </Box>
30- );
31-}
src/components/StateGrid.tsxmodified+59−7View file
@@ -27,6 +27,22 @@ export default function StateGrid({ symbols }: StateGridProps) {
2727 const [boxesPerRow, setBoxesPerRow] = useState(0);
2828 const [hoveredState, setHoveredState] = useState<number | null>(null);
2929 const [numLeadingAs, setNumLeadingAs] = useState(0);
30+ const [supportsHover, setSupportsHover] = useState(true);
31+
32+ // Detect if device supports hover (mouse) vs touch-only
33+ useEffect(() => {
34+ const hoverQuery = window.matchMedia("(hover: hover)");
35+ setSupportsHover(hoverQuery.matches);
36+
37+ const handleChange = (e: MediaQueryListEvent) => {
38+ setSupportsHover(e.matches);
39+ };
40+
41+ hoverQuery.addEventListener("change", handleChange);
42+ return () => {
43+ hoverQuery.removeEventListener("change", handleChange);
44+ };
45+ }, []);
3046
3147 const L = useMemo(() => calculateL(symbols), [symbols]);
3248 const maxStates = 5000; // Show first 5000 states
@@ -39,14 +55,12 @@ export default function StateGrid({ symbols }: StateGridProps) {
3955 // Calculate edge chain on demand for hovered state
4056 const edgeChain = useMemo(() => {
4157 if (hoveredState === null) return [];
42- console.log("Calculating edge chain for state:", hoveredState);
4358 return calculateEdgeChain(hoveredState, symbols, numLeadingAs);
4459 }, [hoveredState, symbols, numLeadingAs]);
4560
4661 // Calculate forward edges on demand for hovered state
4762 const forwardEdges = useMemo(() => {
4863 if (hoveredState === null) return [];
49- console.log("Calculating forward edges for state:", hoveredState);
5064 return calculateForwardEdges(hoveredState, symbols, maxStates);
5165 }, [hoveredState, symbols, maxStates]);
5266
@@ -174,9 +188,11 @@ export default function StateGrid({ symbols }: StateGridProps) {
174188 forwardEdges,
175189 ]);
176190
177- // Handle mouse move on canvas
191+ // Handle mouse move on canvas (only on devices with hover support)
178192 const handleMouseMove = useCallback(
179193 (e: React.MouseEvent<HTMLCanvasElement>) => {
194+ if (!supportsHover) return; // Skip hover on touch devices
195+
180196 const canvas = canvasRef.current;
181197 if (!canvas || boxesPerRow === 0) return;
182198
@@ -200,13 +216,48 @@ export default function StateGrid({ symbols }: StateGridProps) {
200216 );
201217 setHoveredState(boxIndex);
202218 },
203- [boxSize, boxesPerRow, L, canvasWidth, canvasHeight, states.length]
219+ [supportsHover, boxSize, boxesPerRow, L, canvasWidth, canvasHeight, states.length]
204220 );
205221
206- // Handle mouse leave
222+ // Handle mouse leave (only on devices with hover support)
207223 const handleMouseLeave = useCallback(() => {
224+ if (!supportsHover) return; // Skip on touch devices
208225 setHoveredState(null);
209- }, []);
226+ }, [supportsHover]);
227+
228+ // Handle click on canvas (for touch devices and desktop)
229+ const handleClick = useCallback(
230+ (e: React.MouseEvent<HTMLCanvasElement>) => {
231+ const canvas = canvasRef.current;
232+ if (!canvas || boxesPerRow === 0) return;
233+
234+ const rect = canvas.getBoundingClientRect();
235+ const clickX = e.clientX - rect.left;
236+ const clickY = e.clientY - rect.top;
237+
238+ const config: RenderConfig = {
239+ boxSize,
240+ boxesPerRow,
241+ L,
242+ canvasWidth,
243+ canvasHeight,
244+ };
245+
246+ const boxIndex = getBoxAtPosition(
247+ clickX,
248+ clickY,
249+ states.length,
250+ config
251+ );
252+
253+ if (!supportsHover) {
254+ // On touch devices: toggle selection
255+ setHoveredState(prevState => prevState === boxIndex ? null : boxIndex);
256+ }
257+ // On desktop with hover: do nothing (hover already handles it)
258+ },
259+ [supportsHover, boxSize, boxesPerRow, L, canvasWidth, canvasHeight, states.length]
260+ );
210261
211262 if (symbols.length === 0) {
212263 return (
@@ -289,7 +340,7 @@ export default function StateGrid({ symbols }: StateGridProps) {
289340 </Typography>
290341 ) : (
291342 <Typography variant="body2" color="text.secondary" sx={{ fontStyle: "italic" }}>
292- Hover over a state to see its details
343+ {supportsHover ? "Hover over a state to see its details" : "Click on a state to see its details"}
293344 </Typography>
294345 )}
295346 </Box>
@@ -307,6 +358,7 @@ export default function StateGrid({ symbols }: StateGridProps) {
307358 ref={canvasRef}
308359 onMouseMove={handleMouseMove}
309360 onMouseLeave={handleMouseLeave}
361+ onClick={handleClick}
310362 style={{
311363 display: "block",
312364 cursor: "pointer",
src/explanation.mdmodified+21−10View file
@@ -1,8 +1,19 @@
1-## Explanation of Asymmetric Numeral Systems (ANS)
1+# ANS Visualizer
2+
3+Asymmetric Numeral Systems (ANS) is a family of entropy coding methods used in data compression. It was invented by Jarek Duda.
4+
5+- [arXiv preprint, 2013](https://arxiv.org/abs/1311.2540)
6+- [IEEE Paper, 2015](https://ieeexplore.ieee.org/abstract/document/7170048)
7+
8+For arrays of independently sampled symbols, ANS can achieve compression ratios that approach the theoretical limit defined by Shannon entropy. ANS has been adopted in various compression algorithms and formats, including Facebook's Zstandard and Apple's LZFSE.
9+
10+The [simple_ans](https://github.com/flatironinstitute/simple_ans) package is a simple and efficient Python implementation of ANS. The purpose of this app is to visualize the version of ANS encoding and decoding that is implemented in this library.
11+
12+## Explanation of ANS
213
314 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.
415
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$.
16+Suppose that $\{0, 1, ..., S-1\}$ are the symbols we want to encode (in the interactive view, 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$.
617
718 We assume that the symbols are sampled randomly and independently of one another. Let
819
@@ -12,8 +23,6 @@ $$
1223
1324 be the sum of the symbol frequencies.
1425
15-## ANS Encoding/Decoding
16-
1726 ### The Symbol Table
1827 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\}$
1928
@@ -27,7 +36,7 @@ We construct this table with a periodic pattern where:
2736 - For any position $n$, $T(n) = T(n \text{ mod } L)$
2837 - Define $C_s = \sum_{i=0}^{s-1} f_i$ as the cumulative frequency (start position of symbol $s$ in each period)
2938
30-### The Encoding/Decoding Process
39+### Encoding
3140
3241 The encoding process works by maintaining a state value $x$ that evolves as we process each symbol. Starting from state $x_0$:
3342
@@ -58,7 +67,9 @@ $$H = -\sum_{s=0}^{S-1} p_s \log(p_s)$$
5867
5968 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.
6069
61-**Now for decoding.** Given a final state $x_k$, we can recover the original sequence as follows:
70+### Decoding
71+
72+Given a final state $x_k$, we can recover the original sequence as follows:
6273
6374 1. The last symbol $s_k$ is simply $T[x_k]$ (the symbol at position $x_k$)
6475 2. To get the previous state $x_{k-1}$:
@@ -70,6 +81,10 @@ The formula for this decoding is
7081
7182 $$x_i = (x_{i+1} // L) \cdot f_{s_i} + (x_{i+1} \text{ mod } L) - C(s_i)$$
7283
84+## Interactive Visualization
85+
86+This app illustrates the encoding and decoding processes described above. You specify the symbol frequencies and the table of states is shown with each integer state represented as a box. As you hover over a box, you can see the path of black arrows representing the decoding process as well as blue arrows representing the encoding process for all possibilities of the next symbol to encode. For technical reasons, you also need to specify the number of leading A's for the decoded sequence, but this information cannot be captured by just the integer state alone.
87+
7388 ## Practical ANS Implementation
7489
7590 ### The Need for Bounded State
@@ -132,7 +147,3 @@ In the above, there are two sources of loss in the compression efficiency:
132147 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.
133148
134149 The second source of loss is more difficult to predict but can be examined empirically. (not yet explored)
135-
136-[Source code for the ANS Visualizer](https://github.com/magland/ans-visualizer)
137-
138-[simple_ans: A simple and efficient Python implementation of ANS](https://github.com/flatironinstitute/simple_ans)
src/index.cssmodified+6−1View file
@@ -10,8 +10,13 @@ body {
1010 sans-serif;
1111 -webkit-font-smoothing: antialiased;
1212 -moz-osx-font-smoothing: grayscale;
13+ margin: 0;
14+ padding: 0;
15+ height: 100vh;
16+ overflow: hidden;
1317 }
1418
1519 #root {
16- min-height: 100vh;
20+ height: 100vh;
21+ overflow: hidden;
1722 }
src/utils/deviceDetection.tsdeleted+0−14View file
@@ -1,14 +0,0 @@
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-}