initial
20 changed files+6416−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+# GCD Visualizer
2+
3+[View the live visualizer](https://visposts.github.io/gcd-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="/gcd-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>GCD 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": "gcd-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>
src/App.tsxadded+162−0View file
@@ -0,0 +1,162 @@
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 { GCDTable } from './components/GCDTable';
4+import { getNextInteger, getPreviousInteger, getInitialIntegerForViewport, INTEGERS } from './utils/integerUtils';
5+
6+const theme = createTheme({
7+ palette: {
8+ mode: 'light',
9+ primary: {
10+ main: '#1976d2',
11+ },
12+ },
13+});
14+
15+function App() {
16+ const [currentInteger, setCurrentInteger] = useState(() => getInitialIntegerForViewport());
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 handleNextInteger = () => {
35+ setCurrentInteger(prev => getNextInteger(prev));
36+ };
37+
38+ const handlePreviousInteger = () => {
39+ setCurrentInteger(prev => getPreviousInteger(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+ GCD Table
66+ </Typography>
67+
68+ <Box>
69+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
70+ {isComputing && <CircularProgress size={20} />}
71+ </Box>
72+ <Typography variant="body2" color="text.secondary" paragraph>
73+ Log GCD table for integers 1 to {currentInteger}
74+ </Typography>
75+ <Typography variant="body2" color="text.secondary" paragraph>
76+ Showing ({currentInteger} × {currentInteger}) grid
77+ </Typography>
78+ <FormControl fullWidth sx={{ mb: 2 }}>
79+ <InputLabel id="integer-select-label">Select Size</InputLabel>
80+ <Select
81+ labelId="integer-select-label"
82+ id="integer-select"
83+ value={currentInteger}
84+ label="Select Integer"
85+ onChange={(e) => setCurrentInteger(e.target.value as number)}
86+ >
87+ {INTEGERS.map((integer) => (
88+ <MenuItem key={integer} value={integer}>
89+ {integer}
90+ </MenuItem>
91+ ))}
92+ </Select>
93+ </FormControl>
94+ <Box sx={{ display: 'flex', gap: 1 }}>
95+ <Button
96+ variant="outlined"
97+ onClick={handlePreviousInteger}
98+ fullWidth
99+ disabled={currentInteger === INTEGERS[0]}
100+ >
101+ Previous
102+ </Button>
103+ <Button
104+ variant="contained"
105+ onClick={handleNextInteger}
106+ fullWidth
107+ disabled={currentInteger === INTEGERS[INTEGERS.length - 1]}
108+ >
109+ Next
110+ </Button>
111+ </Box>
112+ </Box>
113+
114+ <Box sx={{ mt: 2 }}>
115+ <Typography variant="body2" color="text.secondary">
116+ Each cell (i, j) shows the value of log(gcd(i, j))
117+ </Typography>
118+ </Box>
119+
120+ {isMobile ? (
121+ <Box sx={{ mt: 2 }}>
122+ <Alert severity="info" sx={{ fontSize: '0.875rem' }}>
123+ This visualization is best viewed on a desktop.
124+ </Alert>
125+ </Box>
126+ ) : (
127+ <Box sx={{ mt: 2 }}>
128+ <FormControlLabel
129+ control={
130+ <Checkbox
131+ checked={showColorBar}
132+ onChange={(e) => setShowColorBar(e.target.checked)}
133+ />
134+ }
135+ label="Show Color Bar"
136+ />
137+ </Box>
138+ )}
139+ </Box>
140+
141+ {/* Main content area */}
142+ <Box sx={{
143+ flex: 1,
144+ display: 'flex',
145+ alignItems: 'center',
146+ justifyContent: 'center',
147+ p: { xs: 1, md: 2 },
148+ overflow: 'hidden',
149+ minHeight: 0
150+ }}>
151+ <GCDTable
152+ n={currentInteger}
153+ onLoadingChange={setIsComputing}
154+ showColorBar={showColorBar}
155+ />
156+ </Box>
157+ </Box>
158+ </ThemeProvider>
159+ );
160+}
161+
162+export default App;
src/components/GCDTable.tsxadded+136−0View file
@@ -0,0 +1,136 @@
1+import { useEffect, useRef } from 'react';
2+import { Box } from '@mui/material';
3+import { valueToColor } from '../utils/colorUtils';
4+
5+interface GCDTableProps {
6+ n: number;
7+ onLoadingChange?: (isLoading: boolean) => void;
8+ showColorBar: boolean;
9+}
10+
11+export function GCDTable({ n, onLoadingChange, showColorBar }: GCDTableProps) {
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 GCD 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/gcdWorker.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 = n;
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({ n });
66+
67+ // Cleanup function
68+ return () => {
69+ worker.terminate();
70+ };
71+ }, [n, onLoadingChange]);
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 log(n) as max value to match data range
90+ const maxLogValue = Math.log(n);
91+ const steps = 256; // Use more steps for smoother gradient
92+ const stepHeight = colorBarHeight / steps;
93+
94+ for (let i = 0; i < steps; i++) {
95+ // Map i to value in range [0, log(n)] correctly
96+ const value = (i / (steps - 1)) * maxLogValue;
97+ const color = valueToColor(value, maxLogValue);
98+ colorBarCtx.fillStyle = color;
99+ colorBarCtx.fillRect(0, colorBarHeight - (i + 1) * stepHeight, colorBarWidth, stepHeight);
100+ }
101+
102+ // Draw border around color bar
103+ colorBarCtx.strokeStyle = '#000';
104+ colorBarCtx.strokeRect(0, 0, colorBarWidth, colorBarHeight);
105+ }, [n, showColorBar]);
106+
107+ return (
108+ <Box ref={containerRef} sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', justifyContent: 'flex-start', height: '100%', overflow: 'auto' }}>
109+ <Box sx={{ flexShrink: 0 }}>
110+ <canvas
111+ ref={canvasRef}
112+ style={{
113+ border: '1px solid #000',
114+ display: 'block'
115+ }}
116+ />
117+ </Box>
118+ {showColorBar && (
119+ <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
120+ <Box sx={{ fontSize: '0.875rem', fontWeight: 'bold' }}>
121+ log({n}) ≈ {Math.log(n).toFixed(2)}
122+ </Box>
123+ <canvas
124+ ref={colorBarRef}
125+ style={{
126+ display: 'block'
127+ }}
128+ />
129+ <Box sx={{ fontSize: '0.875rem', fontWeight: 'bold' }}>
130+ 0
131+ </Box>
132+ </Box>
133+ )}
134+ </Box>
135+ );
136+}
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/integerUtils.tsadded+103−0View file
@@ -0,0 +1,103 @@
1+/**
2+ * Generate list of integers from 10 to 2000 in increments of 10
3+ */
4+function generateIntegers(start: number, end: number, step: number): number[] {
5+ const integers: number[] = [];
6+ for (let i = start; i <= end; i += step) {
7+ integers.push(i);
8+ }
9+ return integers;
10+}
11+
12+/**
13+ * Precomputed list of integers (10, 20, 30, ..., 2000)
14+ */
15+export const INTEGERS = generateIntegers(10, 2000, 10);
16+
17+/**
18+ * Get the index of an integer in the precomputed list, or -1 if not found
19+ */
20+export function getIntegerIndex(integer: number): number {
21+ return INTEGERS.indexOf(integer);
22+}
23+
24+/**
25+ * Get the integer at a specific index in the precomputed list
26+ */
27+export function getIntegerAtIndex(index: number): number | undefined {
28+ return INTEGERS[index];
29+}
30+
31+/**
32+ * Get the next integer in the precomputed list
33+ */
34+export function getNextInteger(currentInteger: number): number {
35+ const index = getIntegerIndex(currentInteger);
36+ if (index === -1 || index === INTEGERS.length - 1) {
37+ return currentInteger; // Stay at current if not found or at end
38+ }
39+ return INTEGERS[index + 1];
40+}
41+
42+/**
43+ * Get the previous integer in the precomputed list
44+ */
45+export function getPreviousInteger(currentInteger: number): number {
46+ const index = getIntegerIndex(currentInteger);
47+ if (index === -1 || index === 0) {
48+ return currentInteger; // Stay at current if not found or at start
49+ }
50+ return INTEGERS[index - 1];
51+}
52+
53+/**
54+ * Calculate the best initial integer based on viewport dimensions
55+ * The grid size will be n × n pixels
56+ *
57+ * @param maxInteger - Maximum allowed integer (default 2000)
58+ * @returns The largest integer that will fit in the viewport without scrolling
59+ */
60+export function getInitialIntegerForViewport(maxInteger: number = 2000): number {
61+ // MUI's 'md' breakpoint is 900px
62+ const isMobile = window.innerWidth < 900;
63+
64+ let availableWidth: number;
65+ let availableHeight: number;
66+
67+ if (isMobile) {
68+ // Mobile layout: controls at top, full width available
69+ // Account for left/right padding (~20px total on mobile)
70+ availableWidth = window.innerWidth - 20;
71+
72+ // Account for controls section at top (approximately 400-450px) and padding
73+ // Being conservative with 500px to ensure it fits
74+ availableHeight = window.innerHeight - 500;
75+ } else {
76+ // Desktop layout: sidebar on left
77+ // Account for sidebar (300px) and padding/margins (~50px total)
78+ availableWidth = window.innerWidth - 350;
79+
80+ // Account for top/bottom padding (~50px total)
81+ availableHeight = window.innerHeight - 50;
82+ }
83+
84+ // Use the smaller dimension to ensure it fits both ways
85+ const availableSize = Math.min(availableWidth, availableHeight);
86+
87+ // Find the largest integer where n fits in the available space
88+ // Round down to nearest multiple of 10
89+ const targetInteger = Math.floor(availableSize / 10) * 10;
90+
91+ // Find the largest integer in our list that doesn't exceed the target or max
92+ const maxAllowedInteger = Math.min(targetInteger, maxInteger);
93+
94+ // Find the largest integer in our list that doesn't exceed the max
95+ for (let i = INTEGERS.length - 1; i >= 0; i--) {
96+ if (INTEGERS[i] <= maxAllowedInteger) {
97+ return INTEGERS[i];
98+ }
99+ }
100+
101+ // Fallback to smallest integer if viewport is too small
102+ return INTEGERS[0];
103+}
src/workers/gcdWorker.tsadded+85−0View file
@@ -0,0 +1,85 @@
1+/**
2+ * Web Worker for computing GCD table bitmap
3+ * Computes n x n 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+// Compute GCD using Euclidean algorithm
31+function gcd(a: number, b: number): number {
32+ while (b !== 0) {
33+ const temp = b;
34+ b = a % b;
35+ a = temp;
36+ }
37+ return a;
38+}
39+
40+self.onmessage = (e: MessageEvent<{ n: number }>) => {
41+ const { n } = e.data;
42+
43+ // Create ImageData for n x n grid
44+ const imageData = new ImageData(n, n);
45+ const data = imageData.data;
46+
47+ // Compute GCD table and fill ImageData
48+ // First pass: compute log(gcd) values and find max
49+ const logValues: number[] = new Array(n * n);
50+ let maxLogValue = 0;
51+
52+ for (let i = 1; i <= n; i++) {
53+ for (let j = 1; j <= n; j++) {
54+ const gcdValue = gcd(i, j);
55+ const logValue = Math.log(gcdValue);
56+ const index = (i - 1) * n + (j - 1);
57+ logValues[index] = logValue;
58+ maxLogValue = Math.max(maxLogValue, logValue);
59+ }
60+ }
61+
62+ // Second pass: map log values to colors
63+ for (let i = 1; i <= n; i++) {
64+ for (let j = 1; j <= n; j++) {
65+ const index = (i - 1) * n + (j - 1);
66+ const logValue = logValues[index];
67+ const color = valueToColor(logValue, maxLogValue);
68+
69+ // Calculate pixel index (RGBA format)
70+ const pixelIndex = index * 4;
71+
72+ data[pixelIndex] = color.r; // Red
73+ data[pixelIndex + 1] = color.g; // Green
74+ data[pixelIndex + 2] = color.b; // Blue
75+ data[pixelIndex + 3] = 255; // Alpha (fully opaque)
76+ }
77+ }
78+
79+ // Transfer ImageData back to main thread using transferable objects
80+ self.postMessage(
81+ { type: 'complete', imageData },
82+ // @ts-expect-error - TypeScript doesn't recognize this overload but it's valid
83+ [imageData.data.buffer]
84+ );
85+};
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: '/gcd-visualizer/',
8+})