concept-collection / finite-field-visualizer
initial
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 56938f52ef62 Browse files
20 changed files+6414−0
.github/workflows/deploy.ymladded+51−0View file
@@ -0,0 +1,51 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches:
6+ - main
7+ workflow_dispatch:
8+
9+permissions:
10+ contents: read
11+ pages: write
12+ id-token: write
13+
14+concurrency:
15+ group: "pages"
16+ cancel-in-progress: false
17+
18+jobs:
19+ build:
20+ runs-on: ubuntu-latest
21+ steps:
22+ - name: Checkout
23+ uses: actions/checkout@v4
24+
25+ - name: Setup Node
26+ uses: actions/setup-node@v4
27+ with:
28+ node-version: '20'
29+ cache: 'npm'
30+
31+ - name: Install dependencies
32+ run: npm ci
33+
34+ - name: Build
35+ run: npm run build
36+
37+ - name: Upload artifact
38+ uses: actions/upload-pages-artifact@v3
39+ with:
40+ path: ./dist
41+
42+ deploy:
43+ environment:
44+ name: github-pages
45+ url: ${{ steps.deployment.outputs.page_url }}
46+ runs-on: ubuntu-latest
47+ needs: build
48+ steps:
49+ - name: Deploy to GitHub Pages
50+ id: deployment
51+ uses: actions/deploy-pages@v4
.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+4−0View file
@@ -0,0 +1,4 @@
1+# Finite Field Visualizer
2+
3+[View the live visualizer](https://visposts.github.io/finite-field-visualizer/)
4+
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="/finite-field-logo.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>Finite Field 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": "finite-field-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/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>
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+165−0View file
@@ -0,0 +1,165 @@
1+import { useState, useEffect } from 'react';
2+import { createTheme, CssBaseline, ThemeProvider, Typography, Button, Box, CircularProgress, Select, MenuItem, FormControl, InputLabel, FormControlLabel, Checkbox, Alert } from '@mui/material';
3+import { MultiplicationTable } from './components/MultiplicationTable';
4+import { getNextPrime, getPreviousPrime, getInitialPrimeForViewport, PRIMES } from './utils/primeUtils';
5+
6+const theme = createTheme({
7+ palette: {
8+ mode: 'light',
9+ primary: {
10+ main: '#1976d2',
11+ },
12+ },
13+});
14+
15+function App() {
16+ const [currentPrime, setCurrentPrime] = useState(() => getInitialPrimeForViewport());
17+ const [isComputing, setIsComputing] = useState(true);
18+ const [showColorBar, setShowColorBar] = useState(false);
19+ const [isMobile, setIsMobile] = useState(false);
20+
21+ // Detect mobile device
22+ useEffect(() => {
23+ const checkMobile = () => {
24+ const isMobileDevice = window.innerWidth < 768; // Standard tablet/mobile breakpoint
25+ setIsMobile(isMobileDevice);
26+ };
27+
28+ checkMobile();
29+ window.addEventListener('resize', checkMobile);
30+
31+ return () => window.removeEventListener('resize', checkMobile);
32+ }, []);
33+
34+ const handleNextPrime = () => {
35+ setCurrentPrime(prev => getNextPrime(prev));
36+ };
37+
38+ const handlePreviousPrime = () => {
39+ setCurrentPrime(prev => getPreviousPrime(prev));
40+ };
41+
42+ return (
43+ <ThemeProvider theme={theme}>
44+ <CssBaseline />
45+ <Box sx={{
46+ display: 'flex',
47+ flexDirection: { xs: 'column', md: 'row' },
48+ height: '100vh',
49+ overflow: 'hidden'
50+ }}>
51+ {/* Sidebar with controls */}
52+ <Box sx={{
53+ width: { xs: '100%', md: 300 },
54+ maxHeight: { xs: 'auto', md: '100vh' },
55+ p: { xs: 2, md: 3 },
56+ borderRight: { xs: 0, md: 1 },
57+ borderBottom: { xs: 1, md: 0 },
58+ borderColor: 'divider',
59+ display: 'flex',
60+ flexDirection: 'column',
61+ gap: 2,
62+ overflowY: { xs: 'visible', md: 'auto' }
63+ }}>
64+ <Typography variant="h5" component="h1" gutterBottom>
65+ Finite Field Multiplication Table
66+ </Typography>
67+
68+ <Box>
69+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
70+ <Typography variant="h6" component="h2">
71+ Prime: {currentPrime}
72+ </Typography>
73+ {isComputing && <CircularProgress size={20} />}
74+ </Box>
75+ <Typography variant="body2" color="text.secondary" paragraph>
76+ Multiplication table modulo {currentPrime}
77+ </Typography>
78+ <Typography variant="body2" color="text.secondary" paragraph>
79+ Showing ({currentPrime - 1} × {currentPrime - 1}) grid
80+ </Typography>
81+ <FormControl fullWidth sx={{ mb: 2 }}>
82+ <InputLabel id="prime-select-label">Select Prime</InputLabel>
83+ <Select
84+ labelId="prime-select-label"
85+ id="prime-select"
86+ value={currentPrime}
87+ label="Select Prime"
88+ onChange={(e) => setCurrentPrime(e.target.value as number)}
89+ >
90+ {PRIMES.map((prime) => (
91+ <MenuItem key={prime} value={prime}>
92+ {prime}
93+ </MenuItem>
94+ ))}
95+ </Select>
96+ </FormControl>
97+ <Box sx={{ display: 'flex', gap: 1 }}>
98+ <Button
99+ variant="outlined"
100+ onClick={handlePreviousPrime}
101+ fullWidth
102+ disabled={currentPrime === PRIMES[0]}
103+ >
104+ Previous
105+ </Button>
106+ <Button
107+ variant="contained"
108+ onClick={handleNextPrime}
109+ fullWidth
110+ disabled={currentPrime === PRIMES[PRIMES.length - 1]}
111+ >
112+ Next
113+ </Button>
114+ </Box>
115+ </Box>
116+
117+ <Box sx={{ mt: 2 }}>
118+ <Typography variant="body2" color="text.secondary">
119+ Each cell (i, j) shows the value of (i × j) mod {currentPrime}
120+ </Typography>
121+ </Box>
122+
123+ {isMobile ? (
124+ <Box sx={{ mt: 2 }}>
125+ <Alert severity="info" sx={{ fontSize: '0.875rem' }}>
126+ This visualization is best viewed on a desktop.
127+ </Alert>
128+ </Box>
129+ ) : (
130+ <Box sx={{ mt: 2 }}>
131+ <FormControlLabel
132+ control={
133+ <Checkbox
134+ checked={showColorBar}
135+ onChange={(e) => setShowColorBar(e.target.checked)}
136+ />
137+ }
138+ label="Show Color Bar"
139+ />
140+ </Box>
141+ )}
142+ </Box>
143+
144+ {/* Main content area */}
145+ <Box sx={{
146+ flex: 1,
147+ display: 'flex',
148+ alignItems: 'center',
149+ justifyContent: 'center',
150+ p: { xs: 1, md: 2 },
151+ overflow: 'hidden',
152+ minHeight: 0
153+ }}>
154+ <MultiplicationTable
155+ prime={currentPrime}
156+ onLoadingChange={setIsComputing}
157+ showColorBar={showColorBar}
158+ />
159+ </Box>
160+ </Box>
161+ </ThemeProvider>
162+ );
163+}
164+
165+export default App;
src/components/MultiplicationTable.tsxadded+135−0View file
@@ -0,0 +1,135 @@
1+import { useEffect, useRef } from 'react';
2+import { Box } from '@mui/material';
3+import { valueToColor } from '../utils/colorUtils';
4+
5+interface MultiplicationTableProps {
6+ prime: number;
7+ onLoadingChange?: (isLoading: boolean) => void;
8+ showColorBar: boolean;
9+}
10+
11+export function MultiplicationTable({ prime, onLoadingChange, showColorBar }: MultiplicationTableProps) {
12+ const canvasRef = useRef<HTMLCanvasElement>(null);
13+ const colorBarRef = useRef<HTMLCanvasElement>(null);
14+ const containerRef = useRef<HTMLDivElement>(null);
15+ const workerRef = useRef<Worker | null>(null);
16+
17+ // Effect for computing multiplication table using Web Worker
18+ useEffect(() => {
19+ const canvas = canvasRef.current;
20+ if (!canvas) return;
21+
22+ const ctx = canvas.getContext('2d');
23+ if (!ctx) return;
24+
25+ // Terminate existing worker if any (this handles interruption)
26+ if (workerRef.current) {
27+ workerRef.current.terminate();
28+ }
29+
30+ // Set loading state
31+ onLoadingChange?.(true);
32+
33+ // Create new worker
34+ const worker = new Worker(new URL('../workers/multiplicationWorker.ts', import.meta.url), {
35+ type: 'module'
36+ });
37+ workerRef.current = worker;
38+
39+ // Handle messages from worker
40+ worker.onmessage = (e: MessageEvent<{ type: string; imageData: ImageData }>) => {
41+ if (e.data.type === 'complete') {
42+ const { imageData } = e.data;
43+
44+ // Set canvas dimensions to match grid size (1 pixel per cell)
45+ const gridSize = prime - 1;
46+
47+ // Resize canvas only when we have the new image ready (to prevent flickering)
48+ if (canvas.width !== gridSize || canvas.height !== gridSize) {
49+ canvas.width = gridSize;
50+ canvas.height = gridSize;
51+ }
52+
53+ // Create ImageBitmap from ImageData for efficient rendering
54+ createImageBitmap(imageData).then((bitmap) => {
55+ // Draw the bitmap at 1:1 scale (1 pixel per cell)
56+ ctx.imageSmoothingEnabled = false; // Disable smoothing for crisp pixels
57+ ctx.drawImage(bitmap, 0, 0);
58+
59+ onLoadingChange?.(false);
60+ });
61+ }
62+ };
63+
64+ // Send computation request to worker
65+ worker.postMessage({ prime });
66+
67+ // Cleanup function
68+ return () => {
69+ worker.terminate();
70+ };
71+ }, [prime]);
72+
73+ // Effect for drawing color bar (remains synchronous as it's simple)
74+ useEffect(() => {
75+ if (!showColorBar) return;
76+
77+ const colorBarCanvas = colorBarRef.current;
78+ if (!colorBarCanvas) return;
79+
80+ const colorBarCtx = colorBarCanvas.getContext('2d');
81+ if (!colorBarCtx) return;
82+
83+ // Draw color bar
84+ const colorBarHeight = 400;
85+ const colorBarWidth = 20;
86+ colorBarCanvas.width = colorBarWidth;
87+ colorBarCanvas.height = colorBarHeight;
88+
89+ // Draw gradient - use prime-1 as max value to match data range
90+ const steps = 256; // Use more steps for smoother gradient
91+ const stepHeight = colorBarHeight / steps;
92+
93+ for (let i = 0; i < steps; i++) {
94+ // Map i to value in range [0, prime-1] correctly
95+ const value = Math.floor((i / (steps - 1)) * (prime - 1));
96+ const color = valueToColor(value, prime - 1);
97+ colorBarCtx.fillStyle = color;
98+ colorBarCtx.fillRect(0, colorBarHeight - (i + 1) * stepHeight, colorBarWidth, stepHeight);
99+ }
100+
101+ // Draw border around color bar
102+ colorBarCtx.strokeStyle = '#000';
103+ colorBarCtx.strokeRect(0, 0, colorBarWidth, colorBarHeight);
104+ }, [prime, showColorBar]);
105+
106+ return (
107+ <Box ref={containerRef} sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', justifyContent: 'flex-start', height: '100%', overflow: 'auto' }}>
108+ <Box sx={{ flexShrink: 0 }}>
109+ <canvas
110+ ref={canvasRef}
111+ style={{
112+ border: '1px solid #000',
113+ display: 'block'
114+ }}
115+ />
116+ </Box>
117+ {showColorBar && (
118+ <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
119+ <Box sx={{ fontSize: '0.875rem', fontWeight: 'bold' }}>
120+ {prime - 1}
121+ </Box>
122+ <canvas
123+ ref={colorBarRef}
124+ style={{
125+ display: 'block'
126+ }}
127+ />
128+ <Box sx={{ fontSize: '0.875rem', fontWeight: 'bold' }}>
129+ 0
130+ </Box>
131+ </Box>
132+ )}
133+ </Box>
134+ );
135+}
src/index.cssadded+22−0View file
@@ -0,0 +1,22 @@
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+ margin: 0;
14+ padding: 0;
15+ height: 100vh;
16+ overflow: hidden;
17+}
18+
19+#root {
20+ height: 100vh;
21+ overflow: hidden;
22+}
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/utils/colorUtils.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+/**
2+ * Convert a value in range [0, max] to an RGB color using a smooth perceptually uniform gradient
3+ * This gradient goes from dark purple/blue (low values) to bright yellow/orange (high values)
4+ * Similar to the plasma colormap - attractive and perceptually linear
5+ */
6+export function valueToColor(value: number, max: number): string {
7+ const t = value / max;
8+
9+ // Smooth gradient from dark purple-blue to bright yellow-orange
10+ // Carefully tuned for perceptual uniformity and attractiveness
11+ let r: number, g: number, b: number;
12+
13+ // Red channel: smooth increase
14+ r = Math.floor(13 + 242 * Math.pow(t, 0.5));
15+
16+ // Green channel: gentle S-curve for smoothness
17+ g = Math.floor(8 + 247 * Math.pow(t, 1.5));
18+
19+ // Blue channel: decrease from purple to yellow
20+ b = Math.floor(135 * Math.pow(1 - t, 2));
21+
22+ // Clamp values to valid range
23+ r = Math.min(255, Math.max(0, r));
24+ g = Math.min(255, Math.max(0, g));
25+ b = Math.min(255, Math.max(0, b));
26+
27+ return `rgb(${r}, ${g}, ${b})`;
28+}
29+
30+/**
31+ * Get an array of colors for the color bar
32+ */
33+export function getColorGradient(steps: number): string[] {
34+ const colors: string[] = [];
35+ for (let i = 0; i < steps; i++) {
36+ colors.push(valueToColor(i, steps - 1));
37+ }
38+ return colors;
39+}
src/utils/primeUtils.tsadded+124−0View file
@@ -0,0 +1,124 @@
1+/**
2+ * Check if a number is prime
3+ */
4+function isPrime(n: number): boolean {
5+ if (n < 2) return false;
6+ if (n === 2) return true;
7+ if (n % 2 === 0) return false;
8+
9+ const sqrt = Math.sqrt(n);
10+ for (let i = 3; i <= sqrt; i += 2) {
11+ if (n % i === 0) return false;
12+ }
13+ return true;
14+}
15+
16+/**
17+ * Generate the first n prime numbers
18+ */
19+function generatePrimes(count: number): number[] {
20+ const primes: number[] = [];
21+ let candidate = 2;
22+
23+ while (primes.length < count) {
24+ if (isPrime(candidate)) {
25+ primes.push(candidate);
26+ }
27+ candidate++;
28+ }
29+
30+ return primes;
31+}
32+
33+/**
34+ * Precomputed list primes
35+ */
36+export const PRIMES = generatePrimes(2000);
37+
38+/**
39+ * Get the index of a prime in the precomputed list, or -1 if not found
40+ */
41+export function getPrimeIndex(prime: number): number {
42+ return PRIMES.indexOf(prime);
43+}
44+
45+/**
46+ * Get the prime at a specific index in the precomputed list
47+ */
48+export function getPrimeAtIndex(index: number): number | undefined {
49+ return PRIMES[index];
50+}
51+
52+/**
53+ * Get the next prime in the precomputed list
54+ */
55+export function getNextPrime(currentPrime: number): number {
56+ const index = getPrimeIndex(currentPrime);
57+ if (index === -1 || index === PRIMES.length - 1) {
58+ return currentPrime; // Stay at current if not found or at end
59+ }
60+ return PRIMES[index + 1];
61+}
62+
63+/**
64+ * Get the previous prime in the precomputed list
65+ */
66+export function getPreviousPrime(currentPrime: number): number {
67+ const index = getPrimeIndex(currentPrime);
68+ if (index === -1 || index === 0) {
69+ return currentPrime; // Stay at current if not found or at start
70+ }
71+ return PRIMES[index - 1];
72+}
73+
74+/**
75+ * Calculate the best initial prime based on viewport dimensions
76+ * The grid size will be (prime - 1) × (prime - 1) pixels
77+ *
78+ * @param maxPrime - Maximum allowed prime (default 2000)
79+ * @returns The largest prime that will fit in the viewport without scrolling
80+ */
81+export function getInitialPrimeForViewport(maxPrime: number = 2000): number {
82+ // MUI's 'md' breakpoint is 900px
83+ const isMobile = window.innerWidth < 900;
84+
85+ let availableWidth: number;
86+ let availableHeight: number;
87+
88+ if (isMobile) {
89+ // Mobile layout: controls at top, full width available
90+ // Account for left/right padding (~20px total on mobile)
91+ availableWidth = window.innerWidth - 20;
92+
93+ // Account for controls section at top (approximately 400-450px) and padding
94+ // Being conservative with 500px to ensure it fits
95+ availableHeight = window.innerHeight - 500;
96+ } else {
97+ // Desktop layout: sidebar on left
98+ // Account for sidebar (300px) and padding/margins (~50px total)
99+ availableWidth = window.innerWidth - 350;
100+
101+ // Account for top/bottom padding (~50px total)
102+ availableHeight = window.innerHeight - 50;
103+ }
104+
105+ // Use the smaller dimension to ensure it fits both ways
106+ const availableSize = Math.min(availableWidth, availableHeight);
107+
108+ // Find the largest prime where (prime - 1) fits in the available space
109+ // The target grid size is availableSize, so target prime is availableSize + 1
110+ const targetPrime = availableSize + 1;
111+
112+ // Find the largest prime that doesn't exceed targetPrime or maxPrime
113+ const maxAllowedPrime = Math.min(targetPrime, maxPrime);
114+
115+ // Find the largest prime in our list that doesn't exceed the max
116+ for (let i = PRIMES.length - 1; i >= 0; i--) {
117+ if (PRIMES[i] <= maxAllowedPrime) {
118+ return PRIMES[i];
119+ }
120+ }
121+
122+ // Fallback to smallest prime if viewport is too small
123+ return PRIMES[0];
124+}
src/workers/multiplicationWorker.tsadded+60−0View file
@@ -0,0 +1,60 @@
1+/**
2+ * Web Worker for computing multiplication table bitmap
3+ * Computes (prime-1) x (prime-1) ImageData and transfers it back to main thread
4+ */
5+
6+// Inline color computation function (copied from colorUtils.ts)
7+function valueToColor(value: number, max: number): { r: number; g: number; b: number } {
8+ const t = value / max;
9+
10+ // Smooth gradient from dark purple-blue to bright yellow-orange
11+ let r: number, g: number, b: number;
12+
13+ // Red channel: smooth increase
14+ r = Math.floor(13 + 242 * Math.pow(t, 0.5));
15+
16+ // Green channel: gentle S-curve for smoothness
17+ g = Math.floor(8 + 247 * Math.pow(t, 1.5));
18+
19+ // Blue channel: decrease from purple to yellow
20+ b = Math.floor(135 * Math.pow(1 - t, 2));
21+
22+ // Clamp values to valid range
23+ r = Math.min(255, Math.max(0, r));
24+ g = Math.min(255, Math.max(0, g));
25+ b = Math.min(255, Math.max(0, b));
26+
27+ return { r, g, b };
28+}
29+
30+self.onmessage = (e: MessageEvent<{ prime: number }>) => {
31+ const { prime } = e.data;
32+
33+ // Create ImageData for (prime-1) x (prime-1) grid
34+ const size = prime - 1;
35+ const imageData = new ImageData(size, size);
36+ const data = imageData.data;
37+
38+ // Compute multiplication table and fill ImageData
39+ for (let i = 1; i < prime; i++) {
40+ for (let j = 1; j < prime; j++) {
41+ const value = (i * j) % prime;
42+ const color = valueToColor(value, prime - 1);
43+
44+ // Calculate pixel index (RGBA format)
45+ const pixelIndex = ((i - 1) * size + (j - 1)) * 4;
46+
47+ data[pixelIndex] = color.r; // Red
48+ data[pixelIndex + 1] = color.g; // Green
49+ data[pixelIndex + 2] = color.b; // Blue
50+ data[pixelIndex + 3] = 255; // Alpha (fully opaque)
51+ }
52+ }
53+
54+ // Transfer ImageData back to main thread using transferable objects
55+ self.postMessage(
56+ { type: 'complete', imageData },
57+ // @ts-expect-error - TypeScript doesn't recognize this overload but it's valid
58+ [imageData.data.buffer]
59+ );
60+};
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+8−0View file
@@ -0,0 +1,8 @@
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+ base: '/finite-field-visualizer/',
8+})